From a7391893404c1491ea5acc8f2000d14e311abfc9 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Fri, 10 Apr 2026 20:32:46 -0500 Subject: [PATCH 001/119] feat(types): add Action::StickyMod variant for sticky modifier behavior --- rmk-types/src/action/mod.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/rmk-types/src/action/mod.rs b/rmk-types/src/action/mod.rs index 5eb4d5433..42689d66e 100644 --- a/rmk-types/src/action/mod.rs +++ b/rmk-types/src/action/mod.rs @@ -75,6 +75,9 @@ pub enum Action { Special(SpecialKey), /// User Keys User(u8), + /// Sticky modifier: sends key + modifier on press, holds modifier until + /// another key is pressed or layer changes. Used for Alt+Tab-like switching. + StickyMod(KeyCode, ModifierCombination), /// A Plover HID stenography key. Press/release of this key updates the /// in-progress steno chord; on first release the accumulated chord is /// sent to the host as a vendor HID report. From 30b57b0a7fb81fd192ffd26aea14f169ff7fa1e9 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:03:34 -0500 Subject: [PATCH 002/119] feat(keyboard): add sticky_mod module with state machine and processing logic --- rmk/src/keyboard/sticky_mod.rs | 91 ++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 rmk/src/keyboard/sticky_mod.rs diff --git a/rmk/src/keyboard/sticky_mod.rs b/rmk/src/keyboard/sticky_mod.rs new file mode 100644 index 000000000..d9586e7b5 --- /dev/null +++ b/rmk/src/keyboard/sticky_mod.rs @@ -0,0 +1,91 @@ +//! StickyMod action implementation +//! +//! StickyMod provides Alt+Tab-like window/tab switching behavior. +//! On first press: sends modifier + key. On release: holds modifier. +//! Subsequent presses send only the key. Modifier releases when any +//! non-SM/non-modifier key is pressed, or when the layer changes. + +use rmk_types::keycode::{HidKeyCode, KeyCode}; +use rmk_types::modifier::ModifierCombination; + +use crate::event::KeyboardEvent; +use crate::keyboard::Keyboard; + +/// State for StickyMod action +#[derive(Default, Debug)] +pub(crate) enum StickyModState { + /// StickyMod is inactive + #[default] + None, + /// StickyMod is active — modifier is being held + Active(ModifierCombination), +} + +impl StickyModState { + /// Get the held modifiers if StickyMod is active + pub fn value(&self) -> Option<&ModifierCombination> { + match self { + StickyModState::Active(mods) => Some(mods), + StickyModState::None => None, + } + } + + /// Check if StickyMod is currently active + pub fn is_active(&self) -> bool { + matches!(self, StickyModState::Active(_)) + } +} + +impl Keyboard<'_> { + /// Process StickyMod action + /// + /// Flow: + /// - First press: activate SM state, register modifier + key, send report + /// - Release: unregister key, keep modifier held (via state + resolve_explicit_modifiers) + /// - Subsequent press: key already held by state, just register key again + /// - Subsequent release: unregister key, modifier stays + /// - Any non-SM/non-modifier key press: release_sticky_mod_if_active() called before processing + /// - Layer change: release_sticky_mod_if_active() called as cleanup + pub(crate) async fn process_action_sticky_mod( + &mut self, + key: KeyCode, + modifiers: ModifierCombination, + event: KeyboardEvent, + ) { + if event.pressed { + // Activate SM if not already active (first press) + // If already active with same or different modifier, keep current state + // (different SM key while active: first SM was already released by the + // any-key-press check in process_key_action_normal before we get here, + // so this is always a fresh activation) + if let StickyModState::None = self.sticky_mod_state { + self.sticky_mod_state = StickyModState::Active(modifiers); + } + + // Register the key (e.g., Tab) — modifier comes from resolve_explicit_modifiers + if let KeyCode::Hid(hid_key) = key { + self.register_key(hid_key, event); + } + self.send_keyboard_report_with_resolved_modifiers(true).await; + } else { + // Release the key, modifier stays held via StickyModState::Active + if let KeyCode::Hid(hid_key) = key { + self.unregister_key(hid_key, event); + } + self.send_keyboard_report_with_resolved_modifiers(false).await; + } + } + + /// Release StickyMod if active. Called when: + /// - A non-SM, non-modifier key is pressed + /// - A layer is deactivated + /// - Timeout expires (Phase 2) + pub(crate) async fn release_sticky_mod_if_active(&mut self) { + if self.sticky_mod_state.is_active() { + debug!("Releasing StickyMod"); + self.sticky_mod_state = StickyModState::None; + // Send report to reflect modifier release + self.send_keyboard_report_with_resolved_modifiers(false).await; + } + } +} From cf1aea81141a66560f75a2042a988bd905420a12 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:07:13 -0500 Subject: [PATCH 003/119] feat(macro): add sm!() macro for StickyMod key actions --- rmk/src/layout_macro.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/rmk/src/layout_macro.rs b/rmk/src/layout_macro.rs index 982ab74ff..8c7ad7907 100644 --- a/rmk/src/layout_macro.rs +++ b/rmk/src/layout_macro.rs @@ -355,6 +355,32 @@ macro_rules! osm { }; } +/// Create a StickyMod action for Alt+Tab-like switching. +/// +/// Sends modifier + key on first press, holds modifier across subsequent presses. +/// Modifier releases when any other key is pressed or layer changes. +/// +/// # Parameters +/// - `$x`: HID keycode identifier (e.g., `Tab`, `A`) +/// - `$m`: `ModifierCombination` to hold +/// +/// # Example +/// ```ignore +/// // Alt+Tab window switcher +/// sm!(Tab, ModifierCombination::LALT) +/// // Ctrl+Tab browser tab switcher +/// sm!(Tab, ModifierCombination::LCTRL) +/// ``` +#[macro_export] +macro_rules! sm { + ($x: ident, $m: expr) => { + $crate::types::action::KeyAction::Single($crate::types::action::Action::StickyMod( + $crate::types::keycode::KeyCode::Hid($crate::types::keycode::HidKeyCode::$x), + $m, + )) + }; +} + /// Create a layer toggle action. /// /// This macro creates a key that toggles a layer on/off with each press. From be07b53eb57f8ecf61ae748b2ce625a63f99b80d Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:15:10 -0500 Subject: [PATCH 004/119] feat(keyboard): integrate StickyMod state, dispatch, modifier resolution, and release triggers --- rmk/src/keyboard.rs | 43 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/rmk/src/keyboard.rs b/rmk/src/keyboard.rs index 7744cbfa2..00839c69a 100644 --- a/rmk/src/keyboard.rs +++ b/rmk/src/keyboard.rs @@ -29,6 +29,7 @@ use crate::keyboard::fork::ActiveFork; use crate::keyboard::held_buffer::{HeldBuffer, HeldKey, KeyState}; use crate::keyboard::mouse::{MouseAction, MouseState}; use crate::keyboard::oneshot::OneShotState; +use crate::keyboard::sticky_mod::StickyModState; use crate::keyboard_macros::MacroOperation; use crate::keymap::KeyMap; #[cfg(all(feature = "split", feature = "_ble"))] @@ -43,6 +44,7 @@ pub(crate) mod mouse; pub(crate) mod oneshot; #[cfg(feature = "steno")] pub(crate) mod steno; +pub(crate) mod sticky_mod; use crate::keymap::HOLD_BUFFER_SIZE; @@ -206,6 +208,9 @@ pub struct Keyboard<'a> { /// Oneshot Modifier state osm_state: OneShotState, + /// StickyMod state — holds modifier across key presses for Alt+Tab-like behavior + sticky_mod_state: StickyModState, + /// Caps Word state machine caps_word: CapsWordState, @@ -259,6 +264,7 @@ impl<'a> Keyboard<'a> { last_press_time: Instant::now(), osl_state: OneShotState::default(), osm_state: OneShotState::default(), + sticky_mod_state: StickyModState::default(), caps_word: CapsWordState::default(), with_modifiers: ModifierCombination::default(), macro_texting: false, @@ -1200,21 +1206,29 @@ impl<'a> Keyboard<'a> { }) .await; + // Release StickyMod when any non-SM, non-modifier key is pressed. + // Modifier keys (Shift, Ctrl, etc.) are excluded so Shift+Tab reverse cycling works. + if event.pressed + && !matches!(action, Action::StickyMod(_, _) | Action::Modifier(_)) + && self.sticky_mod_state.is_active() + { + self.release_sticky_mod_if_active().await; + } + match action { Action::No => {} Action::Key(key) => self.process_action_key(key, event).await, - Action::LayerOn(layer_num) => self.process_action_layer_switch(layer_num, event), + Action::LayerOn(layer_num) => self.process_action_layer_switch(layer_num, event).await, Action::LayerOff(layer_num) => { - // Turn off a layer temporarily when the key is pressed - // Reactivate the layer after the key is released if event.pressed { self.keymap.deactivate_layer(layer_num); + self.release_sticky_mod_if_active().await; } } Action::LayerToggle(layer_num) => { - // Toggle a layer when the key is release if !event.pressed { self.keymap.toggle_layer(layer_num); + self.release_sticky_mod_if_active().await; } } Action::LayerToggleOnly(layer_num) => { @@ -1230,11 +1244,12 @@ impl<'a> Keyboard<'a> { } // Activate the target layer self.keymap.activate_layer(layer_num); + self.release_sticky_mod_if_active().await; } } Action::DefaultLayer(layer_num) => { - // Set the default layer self.keymap.set_default_layer(layer_num); + self.release_sticky_mod_if_active().await; } Action::Modifier(modifiers) => { if event.pressed { @@ -1269,7 +1284,7 @@ impl<'a> Keyboard<'a> { // they will be "released" the same time as the key (in same hid report) self.held_modifiers &= !(modifiers); } - self.process_action_layer_switch(layer_num, event); + self.process_action_layer_switch(layer_num, event).await; self.send_keyboard_report_with_resolved_modifiers(event.pressed).await } Action::OneShotLayer(l) => { @@ -1282,6 +1297,9 @@ impl<'a> Keyboard<'a> { // Process OSL to avoid the OSM state stuck when an OSM is followed by an OSL self.update_osl(event); } + Action::StickyMod(key, modifiers) => { + self.process_action_sticky_mod(key, modifiers, event).await; + } Action::OneShotKey(_k) => warn!("One-shot key is not supported: {:?}", action), Action::Light(_light_action) => warn!("Light controll is not supported"), Action::KeyboardControl(c) => self.process_action_keyboard_control(c, event).await, @@ -1289,12 +1307,12 @@ impl<'a> Keyboard<'a> { Action::User(id) => self.process_user(id, event).await, Action::TriLayerLower => { // Tri-layer lower, turn layer 1 on and update layer state - self.process_action_layer_switch(1, event); + self.process_action_layer_switch(1, event).await; self.keymap.update_fn_layer_state(); } Action::TriLayerUpper => { // Tri-layer upper, turn layer 2 on and update layer state - self.process_action_layer_switch(2, event); + self.process_action_layer_switch(2, event).await; self.keymap.update_fn_layer_state(); } #[cfg(feature = "steno")] @@ -1350,6 +1368,11 @@ impl<'a> Keyboard<'a> { result |= osm; }; + // Add StickyMod modifiers if active + if let Some(sm_mods) = self.sticky_mod_state.value() { + result |= *sm_mods; + } + result } @@ -1542,12 +1565,14 @@ impl<'a> Keyboard<'a> { } /// Process layer switch action. - fn process_action_layer_switch(&mut self, layer_num: u8, event: KeyboardEvent) { + async fn process_action_layer_switch(&mut self, layer_num: u8, event: KeyboardEvent) { // Change layer state only when the key's state is changed if event.pressed { self.keymap.activate_layer(layer_num); } else { self.keymap.deactivate_layer(layer_num); + // Clean up StickyMod when layer deactivates + self.release_sticky_mod_if_active().await; } } From baf588d6e97110201757af94368ba35cc5ae2248 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:23:31 -0500 Subject: [PATCH 005/119] fix: restore removed comments and clean up unused import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restore pre-existing comments in LayerOff, LayerToggle, DefaultLayer arms - Fix typo in LayerToggle comment ("release" → "released") - Remove unused HidKeyCode import from sticky_mod.rs --- rmk/src/keyboard.rs | 4 ++++ rmk/src/keyboard/sticky_mod.rs | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/rmk/src/keyboard.rs b/rmk/src/keyboard.rs index 00839c69a..51bf2eaaf 100644 --- a/rmk/src/keyboard.rs +++ b/rmk/src/keyboard.rs @@ -1220,12 +1220,15 @@ impl<'a> Keyboard<'a> { Action::Key(key) => self.process_action_key(key, event).await, Action::LayerOn(layer_num) => self.process_action_layer_switch(layer_num, event).await, Action::LayerOff(layer_num) => { + // Turn off a layer temporarily when the key is pressed + // Reactivate the layer after the key is released if event.pressed { self.keymap.deactivate_layer(layer_num); self.release_sticky_mod_if_active().await; } } Action::LayerToggle(layer_num) => { + // Toggle a layer when the key is released if !event.pressed { self.keymap.toggle_layer(layer_num); self.release_sticky_mod_if_active().await; @@ -1248,6 +1251,7 @@ impl<'a> Keyboard<'a> { } } Action::DefaultLayer(layer_num) => { + // Set the default layer self.keymap.set_default_layer(layer_num); self.release_sticky_mod_if_active().await; } diff --git a/rmk/src/keyboard/sticky_mod.rs b/rmk/src/keyboard/sticky_mod.rs index d9586e7b5..5cf9733f1 100644 --- a/rmk/src/keyboard/sticky_mod.rs +++ b/rmk/src/keyboard/sticky_mod.rs @@ -5,7 +5,7 @@ //! Subsequent presses send only the key. Modifier releases when any //! non-SM/non-modifier key is pressed, or when the layer changes. -use rmk_types::keycode::{HidKeyCode, KeyCode}; +use rmk_types::keycode::KeyCode; use rmk_types::modifier::ModifierCombination; use crate::event::KeyboardEvent; From fdd0b9791e024f36e17e9556617cf46f1ad2c566 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:26:19 -0500 Subject: [PATCH 006/119] feat(config): add SM(key, modifier) TOML syntax for StickyMod --- rmk-config/src/keymap.pest | 5 +++- rmk-config/src/layout.rs | 5 ++++ rmk-macro/src/codegen/action_parser.rs | 32 ++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/rmk-config/src/keymap.pest b/rmk-config/src/keymap.pest index 06efd1b4b..b9c6ba2a4 100644 --- a/rmk-config/src/keymap.pest +++ b/rmk-config/src/keymap.pest @@ -102,12 +102,15 @@ morse_action = { (^"TD" | ^"MORSE") ~ "(" ~ number ~ ")" } // Rule 9: Macro(n) - Trigger Macro trigger_macro_action = { ^"MACRO" ~ "(" ~ number ~ ")" } +// Rule 10: SM(key, modifier) - Sticky Modifier Action (holds modifier, taps key) +sm_action = { ^"SM" ~ "(" ~ keycode_name ~ "," ~ modifier_combination ~ ")" } + // --- Top Level Rules --- // 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 | osm_action | layer_action | mt_action | th_action | shifted_action | morse_action | trigger_macro_action | no_action | transparent_action | simple_keycode + wm_action | osm_action | layer_action | mt_action | th_action | shifted_action | morse_action | trigger_macro_action | sm_action | no_action | transparent_action | simple_keycode } // The entire key map string: Start, zero or more key actions, End. diff --git a/rmk-config/src/layout.rs b/rmk-config/src/layout.rs index b65db449d..a5d082225 100644 --- a/rmk-config/src/layout.rs +++ b/rmk-config/src/layout.rs @@ -396,6 +396,11 @@ impl KeyboardTomlConfig { key_action_sequence.push(action); } + Rule::sm_action => { + let action = inner_pair.as_str().to_string(); + key_action_sequence.push(action); + } + Rule::wm_action => { let action = inner_pair.as_str().to_string(); key_action_sequence.push(action); diff --git a/rmk-macro/src/codegen/action_parser.rs b/rmk-macro/src/codegen/action_parser.rs index fcc0055e3..c9680841a 100644 --- a/rmk-macro/src/codegen/action_parser.rs +++ b/rmk-macro/src/codegen/action_parser.rs @@ -223,6 +223,38 @@ pub(crate) fn parse_key( ); } } + s if s.to_lowercase().starts_with("sm(") => { + let prefix = s.get(0..3).unwrap(); + if let Some(internal) = s.trim_start_matches(prefix).strip_suffix(")") { + let keys: Vec<&str> = internal + .split_terminator(",") + .map(|w| w.trim()) + .filter(|w| !w.is_empty()) + .collect(); + if keys.len() != 2 { + panic!( + "\n\u{274c} keyboard.toml: SM(key, modifier) requires exactly 2 arguments, got {}. Usage: SM(Tab, LAlt)", + keys.len() + ); + } + + let ident = get_key_with_alias(keys[0].to_string()); + let modifiers = parse_modifiers(keys[1]); + + if modifiers.is_empty() { + panic!( + "\n\u{274c} keyboard.toml: modifier in SM(key, modifier) is not valid! Usage: SM(Tab, LAlt)" + ); + } + quote! { + ::rmk::sm!(#ident, #modifiers) + } + } else { + panic!( + "\n\u{274c} keyboard.toml: SM(key, modifier) invalid. Usage: SM(Tab, LAlt)" + ); + } + } s if s.to_lowercase().starts_with("lm(") => { let prefix = s.get(0..3).unwrap(); if let Some(internal) = s.trim_start_matches(prefix).strip_suffix(")") { From 53db33240d361c9b2686225e55b6433ec93c5c21 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Fri, 10 Apr 2026 21:32:20 -0500 Subject: [PATCH 007/119] fix(keyboard): also exclude modifier HID keycodes from StickyMod release 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. --- rmk/src/keyboard.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/rmk/src/keyboard.rs b/rmk/src/keyboard.rs index 51bf2eaaf..217c5e74e 100644 --- a/rmk/src/keyboard.rs +++ b/rmk/src/keyboard.rs @@ -1208,11 +1208,15 @@ impl<'a> Keyboard<'a> { // Release StickyMod when any non-SM, non-modifier key is pressed. // Modifier keys (Shift, Ctrl, etc.) are excluded so Shift+Tab reverse cycling works. - if event.pressed - && !matches!(action, Action::StickyMod(_, _) | Action::Modifier(_)) - && self.sticky_mod_state.is_active() - { - self.release_sticky_mod_if_active().await; + if event.pressed && self.sticky_mod_state.is_active() { + let is_sm_or_modifier = match action { + Action::StickyMod(_, _) | Action::Modifier(_) => true, + Action::Key(KeyCode::Hid(hid_key)) if hid_key.is_modifier() => true, + _ => false, + }; + if !is_sm_or_modifier { + self.release_sticky_mod_if_active().await; + } } match action { From f8937dfa27bf14d88da51ab382f6f11e77434472 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Sat, 11 Apr 2026 10:10:07 -0500 Subject: [PATCH 008/119] test: add integration tests for StickyMod action 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. --- rmk/tests/keyboard_sticky_mod_test.rs | 204 ++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 rmk/tests/keyboard_sticky_mod_test.rs diff --git a/rmk/tests/keyboard_sticky_mod_test.rs b/rmk/tests/keyboard_sticky_mod_test.rs new file mode 100644 index 000000000..d1c86af8a --- /dev/null +++ b/rmk/tests/keyboard_sticky_mod_test.rs @@ -0,0 +1,204 @@ +pub mod common; + +use rmk::config::{BehaviorConfig, PositionalConfig}; +use rmk::keyboard::Keyboard; +use rmk::types::action::KeyAction; +use rmk::types::modifier::ModifierCombination; +use rmk::{a, k, mo, sm}; +use rusty_fork::rusty_fork_test; + +use crate::common::{KC_LALT, KC_LCTRL, KC_LSHIFT, wrap_keymap}; + +// KEYMAP +// Layer 0: A B C MO(1) LShift No +// Layer 1: SM(Tab,LAlt) SM(Tab,LCtrl) SM(Tab,LCtrl|LShift) Transparent Transparent No + +const KEYMAP: [[[KeyAction; 6]; 1]; 2] = [ + [[ + // Layer 0 + k!(A), // col 0: A + k!(B), // col 1: B + k!(C), // col 2: C + mo!(1), // col 3: MO(1) — momentary layer + k!(LShift), // col 4: LShift + a!(No), // col 5: No + ]], + [[ + // Layer 1 + sm!(Tab, ModifierCombination::LALT), // col 0: SM(Tab, LAlt) + sm!(Tab, ModifierCombination::LCTRL), // col 1: SM(Tab, LCtrl) + sm!(Tab, ModifierCombination::new_from_vals(true, true, false, false, false, false, false, false)), // col 2: SM(Tab, LCtrl|LShift) + a!(Transparent), // col 3: Transparent + a!(Transparent), // col 4: Transparent → LShift + a!(No), // col 5: No + ]], +]; + +fn create_test_keyboard() -> Keyboard<'static> { + static BEHAVIOR_CONFIG: static_cell::StaticCell = static_cell::StaticCell::new(); + let behavior_config = BEHAVIOR_CONFIG.init(BehaviorConfig::default()); + static KEY_CONFIG: static_cell::StaticCell> = static_cell::StaticCell::new(); + let per_key_config = KEY_CONFIG.init(PositionalConfig::default()); + Keyboard::new(wrap_keymap(KEYMAP, per_key_config, behavior_config)) +} + +rusty_fork_test! { + /// StickyMod Test 1: Basic SM flow — press SM twice while MO held + /// + /// Sequence: + /// - Press MO(1) → layer activates, no report + /// - Press SM(Tab,LAlt) → [KC_LALT, [Tab, ...]] + /// - Release SM → [KC_LALT, [0, ...]] (modifier held) + /// - Press SM again → [KC_LALT, [Tab, ...]] + /// - Release SM → [KC_LALT, [0, ...]] + /// - Release MO(1) → [0, [0, ...]] (layer deactivation cleans up SM) + #[test] + fn test_sm_basic_flow_press_twice() { + key_sequence_test! { + keyboard: create_test_keyboard(), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SM(Tab, LAlt) + [0, 0, false, 10], // Release SM + [0, 0, true, 10], // Press SM again + [0, 0, false, 10], // Release SM + [0, 3, false, 10], // Release MO(1) + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SM press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SM release: Alt held + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SM press again: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SM release: Alt held + [0, [0, 0, 0, 0, 0, 0]], // MO release: SM cleaned up + ] + }; + } + + /// StickyMod Test 2: Layer change cleanup + /// + /// Sequence: + /// - Press MO(1), press SM(Tab,LAlt), release SM, release MO(1) + /// + /// Expected: + /// - SM press: Alt+Tab + /// - SM release: Alt held + /// - MO release: cleans up SM, sends [0, [0,...]] + #[test] + fn test_sm_layer_change_cleanup() { + key_sequence_test! { + keyboard: create_test_keyboard(), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SM(Tab, LAlt) + [0, 0, false, 10], // Release SM + [0, 3, false, 10], // Release MO(1) → triggers SM cleanup + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SM press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SM release: Alt held + [0, [0, 0, 0, 0, 0, 0]], // MO release: SM cleaned up + ] + }; + } + + /// StickyMod Test 3: Shift integration — Shift does NOT release SM + /// + /// Sequence: + /// - Press MO(1), press SM(Tab,LCtrl), release SM + /// - Press LShift (col 4, transparent → LShift) — should NOT release SM + /// - Press SM again, release SM + /// - Release LShift, release MO(1) + /// + /// Expected: + /// - SM press: Ctrl+Tab + /// - SM release: Ctrl held + /// - Shift press: Ctrl+Shift held (SM not released) + /// - SM press: Ctrl+Shift+Tab + /// - SM release: Ctrl+Shift held + /// - Shift release: Ctrl held + /// - MO release: SM cleaned up + #[test] + fn test_sm_shift_does_not_release_sm() { + key_sequence_test! { + keyboard: create_test_keyboard(), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 1, true, 10], // Press SM(Tab, LCtrl) + [0, 1, false, 10], // Release SM + [0, 4, true, 10], // Press LShift (Transparent → LShift on L0) + [0, 1, true, 10], // Press SM again + [0, 1, false, 10], // Release SM + [0, 4, false, 10], // Release LShift + [0, 3, false, 10], // Release MO(1) + ], + expected_reports: [ + [KC_LCTRL, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SM press: Ctrl+Tab + [KC_LCTRL, [0, 0, 0, 0, 0, 0]], // SM release: Ctrl held + [KC_LCTRL | KC_LSHIFT, [0, 0, 0, 0, 0, 0]], // Shift press: Ctrl+Shift (SM not released) + [KC_LCTRL | KC_LSHIFT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SM press: Ctrl+Shift+Tab + [KC_LCTRL | KC_LSHIFT, [0, 0, 0, 0, 0, 0]], // SM release: Ctrl+Shift held + [KC_LCTRL, [0, 0, 0, 0, 0, 0]], // Shift release: Ctrl held + [0, [0, 0, 0, 0, 0, 0]], // MO release: SM cleaned up + ] + }; + } + + /// StickyMod Test 4: Rapid presses — 3x SM press/release while MO held + /// + /// Sequence: + /// - Press MO(1), then 3x (press SM, release SM), release MO(1) + /// + /// Expected: Each SM press sends Alt+Tab; each release holds Alt; MO release cleans up. + #[test] + fn test_sm_rapid_three_presses() { + key_sequence_test! { + keyboard: create_test_keyboard(), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SM #1 + [0, 0, false, 10], // Release SM #1 + [0, 0, true, 10], // Press SM #2 + [0, 0, false, 10], // Release SM #2 + [0, 0, true, 10], // Press SM #3 + [0, 0, false, 10], // Release SM #3 + [0, 3, false, 10], // Release MO(1) + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SM #1 press + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SM #1 release + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SM #2 press + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SM #2 release + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SM #3 press + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SM #3 release + [0, [0, 0, 0, 0, 0, 0]], // MO release: SM cleaned up + ] + }; + } + + /// StickyMod Test 5: Combined modifiers LCtrl|LShift + /// + /// Sequence: + /// - Press MO(1), press SM(Tab,LCtrl|LShift) at col 2, release SM, release MO(1) + /// + /// Expected: + /// - SM press: Ctrl+Shift+Tab + /// - SM release: Ctrl+Shift held + /// - MO release: SM cleaned up + #[test] + fn test_sm_combined_modifiers() { + key_sequence_test! { + keyboard: create_test_keyboard(), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 2, true, 10], // Press SM(Tab, LCtrl|LShift) + [0, 2, false, 10], // Release SM + [0, 3, false, 10], // Release MO(1) + ], + expected_reports: [ + [KC_LCTRL | KC_LSHIFT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SM press: Ctrl+Shift+Tab + [KC_LCTRL | KC_LSHIFT, [0, 0, 0, 0, 0, 0]], // SM release: Ctrl+Shift held + [0, [0, 0, 0, 0, 0, 0]], // MO release: SM cleaned up + ] + }; + } +} From 9cb1988d77663bb9be220272781e7ace381e25d5 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Sat, 11 Apr 2026 10:20:23 -0500 Subject: [PATCH 009/119] feat(config): add [behavior.sticky_mod] config with optional timeout --- rmk-config/src/lib.rs | 9 +++++++++ rmk-config/src/resolved/behavior.rs | 4 ++++ rmk-macro/src/codegen/behavior.rs | 16 ++++++++++++++++ rmk/src/config/behavior.rs | 18 ++++++++++++++++++ 4 files changed, 47 insertions(+) diff --git a/rmk-config/src/lib.rs b/rmk-config/src/lib.rs index 8473bb383..70028ac7f 100644 --- a/rmk-config/src/lib.rs +++ b/rmk-config/src/lib.rs @@ -576,6 +576,7 @@ pub(crate) struct BehaviorConfig { pub macros: Option, pub fork: Option, pub morse: Option, + pub sticky_mod: Option, } /// Per Key configurations profiles for morse, tap-hold, etc. @@ -622,6 +623,14 @@ pub struct OneShotModifiersConfig { pub quick_release: Option, } +/// Configurations for sticky modifier +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StickyModConfig { + /// Timeout for sticky modifier auto-release (e.g., "5000ms", "5s") + pub timeout: Option, +} + /// Configurations for combos #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields)] diff --git a/rmk-config/src/resolved/behavior.rs b/rmk-config/src/resolved/behavior.rs index 1cdef838e..cf68d9a30 100644 --- a/rmk-config/src/resolved/behavior.rs +++ b/rmk-config/src/resolved/behavior.rs @@ -9,6 +9,7 @@ pub struct Behavior { pub macros: Option, pub forks: Option, pub morse: Option, + pub sticky_mod_timeout_ms: Option, } pub struct OneShot { @@ -201,6 +202,8 @@ impl crate::KeyboardTomlConfig { } }); + let sticky_mod_timeout_ms = toml_behavior.sticky_mod.and_then(|s| s.timeout.map(|t| t.0)); + Ok(Behavior { tri_layer, one_shot_timeout_ms, @@ -209,6 +212,7 @@ impl crate::KeyboardTomlConfig { macros, forks, morse, + sticky_mod_timeout_ms, }) } } diff --git a/rmk-macro/src/codegen/behavior.rs b/rmk-macro/src/codegen/behavior.rs index ff8854475..2eaeffd9f 100644 --- a/rmk-macro/src/codegen/behavior.rs +++ b/rmk-macro/src/codegen/behavior.rs @@ -64,6 +64,20 @@ fn expand_one_shot_modifiers(one_shot_modifiers: &Option) -> proc_macro } } +fn expand_sticky_mod(sticky_mod_timeout_ms: &Option) -> proc_macro2::TokenStream { + match sticky_mod_timeout_ms { + Some(millis) => { + let timeout = quote! { ::embassy_time::Duration::from_millis(#millis) }; + quote! { + ::rmk::config::StickyModConfig { + timeout: #timeout, + } + } + } + None => quote! { ::rmk::config::StickyModConfig::default() }, + } +} + fn expand_morse_action_pair( action_pair: &MorseActionPair, profiles: &Option>, @@ -496,6 +510,7 @@ pub(crate) fn expand_behavior_config(behavior: &Behavior) -> proc_macro2::TokenS let macros = expand_macros(&behavior.macros); let forks = expand_forks(&behavior.forks, &profiles); let morse = expand_morse(&behavior.morse); + let sticky_mod = expand_sticky_mod(&behavior.sticky_mod_timeout_ms); quote! { #[allow(clippy::needless_update)] @@ -509,6 +524,7 @@ 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_mod: #sticky_mod, }; } } diff --git a/rmk/src/config/behavior.rs b/rmk/src/config/behavior.rs index 005de08d1..7cf66883b 100644 --- a/rmk/src/config/behavior.rs +++ b/rmk/src/config/behavior.rs @@ -18,6 +18,7 @@ pub struct BehaviorConfig { pub morse: MorsesConfig, pub keyboard_macros: KeyboardMacrosConfig, pub mouse_key: MouseKeyConfig, + pub sticky_mod: StickyModConfig, } /// Configurations for tap behavior @@ -81,6 +82,23 @@ pub struct OneShotModifiersConfig { pub quick_release: bool, } +/// Configuration for StickyMod behavior +#[derive(Clone, Copy, Debug)] +pub struct StickyModConfig { + /// Timeout before automatically releasing the held modifier. + /// Duration::MAX means no timeout — modifier is held until + /// another key press or layer change. + pub timeout: Duration, +} + +impl Default for StickyModConfig { + fn default() -> Self { + Self { + timeout: Duration::MAX, + } + } +} + /// Config for combo behavior #[derive(Clone, Debug)] pub struct CombosConfig { From e4c06684a8f678072016de65d938b5940d4e40b1 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Sat, 11 Apr 2026 11:04:57 -0500 Subject: [PATCH 010/119] fix(config): export StickyModConfig from rmk::config module --- rmk/src/config/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rmk/src/config/mod.rs b/rmk/src/config/mod.rs index 3333de3d4..2aa20c21f 100644 --- a/rmk/src/config/mod.rs +++ b/rmk/src/config/mod.rs @@ -8,7 +8,7 @@ mod vial; pub use behavior::{ BehaviorConfig, CombosConfig, ForksConfig, KeyboardMacrosConfig, MorsesConfig, MouseKeyConfig, OneShotConfig, - OneShotModifiersConfig, TapConfig, + OneShotModifiersConfig, StickyModConfig, TapConfig, }; #[cfg(feature = "_ble")] pub use ble_battery::BleBatteryConfig; From 94eb66c9e60bb5e71c8a622156991f3fb8b9f06a Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Sat, 9 May 2026 00:14:51 -0500 Subject: [PATCH 011/119] fix: add rusty-fork dev dep and await in test after rebase 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). --- rmk/Cargo.toml | 1 + rmk/src/keyboard.rs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/rmk/Cargo.toml b/rmk/Cargo.toml index c1677d12f..1c7e5c856 100644 --- a/rmk/Cargo.toml +++ b/rmk/Cargo.toml @@ -94,6 +94,7 @@ critical-section = { version = "1.2", features = ["std"] } env_logger = "0.11" ctor = "1.0" embedded-hal-mock = { version = "0.11.1", features = ["embedded-hal-async"] } +rusty-fork = "0.3" [build-dependencies] crc32fast = "1.3" diff --git a/rmk/src/keyboard.rs b/rmk/src/keyboard.rs index 217c5e74e..3e30c8251 100644 --- a/rmk/src/keyboard.rs +++ b/rmk/src/keyboard.rs @@ -2223,7 +2223,7 @@ mod test { let mut keyboard = create_test_keyboard(); // Activate layer 1 - keyboard.process_action_layer_switch(1, KeyboardEvent::key(0, 0, true)); + keyboard.process_action_layer_switch(1, KeyboardEvent::key(0, 0, true)).await; // Press Transparent key (Q on lower layer) keyboard.process_inner(KeyboardEvent::key(1, 1, true)).await; From b1375e7863aaa8cabef2eb90e2bbaf96fff7d2e9 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Fri, 15 May 2026 12:24:02 -0500 Subject: [PATCH 012/119] fix/docs/test: fix DurationMillis visibility, add SM parser tests, add 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 --- docs/docs/main/docs/configuration/behavior.md | 1 + docs/docs/main/docs/configuration/layout.md | 7 +-- rmk-config/src/layout.rs | 44 +++++++++++++++++++ rmk-config/src/lib.rs | 2 +- 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/docs/docs/main/docs/configuration/behavior.md b/docs/docs/main/docs/configuration/behavior.md index e23850711..fbb5db96a 100644 --- a/docs/docs/main/docs/configuration/behavior.md +++ b/docs/docs/main/docs/configuration/behavior.md @@ -76,6 +76,7 @@ Quick-release example: quick_release = true ``` + ## Combo In the `combo` sub-table, you can configure the keyboard's combo key functionality. Combo allows you to define a group of keys that, when pressed simultaneously, will trigger a specific output action. diff --git a/docs/docs/main/docs/configuration/layout.md b/docs/docs/main/docs/configuration/layout.md index 05bd187b6..476f04bf9 100644 --- a/docs/docs/main/docs/configuration/layout.md +++ b/docs/docs/main/docs/configuration/layout.md @@ -124,9 +124,10 @@ The `layer.keys` string should follow several rules: 4. Use `LT(n, key, )` to create a layer activate action or tap key(tap/hold). The `key` here is the RMK [`KeyCode`](https://docs.rs/rmk/latest/rmk/keycode/enum.KeyCode.html), The `profile_name` is optional, which defines the key's [profile](./behavior#per-key-profiles-for-morse-tapdance-tap-hold-fine-tuning) 5. Use `OSL(n)` to create a one-shot layer action, `n` is the layer number 6. Use `OSM(modifier)` to create a one-shot modifier action. The modifier can be chained in the same way as `WM` - 7. Use `TT(n)` to create a layer activate or tap toggle action, `n` is the layer number - 8. Use `TG(n)` to create a layer toggle action, `n` is the layer number - 9. Use `TO(n)` to create a layer toggle only action (activate layer `n` and deactivate all other layers), `n` is the layer number + 7. Use `SM(key, modifier)` to create a sticky modifier action. The modifier stays held across repeated presses of `key` until any non-SM key is pressed or the layer changes — useful for Alt+Tab-style cycling. The modifier can be chained in the same way as `WM`. See [Sticky Modifiers](./behavior#sticky-modifiers) for optional timeout configuration. + 8. Use `TT(n)` to create a layer activate or tap toggle action, `n` is the layer number + 9. Use `TG(n)` to create a layer toggle action, `n` is the layer number + 10. Use `TO(n)` to create a layer toggle only action (activate layer `n` and deactivate all other layers), `n` is the layer number The definitions of these operations are the same as QMK's; you can find them [here](https://docs.qmk.fm/#/feature_layers). If you want other actions, please [file an issue](https://github.com/HaoboGu/rmk/issues/new). diff --git a/rmk-config/src/layout.rs b/rmk-config/src/layout.rs index a5d082225..9f28de62d 100644 --- a/rmk-config/src/layout.rs +++ b/rmk-config/src/layout.rs @@ -736,4 +736,48 @@ mod tests { ); } } + + #[test] + fn test_sm_action_parsing() { + let aliases = HashMap::new(); + let layer_names = HashMap::new(); + + let keymap = "SM(Tab, LAlt) SM(Tab, LCtrl) SM(Tab, LCtrl | LShift)"; + let result = KeyboardTomlConfig::keymap_parser(keymap, &aliases, &layer_names); + + assert!(result.is_ok()); + assert_eq!( + result.unwrap(), + vec!["SM(Tab, LAlt)", "SM(Tab, LCtrl)", "SM(Tab, LCtrl | LShift)"] + ); + } + + #[test] + fn test_sm_action_grammar() { + let test_cases = vec![ + "SM(Tab, LAlt)", + "SM(Tab, LCtrl)", + "SM(Tab, LCtrl | LShift)", + "SM(A, LGui)", + "sm(Tab, LAlt)", // case insensitive + ]; + + for input in test_cases { + let result = ConfigParser::parse(Rule::key_map, input); + assert!(result.is_ok(), "Failed to parse: {}", input); + + let mut found_sm = false; + for pair in result.unwrap() { + if pair.as_rule() == Rule::key_map { + for inner_pair in pair.into_inner() { + if inner_pair.as_rule() == Rule::sm_action { + found_sm = true; + } + } + } + } + + assert!(found_sm, "Input should be parsed as sm_action: {}", input); + } + } } diff --git a/rmk-config/src/lib.rs b/rmk-config/src/lib.rs index 70028ac7f..97b4d23d7 100644 --- a/rmk-config/src/lib.rs +++ b/rmk-config/src/lib.rs @@ -803,7 +803,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 From bb1b111fb06868521fc19bebb2d3017594755b83 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Wed, 20 May 2026 19:06:27 -0500 Subject: [PATCH 013/119] feat(keyboard): implement StickyMod timeout via main event loop deadline 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 --- rmk/src/keyboard.rs | 21 ++++-- rmk/src/keyboard/sticky_mod.rs | 56 ++++++++++------ rmk/src/keymap.rs | 4 ++ rmk/tests/keyboard_sticky_mod_test.rs | 92 ++++++++++++++++++++++++++- 4 files changed, 148 insertions(+), 25 deletions(-) diff --git a/rmk/src/keyboard.rs b/rmk/src/keyboard.rs index 3e30c8251..8f72b7081 100644 --- a/rmk/src/keyboard.rs +++ b/rmk/src/keyboard.rs @@ -154,18 +154,29 @@ impl Runnable for Keyboard<'_> { // Process buffered held key self.process_buffered_key(key).await } else { - // If mouse repeat is pending, race subscriber against deadline - let event = if let Some(deadline) = self.mouse.next_deadline() { + // Race subscriber against any pending deadlines (mouse repeat, SM timeout) + let sm_deadline = self.sticky_mod_state.deadline(); + let mouse_deadline = self.mouse.next_deadline(); + let combined_deadline = match (sm_deadline, mouse_deadline) { + (Some(a), Some(b)) => Some(a.min(b)), + (a, b) => a.or(b), + }; + let event = if let Some(deadline) = combined_deadline { match with_deadline(deadline, self.keyboard_event_subscriber.next_message_pure()).await { Ok(event) => event, Err(_) => { - // Repeat deadline expired, fire repeat - self.fire_mouse_repeat().await; + let now = Instant::now(); + if sm_deadline.map_or(false, |d| now >= d) { + self.release_sticky_mod_if_active().await; + } + if mouse_deadline.map_or(false, |d| now >= d) { + self.fire_mouse_repeat().await; + } continue; } } } else { - // No repeat pending, wait indefinitely + // No deadlines pending, wait indefinitely self.keyboard_event_subscriber.next_message_pure().await }; self.process_inner(event).await diff --git a/rmk/src/keyboard/sticky_mod.rs b/rmk/src/keyboard/sticky_mod.rs index 5cf9733f1..285c9de9c 100644 --- a/rmk/src/keyboard/sticky_mod.rs +++ b/rmk/src/keyboard/sticky_mod.rs @@ -3,8 +3,10 @@ //! StickyMod provides Alt+Tab-like window/tab switching behavior. //! On first press: sends modifier + key. On release: holds modifier. //! Subsequent presses send only the key. Modifier releases when any -//! non-SM/non-modifier key is pressed, or when the layer changes. +//! non-SM/non-modifier key is pressed, or when the layer changes, +//! or when the optional timeout fires from the main event loop. +use embassy_time::{Duration, Instant}; use rmk_types::keycode::KeyCode; use rmk_types::modifier::ModifierCombination; @@ -17,22 +19,34 @@ pub(crate) enum StickyModState { /// StickyMod is inactive #[default] None, - /// StickyMod is active — modifier is being held - Active(ModifierCombination), + /// StickyMod is active — modifier is held, optional deadline for auto-release + Active { + mods: ModifierCombination, + /// When to auto-release the modifier. None = no timeout. + deadline: Option, + }, } impl StickyModState { /// Get the held modifiers if StickyMod is active pub fn value(&self) -> Option<&ModifierCombination> { match self { - StickyModState::Active(mods) => Some(mods), + StickyModState::Active { mods, .. } => Some(mods), StickyModState::None => None, } } /// Check if StickyMod is currently active pub fn is_active(&self) -> bool { - matches!(self, StickyModState::Active(_)) + matches!(self, StickyModState::Active { .. }) + } + + /// Return the auto-release deadline if one is set, for use in the main event loop + pub fn deadline(&self) -> Option { + match self { + StickyModState::Active { deadline, .. } => *deadline, + StickyModState::None => None, + } } } @@ -40,10 +54,10 @@ impl Keyboard<'_> { /// Process StickyMod action /// /// Flow: - /// - First press: activate SM state, register modifier + key, send report - /// - Release: unregister key, keep modifier held (via state + resolve_explicit_modifiers) - /// - Subsequent press: key already held by state, just register key again - /// - Subsequent release: unregister key, modifier stays + /// - First press: activate SM state with deadline, register modifier + key, send report + /// - Subsequent press: reset deadline, register key again + /// - Release: unregister key, modifier stays held (deadline unchanged) + /// - Timeout: fires from `run()` loop via `sticky_mod_state.deadline()` /// - Any non-SM/non-modifier key press: release_sticky_mod_if_active() called before processing /// - Layer change: release_sticky_mod_if_active() called as cleanup pub(crate) async fn process_action_sticky_mod( @@ -53,22 +67,26 @@ impl Keyboard<'_> { event: KeyboardEvent, ) { if event.pressed { - // Activate SM if not already active (first press) - // If already active with same or different modifier, keep current state - // (different SM key while active: first SM was already released by the - // any-key-press check in process_key_action_normal before we get here, - // so this is always a fresh activation) - if let StickyModState::None = self.sticky_mod_state { - self.sticky_mod_state = StickyModState::Active(modifiers); + let timeout = self.keymap.sticky_mod_timeout(); + let deadline = (timeout != Duration::MAX).then(|| Instant::now() + timeout); + + match &mut self.sticky_mod_state { + StickyModState::None => { + self.sticky_mod_state = StickyModState::Active { mods: modifiers, deadline }; + } + StickyModState::Active { deadline: d, .. } => { + // Reset deadline on each SM press (timeout counts from last press) + *d = deadline; + } } - // Register the key (e.g., Tab) — modifier comes from resolve_explicit_modifiers if let KeyCode::Hid(hid_key) = key { self.register_key(hid_key, event); } self.send_keyboard_report_with_resolved_modifiers(true).await; } else { - // Release the key, modifier stays held via StickyModState::Active + // Release the key; modifier stays held via StickyModState::Active. + // Deadline remains — the run() loop fires auto-release when it expires. if let KeyCode::Hid(hid_key) = key { self.unregister_key(hid_key, event); } @@ -79,7 +97,7 @@ impl Keyboard<'_> { /// Release StickyMod if active. Called when: /// - A non-SM, non-modifier key is pressed /// - A layer is deactivated - /// - Timeout expires (Phase 2) + /// - Timeout deadline fires in the main event loop pub(crate) async fn release_sticky_mod_if_active(&mut self) { if self.sticky_mod_state.is_active() { debug!("Releasing StickyMod"); diff --git a/rmk/src/keymap.rs b/rmk/src/keymap.rs index e0b4f1dbd..fd74a5a5d 100644 --- a/rmk/src/keymap.rs +++ b/rmk/src/keymap.rs @@ -512,6 +512,10 @@ impl<'a> KeyMap<'a> { self.inner.borrow().behavior.one_shot.timeout } + pub(crate) fn sticky_mod_timeout(&self) -> Duration { + self.inner.borrow().behavior.sticky_mod.timeout + } + pub(crate) fn one_shot_modifiers_config(&self) -> OneShotModifiersConfig { self.inner.borrow().behavior.one_shot_modifiers } diff --git a/rmk/tests/keyboard_sticky_mod_test.rs b/rmk/tests/keyboard_sticky_mod_test.rs index d1c86af8a..d6608a260 100644 --- a/rmk/tests/keyboard_sticky_mod_test.rs +++ b/rmk/tests/keyboard_sticky_mod_test.rs @@ -1,6 +1,7 @@ pub mod common; -use rmk::config::{BehaviorConfig, PositionalConfig}; +use embassy_time::Duration; +use rmk::config::{BehaviorConfig, PositionalConfig, StickyModConfig}; use rmk::keyboard::Keyboard; use rmk::types::action::KeyAction; use rmk::types::modifier::ModifierCombination; @@ -42,6 +43,12 @@ fn create_test_keyboard() -> Keyboard<'static> { Keyboard::new(wrap_keymap(KEYMAP, per_key_config, behavior_config)) } +fn create_test_keyboard_with_behavior_config(config: BehaviorConfig) -> Keyboard<'static> { + let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(config)); + let per_key_config: &'static PositionalConfig<1, 6> = Box::leak(Box::new(PositionalConfig::default())); + Keyboard::new(wrap_keymap(KEYMAP, per_key_config, behavior_config)) +} + rusty_fork_test! { /// StickyMod Test 1: Basic SM flow — press SM twice while MO held /// @@ -201,4 +208,87 @@ rusty_fork_test! { ] }; } + + /// StickyMod Test 6: Timeout — modifier auto-releases after inactivity + /// + /// Config: timeout = 100ms + /// + /// Sequence: + /// - Press MO(1), press SM(Tab,LAlt), release SM → timer starts (100ms) + /// - Wait 150ms → timer fires, Alt auto-released + /// - Release MO(1) (SM already inactive — no cleanup report) + /// - Press C on layer 0 (no modifier), release C + /// + /// Note: MO(1) must be released before pressing the verification key so that + /// col 2 resolves to k!(C) on layer 0 rather than SM(Tab,LCtrl|LShift) on layer 1. + #[test] + fn test_sm_timeout() { + key_sequence_test! { + keyboard: create_test_keyboard_with_behavior_config(BehaviorConfig { + sticky_mod: StickyModConfig { + timeout: Duration::from_millis(100), + }, + ..BehaviorConfig::default() + }), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SM(Tab, LAlt) + [0, 0, false, 10], // Release SM → timer starts (100ms) + [0, 3, false, 150], // Wait 150ms (timer fires!), then release MO(1) + [0, 2, true, 10], // Press C on layer 0 (no modifier) + [0, 2, false, 10], // Release C + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SM press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SM release: Alt held, timer starts + [0, [0, 0, 0, 0, 0, 0]], // Timeout: Alt auto-released + // MO(1) release: SM already inactive, no report + [0, [kc_to_u8!(C), 0, 0, 0, 0, 0]], // C press: no modifier + [0, [0, 0, 0, 0, 0, 0]], // C release + ] + }; + } + + /// StickyMod Test 7: Timeout resets on each SM press + /// + /// Config: timeout = 100ms + /// + /// Sequence: + /// - Press MO(1), press SM #1, release SM #1 → T1 starts (100ms) + /// - At 50ms: press SM #2 → T1 cancelled, SM #2 processed from unprocessed queue + /// - Release SM #2 → T2 starts (100ms reset) + /// - Wait 150ms → T2 fires, Alt auto-released + /// - Release MO(1) (SM already inactive — no cleanup report) + /// - Press C on layer 0 (no modifier), release C + #[test] + fn test_sm_timeout_resets_on_press() { + key_sequence_test! { + keyboard: create_test_keyboard_with_behavior_config(BehaviorConfig { + sticky_mod: StickyModConfig { + timeout: Duration::from_millis(100), + }, + ..BehaviorConfig::default() + }), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SM #1 + [0, 0, false, 10], // Release SM #1 → T1 starts (100ms) + [0, 0, true, 50], // At 50ms: press SM #2 → T1 cancelled + [0, 0, false, 10], // Release SM #2 → T2 starts (100ms reset) + [0, 3, false, 150], // Wait 150ms (T2 fires!), then release MO(1) + [0, 2, true, 10], // Press C on layer 0 (no modifier) + [0, 2, false, 10], // Release C + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SM #1 press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SM #1 release: Alt held (T1 starts) + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SM #2 press: Alt+Tab (T1 cancelled) + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SM #2 release: Alt held (T2 starts) + [0, [0, 0, 0, 0, 0, 0]], // T2 fires: Alt auto-released + // MO(1) release: SM already inactive, no report + [0, [kc_to_u8!(C), 0, 0, 0, 0, 0]], // C press: no modifier + [0, [0, 0, 0, 0, 0, 0]], // C release + ] + }; + } } From a6aa1906d79576a2b366ec59f556fef51a21d24f Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Wed, 20 May 2026 19:18:36 -0500 Subject: [PATCH 014/119] style: apply cargo fmt --- rmk-macro/src/codegen/action_parser.rs | 4 +--- rmk/src/config/behavior.rs | 4 +--- rmk/src/keyboard.rs | 4 +++- rmk/src/keyboard/sticky_mod.rs | 5 ++++- rmk/tests/keyboard_sticky_mod_test.rs | 25 ++++++++++++++----------- 5 files changed, 23 insertions(+), 19 deletions(-) diff --git a/rmk-macro/src/codegen/action_parser.rs b/rmk-macro/src/codegen/action_parser.rs index c9680841a..27a1352bb 100644 --- a/rmk-macro/src/codegen/action_parser.rs +++ b/rmk-macro/src/codegen/action_parser.rs @@ -250,9 +250,7 @@ pub(crate) fn parse_key( ::rmk::sm!(#ident, #modifiers) } } else { - panic!( - "\n\u{274c} keyboard.toml: SM(key, modifier) invalid. Usage: SM(Tab, LAlt)" - ); + panic!("\n\u{274c} keyboard.toml: SM(key, modifier) invalid. Usage: SM(Tab, LAlt)"); } } s if s.to_lowercase().starts_with("lm(") => { diff --git a/rmk/src/config/behavior.rs b/rmk/src/config/behavior.rs index 7cf66883b..4263c6888 100644 --- a/rmk/src/config/behavior.rs +++ b/rmk/src/config/behavior.rs @@ -93,9 +93,7 @@ pub struct StickyModConfig { impl Default for StickyModConfig { fn default() -> Self { - Self { - timeout: Duration::MAX, - } + Self { timeout: Duration::MAX } } } diff --git a/rmk/src/keyboard.rs b/rmk/src/keyboard.rs index 8f72b7081..f7f3b439a 100644 --- a/rmk/src/keyboard.rs +++ b/rmk/src/keyboard.rs @@ -2234,7 +2234,9 @@ mod test { let mut keyboard = create_test_keyboard(); // Activate layer 1 - keyboard.process_action_layer_switch(1, KeyboardEvent::key(0, 0, true)).await; + keyboard + .process_action_layer_switch(1, KeyboardEvent::key(0, 0, true)) + .await; // Press Transparent key (Q on lower layer) keyboard.process_inner(KeyboardEvent::key(1, 1, true)).await; diff --git a/rmk/src/keyboard/sticky_mod.rs b/rmk/src/keyboard/sticky_mod.rs index 285c9de9c..42c2243ff 100644 --- a/rmk/src/keyboard/sticky_mod.rs +++ b/rmk/src/keyboard/sticky_mod.rs @@ -72,7 +72,10 @@ impl Keyboard<'_> { match &mut self.sticky_mod_state { StickyModState::None => { - self.sticky_mod_state = StickyModState::Active { mods: modifiers, deadline }; + self.sticky_mod_state = StickyModState::Active { + mods: modifiers, + deadline, + }; } StickyModState::Active { deadline: d, .. } => { // Reset deadline on each SM press (timeout counts from last press) diff --git a/rmk/tests/keyboard_sticky_mod_test.rs b/rmk/tests/keyboard_sticky_mod_test.rs index d6608a260..b70beca12 100644 --- a/rmk/tests/keyboard_sticky_mod_test.rs +++ b/rmk/tests/keyboard_sticky_mod_test.rs @@ -17,21 +17,24 @@ use crate::common::{KC_LALT, KC_LCTRL, KC_LSHIFT, wrap_keymap}; const KEYMAP: [[[KeyAction; 6]; 1]; 2] = [ [[ // Layer 0 - k!(A), // col 0: A - k!(B), // col 1: B - k!(C), // col 2: C - mo!(1), // col 3: MO(1) — momentary layer + k!(A), // col 0: A + k!(B), // col 1: B + k!(C), // col 2: C + mo!(1), // col 3: MO(1) — momentary layer k!(LShift), // col 4: LShift - a!(No), // col 5: No + a!(No), // col 5: No ]], [[ // Layer 1 - sm!(Tab, ModifierCombination::LALT), // col 0: SM(Tab, LAlt) - sm!(Tab, ModifierCombination::LCTRL), // col 1: SM(Tab, LCtrl) - sm!(Tab, ModifierCombination::new_from_vals(true, true, false, false, false, false, false, false)), // col 2: SM(Tab, LCtrl|LShift) - a!(Transparent), // col 3: Transparent - a!(Transparent), // col 4: Transparent → LShift - a!(No), // col 5: No + sm!(Tab, ModifierCombination::LALT), // col 0: SM(Tab, LAlt) + sm!(Tab, ModifierCombination::LCTRL), // col 1: SM(Tab, LCtrl) + sm!( + Tab, + ModifierCombination::new_from_vals(true, true, false, false, false, false, false, false) + ), // col 2: SM(Tab, LCtrl|LShift) + a!(Transparent), // col 3: Transparent + a!(Transparent), // col 4: Transparent → LShift + a!(No), // col 5: No ]], ]; From c7d54b1efc6baf22bbafeab07ea52f493c0893f0 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Wed, 20 May 2026 22:58:59 -0500 Subject: [PATCH 015/119] fix: simplify map_or to is_some_and (clippy); update wire-format snapshots - 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 --- .../rmk/snapshots/endpoint_keys_base.snap | 20 +++++++++---------- .../rmk/snapshots/endpoint_keys_bulk.snap | 12 +++++------ rmk/src/keyboard.rs | 4 ++-- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_base.snap b/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_base.snap index d030a39c5..7c92a477f 100644 --- a/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_base.snap +++ b/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_base.snap @@ -8,22 +8,22 @@ behavior/get REQ 79 40 45 f9 6e 78 ce 15 RESP ac 59 82 ee ea 41 6c 64 behavior/set REQ c0 6d 36 93 9c 5a 7a b0 RESP 92 d6 0a 5d 06 93 e2 17 -combo/get REQ 81 6e 51 70 26 48 4d 13 RESP 81 bc b2 a9 e0 9f 2b 18 -combo/set REQ b1 8e 65 c2 ed 94 40 7a RESP 2c 9b 2b 68 fe 35 21 25 +combo/get REQ 81 6e 51 70 26 48 4d 13 RESP ed b8 e2 19 7a e2 03 54 +combo/set REQ dd a2 0d 56 15 7b 96 a3 RESP 2c 9b 2b 68 fe 35 21 25 conn/set_type REQ 59 5c 7b 51 0e ff d7 12 RESP 8f e7 08 b9 4d 3f 68 d5 conn/type REQ 4d f1 b2 e7 8d ec 46 a0 RESP 02 58 66 87 39 d7 b5 b5 -encoder/get REQ 4c 0d e1 c9 58 89 4b 52 RESP ee b7 43 aa 57 1b 2c c6 -encoder/set REQ 20 67 57 b9 68 8d 12 0b RESP ea a8 3d 9e dd 6e 67 c7 -fork/get REQ 21 8f a2 dc 1f e8 3a 1b RESP 01 ce d3 fe 50 0c 5c 20 -fork/set REQ d1 e4 71 90 fe f3 2c 8c RESP 0c 8a ca c0 83 a9 dc be +encoder/get REQ 4c 0d e1 c9 58 89 4b 52 RESP ca c8 ac d7 23 33 f2 56 +encoder/set REQ b4 93 2b f7 5f cf 4f 34 RESP ea a8 3d 9e dd 6e 67 c7 +fork/get REQ 21 8f a2 dc 1f e8 3a 1b RESP 1d f5 9e 50 cc f6 25 ee +fork/set REQ 6d 03 c3 4b 8a 03 9a 9f RESP 0c 8a ca c0 83 a9 dc be keymap/default_layer REQ 3b 9b e3 4e c2 47 56 de RESP 79 3f e3 4e c2 11 56 de -keymap/get REQ 9c ce 0f 70 d3 94 0f fb RESP 2f 78 b6 8f fd 2b 5a f1 -keymap/set REQ f4 d9 ba f2 ab 89 43 6e RESP a7 01 c4 70 bb ea d3 b9 +keymap/get REQ 9c ce 0f 70 d3 94 0f fb RESP 0f 27 6e 9e 99 49 89 65 +keymap/set REQ 70 14 f9 d8 1a 17 90 87 RESP a7 01 c4 70 bb ea d3 b9 keymap/set_default_layer REQ 6c 6c 14 62 2a 07 9d b3 RESP 2b 67 98 d3 da 4b f3 98 macro/get REQ 0a 43 62 d5 55 40 09 9d RESP 85 2c 14 7a 94 7c e9 f1 macro/set REQ f7 e6 c3 bd 4c 03 a5 e7 RESP 4e 8c 8b 52 00 fa 68 03 -morse/get REQ f5 0c 0d f1 f0 6b 74 e2 RESP 6d 95 d2 8d 4c c2 14 56 -morse/set REQ b5 f0 f8 c1 a2 7d 34 55 RESP 40 c6 f5 18 aa 72 42 a5 +morse/get REQ f5 0c 0d f1 f0 6b 74 e2 RESP 82 df ee f9 ca 8c 96 e5 +morse/set REQ 7a cd c1 f3 55 84 3c 5c RESP 40 c6 f5 18 aa 72 42 a5 status/layer/get REQ d7 6a 8a 1b 7b bb be 32 RESP 75 45 8a 1b 7b a5 be 32 status/matrix/get REQ 4b ae a1 68 0d d9 90 44 RESP 63 13 83 85 e4 e0 0b 36 sys/bootloader REQ 29 a1 89 88 85 d6 a1 26 RESP 29 a1 89 88 85 d6 a1 26 diff --git a/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_bulk.snap b/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_bulk.snap index 820a508d5..89e623c1d 100644 --- a/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_bulk.snap +++ b/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_bulk.snap @@ -6,9 +6,9 @@ # UPDATE_SNAPSHOTS=1 cargo test -p rmk-types --features rmk_protocol # Format: REQ <8-byte hex> RESP <8-byte hex> -combo/bulk_get REQ 52 b1 93 5f 86 d5 96 53 RESP e9 70 53 4a da 27 53 95 -combo/bulk_set REQ ef 1a 1c 52 ba e4 59 9c RESP 83 3b 2e b1 a0 96 2f 3d -keymap/bulk_get REQ 11 21 e6 78 15 e5 8a ca RESP 07 7a ee d5 7b e7 1e f5 -keymap/bulk_set REQ 39 58 c2 b2 41 12 3b 6e RESP 42 98 cc 60 91 e5 c5 f3 -morse/bulk_get REQ 46 e8 ff eb aa ed 5f db RESP 65 dd 4f a3 28 aa 09 e7 -morse/bulk_set REQ 13 ed 02 c1 7b f5 53 97 RESP f7 57 bd 43 2b 0b ec b8 +combo/bulk_get REQ 52 b1 93 5f 86 d5 96 53 RESP 15 1a 33 42 ab 3e e9 d7 +combo/bulk_set REQ 63 1a 39 ca 37 c0 75 6f RESP 83 3b 2e b1 a0 96 2f 3d +keymap/bulk_get REQ 11 21 e6 78 15 e5 8a ca RESP 47 7b f1 bd ad cd b6 e7 +keymap/bulk_set REQ 01 63 3c a6 88 c3 fa b9 RESP 42 98 cc 60 91 e5 c5 f3 +morse/bulk_get REQ 46 e8 ff eb aa ed 5f db RESP 6a 78 3f f6 e4 0b eb 9d +morse/bulk_set REQ cc ea c6 89 58 61 8b 16 RESP f7 57 bd 43 2b 0b ec b8 diff --git a/rmk/src/keyboard.rs b/rmk/src/keyboard.rs index f7f3b439a..58708c181 100644 --- a/rmk/src/keyboard.rs +++ b/rmk/src/keyboard.rs @@ -166,10 +166,10 @@ impl Runnable for Keyboard<'_> { Ok(event) => event, Err(_) => { let now = Instant::now(); - if sm_deadline.map_or(false, |d| now >= d) { + if sm_deadline.is_some_and(|d| now >= d) { self.release_sticky_mod_if_active().await; } - if mouse_deadline.map_or(false, |d| now >= d) { + if mouse_deadline.is_some_and(|d| now >= d) { self.fire_mouse_repeat().await; } continue; From a6a0b045d036d29d3fe888f678ae0b1309954771 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Thu, 28 May 2026 09:23:31 -0500 Subject: [PATCH 016/119] chore: start feat/sticky-key branch (from feat/sticky-mod) From 273a1dc9f54bce31292535d13b28c889e4bac0a9 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Thu, 28 May 2026 09:45:05 -0500 Subject: [PATCH 017/119] =?UTF-8?q?test:=20add=20keyboard=5Fsticky=5Fkey?= =?UTF-8?q?=5Ftest.rs=20with=2011=20SK=20tests=20(failing=20=E2=80=94=20ty?= =?UTF-8?q?pes=20not=20yet=20implemented)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- rmk/tests/keyboard_sticky_key_test.rs | 514 ++++++++++++++++++++++++++ 1 file changed, 514 insertions(+) create mode 100644 rmk/tests/keyboard_sticky_key_test.rs diff --git a/rmk/tests/keyboard_sticky_key_test.rs b/rmk/tests/keyboard_sticky_key_test.rs new file mode 100644 index 000000000..3af63fc8d --- /dev/null +++ b/rmk/tests/keyboard_sticky_key_test.rs @@ -0,0 +1,514 @@ +pub mod common; + +use embassy_time::Duration; +use rmk::config::{BehaviorConfig, PositionalConfig, StickyKeyConfig}; +use rmk::keyboard::Keyboard; +use rmk::types::action::{KeyAction, StickyKeyAction}; +use rmk::types::modifier::ModifierCombination; +use rmk::{a, k, mo, sk}; +use rusty_fork::rusty_fork_test; + +use crate::common::{KC_LALT, KC_LCTRL, KC_LSHIFT, wrap_keymap}; + +// KEYMAP +// Layer 0: A B C MO(1) LShift No +// Layer 1: SK(Tab,LAlt,exit=true) SK(Tab,LCtrl,exit=true) SK(Tab,LCtrl|LShift,exit=true) Transparent Transparent No + +const KEYMAP: [[[KeyAction; 6]; 1]; 2] = [ + [[ + // Layer 0 + k!(A), // col 0: A + k!(B), // col 1: B + k!(C), // col 2: C + mo!(1), // col 3: MO(1) — momentary layer + k!(LShift), // col 4: LShift + a!(No), // col 5: No + ]], + [[ + // Layer 1 + sk!(Tab, ModifierCombination::LALT, 0, 0, true), // col 0: SK(Tab, LAlt, exit=true) + sk!(Tab, ModifierCombination::LCTRL, 0, 0, true), // col 1: SK(Tab, LCtrl, exit=true) + sk!( + Tab, + ModifierCombination::new_from_vals(true, true, false, false, false, false, false, false), + 0, + 0, + true + ), // col 2: SK(Tab, LCtrl|LShift, exit=true) + a!(Transparent), // col 3: Transparent + a!(Transparent), // col 4: Transparent → LShift + a!(No), // col 5: No + ]], +]; + +// KEYMAP_MAX_REPEAT: SK at col 0 has max_repeat=2 +const KEYMAP_MAX_REPEAT: [[[KeyAction; 6]; 1]; 2] = [ + [[ + k!(A), + k!(B), + k!(C), + mo!(1), + k!(LShift), + a!(No), + ]], + [[ + sk!(Tab, ModifierCombination::LALT, 2, 0, false), // col 0: max_repeat=2 + sk!(Tab, ModifierCombination::LCTRL, 0, 0, false), // col 1 + sk!(Tab, ModifierCombination::LCTRL, 0, 0, false), // col 2 + a!(Transparent), + a!(Transparent), + a!(No), + ]], +]; + +// KEYMAP_PER_KEY_TIMEOUT: SK at col 0 has 50ms per-key timeout +const KEYMAP_PER_KEY_TIMEOUT: [[[KeyAction; 6]; 1]; 2] = [ + [[ + k!(A), + k!(B), + k!(C), + mo!(1), + k!(LShift), + a!(No), + ]], + [[ + sk!(Tab, ModifierCombination::LALT, 0, 50, false), // col 0: 50ms per-key timeout + sk!(Tab, ModifierCombination::LCTRL, 0, 0, false), // col 1 + sk!(Tab, ModifierCombination::LCTRL, 0, 0, false), // col 2 + a!(Transparent), + a!(Transparent), + a!(No), + ]], +]; + +// KEYMAP_NO_EXIT: SK with exit_on_layer_change=false — SK survives MO release +const KEYMAP_NO_EXIT: [[[KeyAction; 6]; 1]; 2] = [ + [[ + k!(A), + k!(B), + k!(C), + mo!(1), + k!(LShift), + a!(No), + ]], + [[ + sk!(Tab, ModifierCombination::LALT, 0, 0, false), // col 0: exit=false + sk!(Tab, ModifierCombination::LCTRL, 0, 0, false), // col 1 + sk!(Tab, ModifierCombination::LCTRL, 0, 0, false), // col 2 + a!(Transparent), + a!(Transparent), + a!(No), + ]], +]; + +fn create_test_keyboard() -> Keyboard<'static> { + static BEHAVIOR_CONFIG: static_cell::StaticCell = static_cell::StaticCell::new(); + let behavior_config = BEHAVIOR_CONFIG.init(BehaviorConfig::default()); + static KEY_CONFIG: static_cell::StaticCell> = static_cell::StaticCell::new(); + let per_key_config = KEY_CONFIG.init(PositionalConfig::default()); + Keyboard::new(wrap_keymap(KEYMAP, per_key_config, behavior_config)) +} + +fn create_test_keyboard_max_repeat() -> Keyboard<'static> { + let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig::default())); + let per_key_config: &'static PositionalConfig<1, 6> = Box::leak(Box::new(PositionalConfig::default())); + Keyboard::new(wrap_keymap(KEYMAP_MAX_REPEAT, per_key_config, behavior_config)) +} + +fn create_test_keyboard_per_key_timeout() -> Keyboard<'static> { + let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig { + sticky_key: StickyKeyConfig { + timeout: Duration::from_millis(100), + }, + ..BehaviorConfig::default() + })); + let per_key_config: &'static PositionalConfig<1, 6> = Box::leak(Box::new(PositionalConfig::default())); + Keyboard::new(wrap_keymap(KEYMAP_PER_KEY_TIMEOUT, per_key_config, behavior_config)) +} + +fn create_test_keyboard_no_exit() -> Keyboard<'static> { + let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig::default())); + let per_key_config: &'static PositionalConfig<1, 6> = Box::leak(Box::new(PositionalConfig::default())); + Keyboard::new(wrap_keymap(KEYMAP_NO_EXIT, per_key_config, behavior_config)) +} + +fn create_test_keyboard_with_behavior_config(config: BehaviorConfig) -> Keyboard<'static> { + let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(config)); + let per_key_config: &'static PositionalConfig<1, 6> = Box::leak(Box::new(PositionalConfig::default())); + Keyboard::new(wrap_keymap(KEYMAP, per_key_config, behavior_config)) +} + +rusty_fork_test! { + /// StickyKey Test 1: Basic SK flow — press SK twice while MO held + /// + /// Sequence: + /// - Press MO(1) → layer activates, no report + /// - Press SK(Tab,LAlt) → [KC_LALT, [Tab, ...]] + /// - Release SK → [KC_LALT, [0, ...]] (modifier held) + /// - Press SK again → [KC_LALT, [Tab, ...]] + /// - Release SK → [KC_LALT, [0, ...]] + /// - Release MO(1) → [0, [0, ...]] (layer deactivation cleans up SK) + #[test] + fn test_sk_basic_flow_press_twice() { + key_sequence_test! { + keyboard: create_test_keyboard(), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SK(Tab, LAlt) + [0, 0, false, 10], // Release SK + [0, 0, true, 10], // Press SK again + [0, 0, false, 10], // Release SK + [0, 3, false, 10], // Release MO(1) + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press again: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held + [0, [0, 0, 0, 0, 0, 0]], // MO release: SK cleaned up + ] + }; + } + + /// StickyKey Test 2: Layer change cleanup (exit_on_layer_change=true) + /// + /// Sequence: + /// - Press MO(1), press SK(Tab,LAlt), release SK, release MO(1) + /// + /// Expected: + /// - SK press: Alt+Tab + /// - SK release: Alt held + /// - MO release: cleans up SK (exit_on_layer_change=true), sends [0, [0,...]] + #[test] + fn test_sk_layer_change_cleanup() { + key_sequence_test! { + keyboard: create_test_keyboard(), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SK(Tab, LAlt) + [0, 0, false, 10], // Release SK + [0, 3, false, 10], // Release MO(1) → triggers SK cleanup (exit_on_layer_change=true) + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held + [0, [0, 0, 0, 0, 0, 0]], // MO release: SK cleaned up + ] + }; + } + + /// StickyKey Test 3: Shift does NOT release SK + /// + /// Sequence: + /// - Press MO(1), press SK(Tab,LCtrl), release SK + /// - Press LShift (col 4, transparent → LShift) — should NOT release SK + /// - Press SK again, release SK + /// - Release LShift, release MO(1) + /// + /// Expected: + /// - SK press: Ctrl+Tab + /// - SK release: Ctrl held + /// - Shift press: Ctrl+Shift held (SK not released) + /// - SK press: Ctrl+Shift+Tab + /// - SK release: Ctrl+Shift held + /// - Shift release: Ctrl held + /// - MO release: SK cleaned up + #[test] + fn test_sk_shift_does_not_release_sk() { + key_sequence_test! { + keyboard: create_test_keyboard(), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 1, true, 10], // Press SK(Tab, LCtrl) + [0, 1, false, 10], // Release SK + [0, 4, true, 10], // Press LShift (Transparent → LShift on L0) + [0, 1, true, 10], // Press SK again + [0, 1, false, 10], // Release SK + [0, 4, false, 10], // Release LShift + [0, 3, false, 10], // Release MO(1) + ], + expected_reports: [ + [KC_LCTRL, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Ctrl+Tab + [KC_LCTRL, [0, 0, 0, 0, 0, 0]], // SK release: Ctrl held + [KC_LCTRL | KC_LSHIFT, [0, 0, 0, 0, 0, 0]], // Shift press: Ctrl+Shift (SK not released) + [KC_LCTRL | KC_LSHIFT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Ctrl+Shift+Tab + [KC_LCTRL | KC_LSHIFT, [0, 0, 0, 0, 0, 0]], // SK release: Ctrl+Shift held + [KC_LCTRL, [0, 0, 0, 0, 0, 0]], // Shift release: Ctrl held + [0, [0, 0, 0, 0, 0, 0]], // MO release: SK cleaned up + ] + }; + } + + /// StickyKey Test 4: Rapid presses — 3x SK press/release while MO held + /// + /// Sequence: + /// - Press MO(1), then 3x (press SK, release SK), release MO(1) + /// + /// Expected: Each SK press sends Alt+Tab; each release holds Alt; MO release cleans up. + #[test] + fn test_sk_rapid_three_presses() { + key_sequence_test! { + keyboard: create_test_keyboard(), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SK #1 + [0, 0, false, 10], // Release SK #1 + [0, 0, true, 10], // Press SK #2 + [0, 0, false, 10], // Release SK #2 + [0, 0, true, 10], // Press SK #3 + [0, 0, false, 10], // Release SK #3 + [0, 3, false, 10], // Release MO(1) + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK #1 press + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK #1 release + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK #2 press + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK #2 release + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK #3 press + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK #3 release + [0, [0, 0, 0, 0, 0, 0]], // MO release: SK cleaned up + ] + }; + } + + /// StickyKey Test 5: Combined modifiers LCtrl|LShift + /// + /// Sequence: + /// - Press MO(1), press SK(Tab,LCtrl|LShift) at col 2, release SK, release MO(1) + /// + /// Expected: + /// - SK press: Ctrl+Shift+Tab + /// - SK release: Ctrl+Shift held + /// - MO release: SK cleaned up + #[test] + fn test_sk_combined_modifiers() { + key_sequence_test! { + keyboard: create_test_keyboard(), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 2, true, 10], // Press SK(Tab, LCtrl|LShift) + [0, 2, false, 10], // Release SK + [0, 3, false, 10], // Release MO(1) + ], + expected_reports: [ + [KC_LCTRL | KC_LSHIFT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Ctrl+Shift+Tab + [KC_LCTRL | KC_LSHIFT, [0, 0, 0, 0, 0, 0]], // SK release: Ctrl+Shift held + [0, [0, 0, 0, 0, 0, 0]], // MO release: SK cleaned up + ] + }; + } + + /// StickyKey Test 6: Timeout — modifier auto-releases after inactivity + /// + /// Config: global timeout = 100ms + /// + /// Sequence: + /// - Press MO(1), press SK(Tab,LAlt), release SK → timer starts (100ms) + /// - Wait 150ms → timer fires, Alt auto-released + /// - Release MO(1) (SK already inactive — no cleanup report) + /// - Press C on layer 0 (no modifier), release C + /// + /// Note: MO(1) must be released before pressing the verification key so that + /// col 2 resolves to k!(C) on layer 0 rather than SK(Tab,LCtrl|LShift) on layer 1. + #[test] + fn test_sk_timeout() { + key_sequence_test! { + keyboard: create_test_keyboard_with_behavior_config(BehaviorConfig { + sticky_key: StickyKeyConfig { + timeout: Duration::from_millis(100), + }, + ..BehaviorConfig::default() + }), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SK(Tab, LAlt) + [0, 0, false, 10], // Release SK → timer starts (100ms) + [0, 3, false, 150], // Wait 150ms (timer fires!), then release MO(1) + [0, 2, true, 10], // Press C on layer 0 (no modifier) + [0, 2, false, 10], // Release C + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held, timer starts + [0, [0, 0, 0, 0, 0, 0]], // Timeout: Alt auto-released + // MO(1) release: SK already inactive, no report + [0, [kc_to_u8!(C), 0, 0, 0, 0, 0]], // C press: no modifier + [0, [0, 0, 0, 0, 0, 0]], // C release + ] + }; + } + + /// StickyKey Test 7: Timeout resets on each SK press + /// + /// Config: global timeout = 100ms + /// + /// Sequence: + /// - Press MO(1), press SK #1, release SK #1 → T1 starts (100ms) + /// - At 50ms: press SK #2 → T1 cancelled, SK #2 processed from unprocessed queue + /// - Release SK #2 → T2 starts (100ms reset) + /// - Wait 150ms → T2 fires, Alt auto-released + /// - Release MO(1) (SK already inactive — no cleanup report) + /// - Press C on layer 0 (no modifier), release C + #[test] + fn test_sk_timeout_resets_on_press() { + key_sequence_test! { + keyboard: create_test_keyboard_with_behavior_config(BehaviorConfig { + sticky_key: StickyKeyConfig { + timeout: Duration::from_millis(100), + }, + ..BehaviorConfig::default() + }), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SK #1 + [0, 0, false, 10], // Release SK #1 → T1 starts (100ms) + [0, 0, true, 50], // At 50ms: press SK #2 → T1 cancelled + [0, 0, false, 10], // Release SK #2 → T2 starts (100ms reset) + [0, 3, false, 150], // Wait 150ms (T2 fires!), then release MO(1) + [0, 2, true, 10], // Press C on layer 0 (no modifier) + [0, 2, false, 10], // Release C + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK #1 press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK #1 release: Alt held (T1 starts) + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK #2 press: Alt+Tab (T1 cancelled) + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK #2 release: Alt held (T2 starts) + [0, [0, 0, 0, 0, 0, 0]], // T2 fires: Alt auto-released + // MO(1) release: SK already inactive, no report + [0, [kc_to_u8!(C), 0, 0, 0, 0, 0]], // C press: no modifier + [0, [0, 0, 0, 0, 0, 0]], // C release + ] + }; + } + + /// StickyKey Test 8: max_repeat — SK releases after N presses + /// + /// Config: KEYMAP_MAX_REPEAT, SK at col 0 has max_repeat=2 + /// + /// Sequence: + /// - Press MO(1), press SK ×3, release MO(1) + /// + /// Expected: + /// - Press 1: fire (Alt+Tab, Alt held) + /// - Press 2: fire (Alt+Tab, Alt held) — this is the max_repeat=2 press + /// - Press 3: max_repeat reached, SK deactivates silently (no new report beyond empty) + #[test] + fn test_sk_max_repeat() { + key_sequence_test! { + keyboard: create_test_keyboard_max_repeat(), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SK #1 + [0, 0, false, 10], // Release SK #1 + [0, 0, true, 10], // Press SK #2 + [0, 0, false, 10], // Release SK #2 + [0, 0, true, 10], // Press SK #3 → max_repeat reached, deactivate + [0, 0, false, 10], // Release SK #3 + [0, 3, false, 10], // Release MO(1) + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK #1 press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK #1 release: Alt held + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK #2 press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK #2 release: Alt held + [0, [0, 0, 0, 0, 0, 0]], // SK #3: max_repeat reached, SK deactivated + ] + }; + } + + /// StickyKey Test 9: Per-key timeout overrides global timeout + /// + /// Config: KEYMAP_PER_KEY_TIMEOUT (SK at col 0 has 50ms per-key timeout), global=100ms + /// + /// Sequence: + /// - Press MO(1), press SK(50ms), release SK → per-key 50ms timer starts + /// - Wait 80ms (per-key 50ms fires, global 100ms has NOT fired) + /// - Release MO(1) (SK already released by per-key timeout) + /// - Press C on layer 0 (no modifier), release C + /// + /// Expected: SK releases at 50ms (per-key), not 100ms (global) + #[test] + fn test_sk_per_key_timeout_overrides_global() { + key_sequence_test! { + keyboard: create_test_keyboard_per_key_timeout(), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SK(Tab, LAlt, 50ms timeout) + [0, 0, false, 10], // Release SK → per-key 50ms timer starts + [0, 3, false, 80], // Wait 80ms (50ms per-key fires!), then release MO(1) + [0, 2, true, 10], // Press C on layer 0 (no modifier) + [0, 2, false, 10], // Release C + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held, per-key timer starts (50ms) + [0, [0, 0, 0, 0, 0, 0]], // Per-key timeout fires at 50ms: Alt released + // MO(1) release: SK already inactive, no report + [0, [kc_to_u8!(C), 0, 0, 0, 0, 0]], // C press: no modifier + [0, [0, 0, 0, 0, 0, 0]], // C release + ] + }; + } + + /// StickyKey Test 10: exit_on_layer_change=true — SK exits on MO release + /// + /// This is the same as Test 2 — verifying the explicit exit_on_layer_change=true + /// setting (the default KEYMAP uses exit=true). + /// + /// Sequence: MO↓ SK(exit=true)↓ SK↑ MO↑ + /// Expected: Alt+Tab, Alt, empty. + #[test] + fn test_sk_exits_on_layer_change() { + key_sequence_test! { + keyboard: create_test_keyboard(), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SK(Tab, LAlt, exit=true) + [0, 0, false, 10], // Release SK + [0, 3, false, 10], // Release MO(1) → SK exits (exit_on_layer_change=true) + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held + [0, [0, 0, 0, 0, 0, 0]], // MO release: SK exits + ] + }; + } + + /// StickyKey Test 11: exit_on_layer_change=false — SK survives layer change + /// + /// Config: KEYMAP_NO_EXIT (exit_on_layer_change=false) + /// + /// Sequence: + /// - Press MO(1), press SK(exit=false), release SK + /// - Release MO(1) — SK does NOT exit (exit_on_layer_change=false) + /// - Press A on layer 0 — A press releases SK first, then sends A + /// - Release A + /// + /// Expected: + /// - SK press: Alt+Tab + /// - SK release: Alt held + /// - (MO release: no report — SK still active) + /// - A press: SK releases (Alt released), then A sent → [0, [A, ...]] + /// - A release: [0, [0, ...]] + #[test] + fn test_sk_survives_layer_change() { + key_sequence_test! { + keyboard: create_test_keyboard_no_exit(), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SK(Tab, LAlt, exit=false) + [0, 0, false, 10], // Release SK + [0, 3, false, 10], // Release MO(1) — SK does NOT exit + [0, 0, true, 10], // Press A on layer 0 — releases SK, sends A + [0, 0, false, 10], // Release A + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held (SK still active after MO release) + [0, [kc_to_u8!(A), 0, 0, 0, 0, 0]], // A press: SK releases, A sent (no Alt) + [0, [0, 0, 0, 0, 0, 0]], // A release + ] + }; + } +} From 5528f9a1837134425a56b16aa8e441aa27c2b41a Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Thu, 28 May 2026 13:05:56 -0500 Subject: [PATCH 018/119] feat(types): add StickyKeyAction struct and Action::StickyKey variant --- rmk-types/src/action/mod.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/rmk-types/src/action/mod.rs b/rmk-types/src/action/mod.rs index 42689d66e..07ce8288d 100644 --- a/rmk-types/src/action/mod.rs +++ b/rmk-types/src/action/mod.rs @@ -31,6 +31,24 @@ use crate::modifier::ModifierCombination; #[cfg(feature = "steno")] use crate::steno::StenoKey; +/// Parameters for the StickyKey action. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, MaxSize)] +#[cfg_attr(feature = "defmt", derive(defmt::Format))] +#[cfg_attr(feature = "rmk_protocol", derive(Schema))] +pub struct StickyKeyAction { + /// Key sent on each SK press. + pub key: KeyCode, + /// Modifiers held between presses (0 = none). + pub keep: ModifierCombination, + /// Maximum presses before auto-release; 0 = infinite. + /// Fires key on presses 1..=max_repeat, deactivates silently on press max_repeat+1. + pub max_repeat: u16, + /// Per-key timeout in ms; 0 = use global BehaviorConfig default. + pub timeout_ms: u16, + /// Release SK when any layer activates or deactivates. + pub exit_on_layer_change: bool, +} + /// A single basic action that a keyboard can execute. #[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, MaxSize)] #[cfg_attr(feature = "defmt", derive(defmt::Format))] @@ -75,6 +93,9 @@ pub enum Action { Special(SpecialKey), /// User Keys User(u8), + /// Sticky key: sends modifier + key on each press, holds modifiers between presses. + /// Supports max_repeat, per-key timeout, and conditional exit on layer change. + StickyKey(StickyKeyAction), /// Sticky modifier: sends key + modifier on press, holds modifier until /// another key is pressed or layer changes. Used for Alt+Tab-like switching. StickyMod(KeyCode, ModifierCombination), From c8c00b8a6707660652ed5d0f43b569c771734645 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Thu, 28 May 2026 17:23:49 -0500 Subject: [PATCH 019/119] feat(config): add StickyKeyConfig and sticky_key field to BehaviorConfig --- rmk/src/config/behavior.rs | 15 +++++++++++++++ rmk/src/config/mod.rs | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/rmk/src/config/behavior.rs b/rmk/src/config/behavior.rs index 4263c6888..6ce590182 100644 --- a/rmk/src/config/behavior.rs +++ b/rmk/src/config/behavior.rs @@ -19,6 +19,7 @@ pub struct BehaviorConfig { pub keyboard_macros: KeyboardMacrosConfig, pub mouse_key: MouseKeyConfig, pub sticky_mod: StickyModConfig, + pub sticky_key: StickyKeyConfig, } /// Configurations for tap behavior @@ -97,6 +98,20 @@ impl Default for StickyModConfig { } } +/// Configuration for StickyKey behavior +#[derive(Clone, Copy, Debug)] +pub struct StickyKeyConfig { + /// Global timeout before auto-releasing held modifiers. + /// Duration::MAX = no timeout — modifier held until key press or layer change. + pub timeout: Duration, +} + +impl Default for StickyKeyConfig { + fn default() -> Self { + Self { timeout: Duration::MAX } + } +} + /// Config for combo behavior #[derive(Clone, Debug)] pub struct CombosConfig { diff --git a/rmk/src/config/mod.rs b/rmk/src/config/mod.rs index 2aa20c21f..1915f3bee 100644 --- a/rmk/src/config/mod.rs +++ b/rmk/src/config/mod.rs @@ -8,7 +8,7 @@ mod vial; pub use behavior::{ BehaviorConfig, CombosConfig, ForksConfig, KeyboardMacrosConfig, MorsesConfig, MouseKeyConfig, OneShotConfig, - OneShotModifiersConfig, StickyModConfig, TapConfig, + OneShotModifiersConfig, StickyKeyConfig, StickyModConfig, TapConfig, }; #[cfg(feature = "_ble")] pub use ble_battery::BleBatteryConfig; From fd475b89951382fca197930398c4df3aca8de742 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Thu, 28 May 2026 19:05:13 -0500 Subject: [PATCH 020/119] feat(macro): add sk!() macro for StickyKey action --- rmk/src/layout_macro.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/rmk/src/layout_macro.rs b/rmk/src/layout_macro.rs index 8c7ad7907..5c1de076d 100644 --- a/rmk/src/layout_macro.rs +++ b/rmk/src/layout_macro.rs @@ -381,6 +381,33 @@ macro_rules! sm { }; } +/// Create a StickyKey action. +/// +/// # Parameters +/// - `$key`: HID keycode identifier (e.g., `Tab`, `A`) +/// - `$keep`: `ModifierCombination` held between presses +/// - `$max_repeat`: `u16` — max fires before auto-release; 0 = infinite +/// - `$timeout_ms`: `u16` — per-key timeout in ms; 0 = use global config +/// - `$exit_on_layer_change`: `bool` — release SK when any layer changes +#[macro_export] +macro_rules! sk { + ($key:ident, $keep:expr, $max_repeat:expr, $timeout_ms:expr, $exit_on_layer_change:expr) => { + $crate::types::action::KeyAction::Single( + $crate::types::action::Action::StickyKey( + $crate::types::action::StickyKeyAction { + key: $crate::types::keycode::KeyCode::Hid( + $crate::types::keycode::HidKeyCode::$key, + ), + keep: $keep, + max_repeat: $max_repeat, + timeout_ms: $timeout_ms, + exit_on_layer_change: $exit_on_layer_change, + } + ) + ) + }; +} + /// Create a layer toggle action. /// /// This macro creates a key that toggles a layer on/off with each press. From 262e496f0f6d9efe50a314745cd41f28f800f4af Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Thu, 28 May 2026 20:28:54 -0500 Subject: [PATCH 021/119] feat(keyboard): add sticky_key.rs state machine with max_repeat, timeout, and exit_on_layer_change --- rmk/src/keyboard/sticky_key.rs | 129 +++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 rmk/src/keyboard/sticky_key.rs diff --git a/rmk/src/keyboard/sticky_key.rs b/rmk/src/keyboard/sticky_key.rs new file mode 100644 index 000000000..08dfb6a25 --- /dev/null +++ b/rmk/src/keyboard/sticky_key.rs @@ -0,0 +1,129 @@ +//! StickyKey action implementation. +//! +//! StickyKey holds a modifier combination across key presses for Alt+Tab-like cycling. +//! Compared to StickyMod, it adds: +//! - `max_repeat`: limit fires before auto-release (0 = infinite) +//! - `timeout_ms`: per-key timeout override (0 = use global config) +//! - `exit_on_layer_change`: whether layer changes release the SK +//! +//! ## `max_repeat` semantics +//! count starts at 1 on first press. On each subsequent press, count is incremented. +//! Deactivation fires when count > max_repeat (strictly greater), so max_repeat=N fires +//! the key exactly N times and deactivates silently on press N+1. + +use embassy_time::{Duration, Instant}; +use rmk_types::action::StickyKeyAction; +use rmk_types::keycode::KeyCode; +use rmk_types::modifier::ModifierCombination; + +use crate::event::KeyboardEvent; +use crate::keyboard::Keyboard; + +/// State for the StickyKey action. +#[derive(Default, Debug)] +pub(crate) enum StickyKeyState { + /// StickyKey is inactive. + #[default] + None, + /// StickyKey is active — modifiers held, optional deadline for auto-release. + Active { + mods: ModifierCombination, + repeat_count: u16, + max_repeat: u16, + exit_on_layer_change: bool, + deadline: Option, + }, +} + +impl StickyKeyState { + pub fn value(&self) -> Option<&ModifierCombination> { + match self { + StickyKeyState::Active { mods, .. } => Some(mods), + StickyKeyState::None => None, + } + } + + pub fn is_active(&self) -> bool { + matches!(self, StickyKeyState::Active { .. }) + } + + pub fn deadline(&self) -> Option { + match self { + StickyKeyState::Active { deadline, .. } => *deadline, + StickyKeyState::None => None, + } + } + + pub fn exit_on_layer_change(&self) -> bool { + matches!(self, StickyKeyState::Active { exit_on_layer_change: true, .. }) + } +} + +impl Keyboard<'_> { + pub(crate) async fn process_action_sticky_key( + &mut self, + params: StickyKeyAction, + event: KeyboardEvent, + ) { + if event.pressed { + let timeout = if params.timeout_ms > 0 { + Duration::from_millis(params.timeout_ms as u64) + } else { + self.keymap.sticky_key_timeout() + }; + let deadline = (timeout != Duration::MAX).then(|| Instant::now() + timeout); + + let mut should_deactivate = false; + + match &mut self.sticky_key_state { + StickyKeyState::None => { + self.sticky_key_state = StickyKeyState::Active { + mods: params.keep, + repeat_count: 1, + max_repeat: params.max_repeat, + exit_on_layer_change: params.exit_on_layer_change, + deadline, + }; + } + StickyKeyState::Active { + repeat_count, + max_repeat: mr, + deadline: d, + .. + } => { + *repeat_count += 1; + let count = *repeat_count; + let mr_val = *mr; + if mr_val > 0 && count > mr_val { + should_deactivate = true; + } else { + *d = deadline; + } + } + } + + if should_deactivate { + self.sticky_key_state = StickyKeyState::None; + self.send_keyboard_report_with_resolved_modifiers(false).await; + } else { + if let KeyCode::Hid(hid_key) = params.key { + self.register_key(hid_key, event); + } + self.send_keyboard_report_with_resolved_modifiers(true).await; + } + } else { + if let KeyCode::Hid(hid_key) = params.key { + self.unregister_key(hid_key, event); + } + self.send_keyboard_report_with_resolved_modifiers(false).await; + } + } + + pub(crate) async fn release_sticky_key_if_active(&mut self) { + if self.sticky_key_state.is_active() { + debug!("Releasing StickyKey"); + self.sticky_key_state = StickyKeyState::None; + self.send_keyboard_report_with_resolved_modifiers(false).await; + } + } +} From cff9c5db25043ace439279c24e3122415cc92d51 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Thu, 28 May 2026 22:11:33 -0500 Subject: [PATCH 022/119] feat(keymap): add sticky_key_timeout() accessor --- rmk/src/keymap.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/rmk/src/keymap.rs b/rmk/src/keymap.rs index fd74a5a5d..05fd1ade6 100644 --- a/rmk/src/keymap.rs +++ b/rmk/src/keymap.rs @@ -516,6 +516,10 @@ impl<'a> KeyMap<'a> { self.inner.borrow().behavior.sticky_mod.timeout } + pub(crate) fn sticky_key_timeout(&self) -> Duration { + self.inner.borrow().behavior.sticky_key.timeout + } + pub(crate) fn one_shot_modifiers_config(&self) -> OneShotModifiersConfig { self.inner.borrow().behavior.one_shot_modifiers } From 63875904f9fdc3444ffb9cf40abbcc21c4fde7aa Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Thu, 28 May 2026 22:18:15 -0500 Subject: [PATCH 023/119] =?UTF-8?q?feat(keyboard):=20integrate=20StickyKey?= =?UTF-8?q?=20=E2=80=94=20dispatch,=20mods,=20deadline,=20layer-change=20r?= =?UTF-8?q?elease?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rmk/src/keyboard.rs | 53 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/rmk/src/keyboard.rs b/rmk/src/keyboard.rs index 58708c181..8555ef7b5 100644 --- a/rmk/src/keyboard.rs +++ b/rmk/src/keyboard.rs @@ -30,6 +30,7 @@ use crate::keyboard::held_buffer::{HeldBuffer, HeldKey, KeyState}; use crate::keyboard::mouse::{MouseAction, MouseState}; use crate::keyboard::oneshot::OneShotState; use crate::keyboard::sticky_mod::StickyModState; +use crate::keyboard::sticky_key::StickyKeyState; use crate::keyboard_macros::MacroOperation; use crate::keymap::KeyMap; #[cfg(all(feature = "split", feature = "_ble"))] @@ -45,6 +46,7 @@ pub(crate) mod oneshot; #[cfg(feature = "steno")] pub(crate) mod steno; pub(crate) mod sticky_mod; +pub(crate) mod sticky_key; use crate::keymap::HOLD_BUFFER_SIZE; @@ -156,11 +158,12 @@ impl Runnable for Keyboard<'_> { } else { // Race subscriber against any pending deadlines (mouse repeat, SM timeout) let sm_deadline = self.sticky_mod_state.deadline(); + let sk_deadline = self.sticky_key_state.deadline(); let mouse_deadline = self.mouse.next_deadline(); - let combined_deadline = match (sm_deadline, mouse_deadline) { - (Some(a), Some(b)) => Some(a.min(b)), - (a, b) => a.or(b), - }; + let combined_deadline = [sm_deadline, sk_deadline, mouse_deadline] + .into_iter() + .flatten() + .reduce(|a, b| a.min(b)); let event = if let Some(deadline) = combined_deadline { match with_deadline(deadline, self.keyboard_event_subscriber.next_message_pure()).await { Ok(event) => event, @@ -169,6 +172,9 @@ impl Runnable for Keyboard<'_> { if sm_deadline.is_some_and(|d| now >= d) { self.release_sticky_mod_if_active().await; } + if sk_deadline.is_some_and(|d| now >= d) { + self.release_sticky_key_if_active().await; + } if mouse_deadline.is_some_and(|d| now >= d) { self.fire_mouse_repeat().await; } @@ -222,6 +228,9 @@ pub struct Keyboard<'a> { /// StickyMod state — holds modifier across key presses for Alt+Tab-like behavior sticky_mod_state: StickyModState, + /// StickyKey state — holds a modifier+key combination across key presses + sticky_key_state: StickyKeyState, + /// Caps Word state machine caps_word: CapsWordState, @@ -276,6 +285,7 @@ impl<'a> Keyboard<'a> { osl_state: OneShotState::default(), osm_state: OneShotState::default(), sticky_mod_state: StickyModState::default(), + sticky_key_state: StickyKeyState::default(), caps_word: CapsWordState::default(), with_modifiers: ModifierCombination::default(), macro_texting: false, @@ -1230,6 +1240,18 @@ impl<'a> Keyboard<'a> { } } + // Release StickyKey when any non-SK, non-modifier key is pressed. + if event.pressed && self.sticky_key_state.is_active() { + let is_sk_or_modifier = match action { + Action::StickyKey(_) | Action::Modifier(_) => true, + Action::Key(KeyCode::Hid(hid_key)) if hid_key.is_modifier() => true, + _ => false, + }; + if !is_sk_or_modifier { + self.release_sticky_key_if_active().await; + } + } + match action { Action::No => {} Action::Key(key) => self.process_action_key(key, event).await, @@ -1240,6 +1262,9 @@ impl<'a> Keyboard<'a> { if event.pressed { self.keymap.deactivate_layer(layer_num); self.release_sticky_mod_if_active().await; + if self.sticky_key_state.exit_on_layer_change() { + self.release_sticky_key_if_active().await; + } } } Action::LayerToggle(layer_num) => { @@ -1247,6 +1272,9 @@ impl<'a> Keyboard<'a> { if !event.pressed { self.keymap.toggle_layer(layer_num); self.release_sticky_mod_if_active().await; + if self.sticky_key_state.exit_on_layer_change() { + self.release_sticky_key_if_active().await; + } } } Action::LayerToggleOnly(layer_num) => { @@ -1263,12 +1291,18 @@ impl<'a> Keyboard<'a> { // Activate the target layer self.keymap.activate_layer(layer_num); self.release_sticky_mod_if_active().await; + if self.sticky_key_state.exit_on_layer_change() { + self.release_sticky_key_if_active().await; + } } } Action::DefaultLayer(layer_num) => { // Set the default layer self.keymap.set_default_layer(layer_num); self.release_sticky_mod_if_active().await; + if self.sticky_key_state.exit_on_layer_change() { + self.release_sticky_key_if_active().await; + } } Action::Modifier(modifiers) => { if event.pressed { @@ -1319,6 +1353,9 @@ impl<'a> Keyboard<'a> { Action::StickyMod(key, modifiers) => { self.process_action_sticky_mod(key, modifiers, event).await; } + Action::StickyKey(params) => { + self.process_action_sticky_key(params, event).await; + } Action::OneShotKey(_k) => warn!("One-shot key is not supported: {:?}", action), Action::Light(_light_action) => warn!("Light controll is not supported"), Action::KeyboardControl(c) => self.process_action_keyboard_control(c, event).await, @@ -1392,6 +1429,11 @@ impl<'a> Keyboard<'a> { result |= *sm_mods; } + // Add StickyKey modifiers if active + if let Some(sk_mods) = self.sticky_key_state.value() { + result |= *sk_mods; + } + result } @@ -1592,6 +1634,9 @@ impl<'a> Keyboard<'a> { self.keymap.deactivate_layer(layer_num); // Clean up StickyMod when layer deactivates self.release_sticky_mod_if_active().await; + if self.sticky_key_state.exit_on_layer_change() { + self.release_sticky_key_if_active().await; + } } } From c20c91d8b40a600cd1a780c2de9cef2589a9ffba Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Thu, 28 May 2026 23:07:09 -0500 Subject: [PATCH 024/119] fix(keyboard): correct expected reports in test_sk_survives_layer_change When a non-SK key press triggers SK release, the implementation emits two separate HID reports: first the SK release (modifier cleared), then the new key registration. Update the test to expect both reports rather than a single combined report. --- rmk/tests/keyboard_sticky_key_test.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/rmk/tests/keyboard_sticky_key_test.rs b/rmk/tests/keyboard_sticky_key_test.rs index 3af63fc8d..20f27f86d 100644 --- a/rmk/tests/keyboard_sticky_key_test.rs +++ b/rmk/tests/keyboard_sticky_key_test.rs @@ -489,7 +489,7 @@ rusty_fork_test! { /// - SK press: Alt+Tab /// - SK release: Alt held /// - (MO release: no report — SK still active) - /// - A press: SK releases (Alt released), then A sent → [0, [A, ...]] + /// - A press: SK released first → [0, [0, ...]], then A registered → [0, [A, ...]] /// - A release: [0, [0, ...]] #[test] fn test_sk_survives_layer_change() { @@ -506,7 +506,8 @@ rusty_fork_test! { expected_reports: [ [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Alt+Tab [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held (SK still active after MO release) - [0, [kc_to_u8!(A), 0, 0, 0, 0, 0]], // A press: SK releases, A sent (no Alt) + [0, [0, 0, 0, 0, 0, 0]], // A press: SK release report (Alt released) + [0, [kc_to_u8!(A), 0, 0, 0, 0, 0]], // A press: A registered [0, [0, 0, 0, 0, 0, 0]], // A release ] }; From e266ea4ebb69b2ab832f9f17406a14d954b51983 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Thu, 28 May 2026 23:23:54 -0500 Subject: [PATCH 025/119] feat(config): replace StickyMod TOML config/parser with StickyKey; add SK PEG grammar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename StickyModConfig → StickyKeyConfig in rmk-config/src/lib.rs - Rename BehaviorConfig.sticky_mod → sticky_key field - Remove sm_action PEG rule; add boolean, modifier_keep_list, sk_action rules - Replace Rule::sm_action arm with Rule::sk_action in layout.rs - Replace SM tests with SK grammar/parsing tests - Rename sticky_mod_timeout_ms → sticky_key_timeout_ms in resolved/behavior.rs - Fix cross-crate breakage in rmk-macro: update field reference to sticky_key_timeout_ms --- rmk-config/src/keymap.pest | 20 +++++++++++++--- rmk-config/src/layout.rs | 36 +++++++++++++++++------------ rmk-config/src/lib.rs | 8 +++---- rmk-config/src/resolved/behavior.rs | 6 ++--- rmk-macro/src/codegen/behavior.rs | 2 +- 5 files changed, 46 insertions(+), 26 deletions(-) diff --git a/rmk-config/src/keymap.pest b/rmk-config/src/keymap.pest index b9c6ba2a4..b66040fe4 100644 --- a/rmk-config/src/keymap.pest +++ b/rmk-config/src/keymap.pest @@ -102,15 +102,29 @@ morse_action = { (^"TD" | ^"MORSE") ~ "(" ~ number ~ ")" } // Rule 9: Macro(n) - Trigger Macro trigger_macro_action = { ^"MACRO" ~ "(" ~ number ~ ")" } -// Rule 10: SM(key, modifier) - Sticky Modifier Action (holds modifier, taps key) -sm_action = { ^"SM" ~ "(" ~ keycode_name ~ "," ~ modifier_combination ~ ")" } +// boolean literal for SK optional args +boolean = @{ ^"true" | ^"false" } + +// bracketed modifier list for SK keep parameter: [LAlt] or [LAlt|LShift] or [] +modifier_keep_list = { "[" ~ modifier_combination ~ "]" | "[" ~ "]" } + +// SK(key, [keep], max_repeat?, timeout_ms?, exit_on_layer_change?) — StickyKey +sk_action = { + ^"SK" ~ "(" ~ + keycode_name ~ "," ~ + modifier_keep_list ~ + ("," ~ number)? ~ + ("," ~ number)? ~ + ("," ~ boolean)? ~ + ")" +} // --- Top Level Rules --- // 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 | osm_action | layer_action | mt_action | th_action | shifted_action | morse_action | trigger_macro_action | sm_action | no_action | transparent_action | simple_keycode + wm_action | osm_action | layer_action | mt_action | th_action | shifted_action | morse_action | trigger_macro_action | sk_action | no_action | transparent_action | simple_keycode } // The entire key map string: Start, zero or more key actions, End. diff --git a/rmk-config/src/layout.rs b/rmk-config/src/layout.rs index 9f28de62d..5abd88011 100644 --- a/rmk-config/src/layout.rs +++ b/rmk-config/src/layout.rs @@ -396,7 +396,7 @@ impl KeyboardTomlConfig { key_action_sequence.push(action); } - Rule::sm_action => { + Rule::sk_action => { let action = inner_pair.as_str().to_string(); key_action_sequence.push(action); } @@ -738,46 +738,52 @@ mod tests { } #[test] - fn test_sm_action_parsing() { + fn test_sk_action_parsing() { let aliases = HashMap::new(); let layer_names = HashMap::new(); - let keymap = "SM(Tab, LAlt) SM(Tab, LCtrl) SM(Tab, LCtrl | LShift)"; + let keymap = "SK(Tab, [LAlt]) SK(Tab, [LCtrl]) SK(Tab, [LCtrl | LShift], 3, 2000, true)"; let result = KeyboardTomlConfig::keymap_parser(keymap, &aliases, &layer_names); assert!(result.is_ok()); assert_eq!( result.unwrap(), - vec!["SM(Tab, LAlt)", "SM(Tab, LCtrl)", "SM(Tab, LCtrl | LShift)"] + vec![ + "SK(Tab, [LAlt])", + "SK(Tab, [LCtrl])", + "SK(Tab, [LCtrl | LShift], 3, 2000, true)" + ] ); } #[test] - fn test_sm_action_grammar() { + fn test_sk_action_grammar() { let test_cases = vec![ - "SM(Tab, LAlt)", - "SM(Tab, LCtrl)", - "SM(Tab, LCtrl | LShift)", - "SM(A, LGui)", - "sm(Tab, LAlt)", // case insensitive + "SK(Tab, [LAlt])", + "SK(Tab, [LCtrl])", + "SK(Tab, [LCtrl | LShift])", + "SK(Tab, [LAlt], 5)", + "SK(Tab, [LAlt], 5, 3000)", + "SK(Tab, [LAlt], 5, 3000, true)", + "SK(Tab, [])", + "sk(Tab, [LAlt])", ]; for input in test_cases { let result = ConfigParser::parse(Rule::key_map, input); assert!(result.is_ok(), "Failed to parse: {}", input); - let mut found_sm = false; + let mut found_sk = false; for pair in result.unwrap() { if pair.as_rule() == Rule::key_map { for inner_pair in pair.into_inner() { - if inner_pair.as_rule() == Rule::sm_action { - found_sm = true; + if inner_pair.as_rule() == Rule::sk_action { + found_sk = true; } } } } - - assert!(found_sm, "Input should be parsed as sm_action: {}", input); + assert!(found_sk, "Input should be parsed as sk_action: {}", input); } } } diff --git a/rmk-config/src/lib.rs b/rmk-config/src/lib.rs index 97b4d23d7..7301e8e2c 100644 --- a/rmk-config/src/lib.rs +++ b/rmk-config/src/lib.rs @@ -576,7 +576,7 @@ pub(crate) struct BehaviorConfig { pub macros: Option, pub fork: Option, pub morse: Option, - pub sticky_mod: Option, + pub sticky_key: Option, } /// Per Key configurations profiles for morse, tap-hold, etc. @@ -623,11 +623,11 @@ pub struct OneShotModifiersConfig { pub quick_release: Option, } -/// Configurations for sticky modifier +/// Configurations for sticky key #[derive(Clone, Debug, Deserialize)] #[serde(deny_unknown_fields)] -pub struct StickyModConfig { - /// Timeout for sticky modifier auto-release (e.g., "5000ms", "5s") +pub struct StickyKeyConfig { + /// Timeout for sticky key auto-release (e.g., "5000ms", "5s") pub timeout: Option, } diff --git a/rmk-config/src/resolved/behavior.rs b/rmk-config/src/resolved/behavior.rs index cf68d9a30..9dd1f9f25 100644 --- a/rmk-config/src/resolved/behavior.rs +++ b/rmk-config/src/resolved/behavior.rs @@ -9,7 +9,7 @@ pub struct Behavior { pub macros: Option, pub forks: Option, pub morse: Option, - pub sticky_mod_timeout_ms: Option, + pub sticky_key_timeout_ms: Option, } pub struct OneShot { @@ -202,7 +202,7 @@ impl crate::KeyboardTomlConfig { } }); - let sticky_mod_timeout_ms = toml_behavior.sticky_mod.and_then(|s| s.timeout.map(|t| t.0)); + let sticky_key_timeout_ms = toml_behavior.sticky_key.and_then(|s| s.timeout.map(|t| t.0)); Ok(Behavior { tri_layer, @@ -212,7 +212,7 @@ impl crate::KeyboardTomlConfig { macros, forks, morse, - sticky_mod_timeout_ms, + sticky_key_timeout_ms, }) } } diff --git a/rmk-macro/src/codegen/behavior.rs b/rmk-macro/src/codegen/behavior.rs index 2eaeffd9f..6a063c001 100644 --- a/rmk-macro/src/codegen/behavior.rs +++ b/rmk-macro/src/codegen/behavior.rs @@ -510,7 +510,7 @@ pub(crate) fn expand_behavior_config(behavior: &Behavior) -> proc_macro2::TokenS let macros = expand_macros(&behavior.macros); let forks = expand_forks(&behavior.forks, &profiles); let morse = expand_morse(&behavior.morse); - let sticky_mod = expand_sticky_mod(&behavior.sticky_mod_timeout_ms); + let sticky_mod = expand_sticky_mod(&behavior.sticky_key_timeout_ms); quote! { #[allow(clippy::needless_update)] From c170eca10d1b0bac57209ce6928f1be0e91977f7 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Thu, 28 May 2026 23:27:22 -0500 Subject: [PATCH 026/119] feat(macro): replace SM codegen with SK codegen; parse SK(key,[keep],opts) syntax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - action_parser.rs: replace SM( arm with SK( arm; parses SK(key,[keep_mods],max_repeat,timeout_ms,exit_on_layer_change) using bracket-delimited keep-mod list and optional trailing args; emits ::rmk::sk!() - behavior.rs: rename expand_sticky_mod→expand_sticky_key, use StickyKeyConfig, update field to sticky_key: in BehaviorConfig quote block; add sticky_mod: StickyModConfig::default() for remaining field --- rmk-macro/src/codegen/action_parser.rs | 64 ++++++++++++++++++-------- rmk-macro/src/codegen/behavior.rs | 13 +++--- 2 files changed, 51 insertions(+), 26 deletions(-) diff --git a/rmk-macro/src/codegen/action_parser.rs b/rmk-macro/src/codegen/action_parser.rs index 27a1352bb..546f33cce 100644 --- a/rmk-macro/src/codegen/action_parser.rs +++ b/rmk-macro/src/codegen/action_parser.rs @@ -223,34 +223,58 @@ pub(crate) fn parse_key( ); } } - s if s.to_lowercase().starts_with("sm(") => { + s if s.to_lowercase().starts_with("sk(") => { let prefix = s.get(0..3).unwrap(); if let Some(internal) = s.trim_start_matches(prefix).strip_suffix(")") { - let keys: Vec<&str> = internal - .split_terminator(",") - .map(|w| w.trim()) - .filter(|w| !w.is_empty()) - .collect(); - if keys.len() != 2 { + // Parse: "Tab, [LAlt]" or "Tab, [LAlt], 5, 3000, true" + let bracket_start = internal.find('[').unwrap_or_else(|| { panic!( - "\n\u{274c} keyboard.toml: SM(key, modifier) requires exactly 2 arguments, got {}. Usage: SM(Tab, LAlt)", - keys.len() - ); - } + "\n\u{274c} keyboard.toml: SK requires a bracketed keep-mod list. \ + Usage: SK(Tab, [LAlt]) or SK(Tab, [LCtrl|LShift], 3, 2000, true)" + ) + }); + let bracket_end = internal.find(']').unwrap_or_else(|| { + panic!( + "\n\u{274c} keyboard.toml: SK has unclosed '['. \ + Usage: SK(Tab, [LAlt])" + ) + }); - let ident = get_key_with_alias(keys[0].to_string()); - let modifiers = parse_modifiers(keys[1]); + let key_str = internal[..bracket_start].trim().trim_end_matches(',').trim(); + let ident = get_key_with_alias(key_str.to_string()); + + let keep_mods_str = &internal[bracket_start + 1..bracket_end]; + let keep_modifiers = if keep_mods_str.trim().is_empty() { + ModifierCombinationMacro::new() + } else { + parse_modifiers(keep_mods_str) + }; + + let after_bracket = internal[bracket_end + 1..].trim_start_matches(',').trim(); + let optional_args: Vec<&str> = if after_bracket.is_empty() { + vec![] + } else { + after_bracket.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect() + }; + + let max_repeat: u16 = optional_args.first() + .and_then(|s| s.parse().ok()) + .unwrap_or(0u16); + let timeout_ms: u16 = optional_args.get(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(0u16); + let exit_on_layer_change: bool = optional_args.get(2) + .map(|s| s.trim() == "true") + .unwrap_or(false); - if modifiers.is_empty() { - panic!( - "\n\u{274c} keyboard.toml: modifier in SM(key, modifier) is not valid! Usage: SM(Tab, LAlt)" - ); - } quote! { - ::rmk::sm!(#ident, #modifiers) + ::rmk::sk!(#ident, #keep_modifiers, #max_repeat, #timeout_ms, #exit_on_layer_change) } } else { - panic!("\n\u{274c} keyboard.toml: SM(key, modifier) invalid. Usage: SM(Tab, LAlt)"); + panic!( + "\n\u{274c} keyboard.toml: SK(...) invalid. \ + Usage: SK(Tab, [LAlt]) or SK(Tab, [LCtrl|LShift], 3, 2000, true)" + ); } } s if s.to_lowercase().starts_with("lm(") => { diff --git a/rmk-macro/src/codegen/behavior.rs b/rmk-macro/src/codegen/behavior.rs index 6a063c001..80ea2f6d4 100644 --- a/rmk-macro/src/codegen/behavior.rs +++ b/rmk-macro/src/codegen/behavior.rs @@ -64,17 +64,17 @@ fn expand_one_shot_modifiers(one_shot_modifiers: &Option) -> proc_macro } } -fn expand_sticky_mod(sticky_mod_timeout_ms: &Option) -> proc_macro2::TokenStream { - match sticky_mod_timeout_ms { +fn expand_sticky_key(sticky_key_timeout_ms: &Option) -> proc_macro2::TokenStream { + match sticky_key_timeout_ms { Some(millis) => { let timeout = quote! { ::embassy_time::Duration::from_millis(#millis) }; quote! { - ::rmk::config::StickyModConfig { + ::rmk::config::StickyKeyConfig { timeout: #timeout, } } } - None => quote! { ::rmk::config::StickyModConfig::default() }, + None => quote! { ::rmk::config::StickyKeyConfig::default() }, } } @@ -510,7 +510,7 @@ pub(crate) fn expand_behavior_config(behavior: &Behavior) -> proc_macro2::TokenS let macros = expand_macros(&behavior.macros); let forks = expand_forks(&behavior.forks, &profiles); let morse = expand_morse(&behavior.morse); - let sticky_mod = expand_sticky_mod(&behavior.sticky_key_timeout_ms); + let sticky_key = expand_sticky_key(&behavior.sticky_key_timeout_ms); quote! { #[allow(clippy::needless_update)] @@ -524,7 +524,8 @@ 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_mod: #sticky_mod, + sticky_mod: ::rmk::config::StickyModConfig::default(), + sticky_key: #sticky_key, }; } } From fc2bb3a488481531b14b941871193e654dc8cb2c Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Fri, 29 May 2026 19:20:16 -0500 Subject: [PATCH 027/119] =?UTF-8?q?refactor:=20remove=20all=20StickyMod=20?= =?UTF-8?q?code=20=E2=80=94=20replaced=20by=20StickyKey?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rmk-macro/src/codegen/behavior.rs | 1 - rmk-types/src/action/mod.rs | 3 - rmk/src/config/behavior.rs | 16 -- rmk/src/config/mod.rs | 2 +- rmk/src/keyboard.rs | 41 +--- rmk/src/keyboard/sticky_key.rs | 2 +- rmk/src/keyboard/sticky_mod.rs | 112 ---------- rmk/src/keymap.rs | 4 - rmk/src/layout_macro.rs | 26 --- rmk/tests/keyboard_sticky_mod_test.rs | 297 -------------------------- 10 files changed, 4 insertions(+), 500 deletions(-) delete mode 100644 rmk/src/keyboard/sticky_mod.rs delete mode 100644 rmk/tests/keyboard_sticky_mod_test.rs diff --git a/rmk-macro/src/codegen/behavior.rs b/rmk-macro/src/codegen/behavior.rs index 80ea2f6d4..1fc5024f1 100644 --- a/rmk-macro/src/codegen/behavior.rs +++ b/rmk-macro/src/codegen/behavior.rs @@ -524,7 +524,6 @@ 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_mod: ::rmk::config::StickyModConfig::default(), sticky_key: #sticky_key, }; } diff --git a/rmk-types/src/action/mod.rs b/rmk-types/src/action/mod.rs index 07ce8288d..0f949a1ff 100644 --- a/rmk-types/src/action/mod.rs +++ b/rmk-types/src/action/mod.rs @@ -96,9 +96,6 @@ pub enum Action { /// Sticky key: sends modifier + key on each press, holds modifiers between presses. /// Supports max_repeat, per-key timeout, and conditional exit on layer change. StickyKey(StickyKeyAction), - /// Sticky modifier: sends key + modifier on press, holds modifier until - /// another key is pressed or layer changes. Used for Alt+Tab-like switching. - StickyMod(KeyCode, ModifierCombination), /// A Plover HID stenography key. Press/release of this key updates the /// in-progress steno chord; on first release the accumulated chord is /// sent to the host as a vendor HID report. diff --git a/rmk/src/config/behavior.rs b/rmk/src/config/behavior.rs index 6ce590182..f20bdba45 100644 --- a/rmk/src/config/behavior.rs +++ b/rmk/src/config/behavior.rs @@ -18,7 +18,6 @@ pub struct BehaviorConfig { pub morse: MorsesConfig, pub keyboard_macros: KeyboardMacrosConfig, pub mouse_key: MouseKeyConfig, - pub sticky_mod: StickyModConfig, pub sticky_key: StickyKeyConfig, } @@ -83,21 +82,6 @@ pub struct OneShotModifiersConfig { pub quick_release: bool, } -/// Configuration for StickyMod behavior -#[derive(Clone, Copy, Debug)] -pub struct StickyModConfig { - /// Timeout before automatically releasing the held modifier. - /// Duration::MAX means no timeout — modifier is held until - /// another key press or layer change. - pub timeout: Duration, -} - -impl Default for StickyModConfig { - fn default() -> Self { - Self { timeout: Duration::MAX } - } -} - /// Configuration for StickyKey behavior #[derive(Clone, Copy, Debug)] pub struct StickyKeyConfig { diff --git a/rmk/src/config/mod.rs b/rmk/src/config/mod.rs index 1915f3bee..4967e67d5 100644 --- a/rmk/src/config/mod.rs +++ b/rmk/src/config/mod.rs @@ -8,7 +8,7 @@ mod vial; pub use behavior::{ BehaviorConfig, CombosConfig, ForksConfig, KeyboardMacrosConfig, MorsesConfig, MouseKeyConfig, OneShotConfig, - OneShotModifiersConfig, StickyKeyConfig, StickyModConfig, TapConfig, + OneShotModifiersConfig, StickyKeyConfig, TapConfig, }; #[cfg(feature = "_ble")] pub use ble_battery::BleBatteryConfig; diff --git a/rmk/src/keyboard.rs b/rmk/src/keyboard.rs index 8555ef7b5..411850ed8 100644 --- a/rmk/src/keyboard.rs +++ b/rmk/src/keyboard.rs @@ -29,7 +29,6 @@ use crate::keyboard::fork::ActiveFork; use crate::keyboard::held_buffer::{HeldBuffer, HeldKey, KeyState}; use crate::keyboard::mouse::{MouseAction, MouseState}; use crate::keyboard::oneshot::OneShotState; -use crate::keyboard::sticky_mod::StickyModState; use crate::keyboard::sticky_key::StickyKeyState; use crate::keyboard_macros::MacroOperation; use crate::keymap::KeyMap; @@ -45,7 +44,6 @@ pub(crate) mod mouse; pub(crate) mod oneshot; #[cfg(feature = "steno")] pub(crate) mod steno; -pub(crate) mod sticky_mod; pub(crate) mod sticky_key; use crate::keymap::HOLD_BUFFER_SIZE; @@ -156,11 +154,10 @@ impl Runnable for Keyboard<'_> { // Process buffered held key self.process_buffered_key(key).await } else { - // Race subscriber against any pending deadlines (mouse repeat, SM timeout) - let sm_deadline = self.sticky_mod_state.deadline(); + // Race subscriber against any pending deadlines (mouse repeat, SK timeout) let sk_deadline = self.sticky_key_state.deadline(); let mouse_deadline = self.mouse.next_deadline(); - let combined_deadline = [sm_deadline, sk_deadline, mouse_deadline] + let combined_deadline = [sk_deadline, mouse_deadline] .into_iter() .flatten() .reduce(|a, b| a.min(b)); @@ -169,9 +166,6 @@ impl Runnable for Keyboard<'_> { Ok(event) => event, Err(_) => { let now = Instant::now(); - if sm_deadline.is_some_and(|d| now >= d) { - self.release_sticky_mod_if_active().await; - } if sk_deadline.is_some_and(|d| now >= d) { self.release_sticky_key_if_active().await; } @@ -225,9 +219,6 @@ pub struct Keyboard<'a> { /// Oneshot Modifier state osm_state: OneShotState, - /// StickyMod state — holds modifier across key presses for Alt+Tab-like behavior - sticky_mod_state: StickyModState, - /// StickyKey state — holds a modifier+key combination across key presses sticky_key_state: StickyKeyState, @@ -284,7 +275,6 @@ impl<'a> Keyboard<'a> { last_press_time: Instant::now(), osl_state: OneShotState::default(), osm_state: OneShotState::default(), - sticky_mod_state: StickyModState::default(), sticky_key_state: StickyKeyState::default(), caps_word: CapsWordState::default(), with_modifiers: ModifierCombination::default(), @@ -1227,19 +1217,6 @@ impl<'a> Keyboard<'a> { }) .await; - // Release StickyMod when any non-SM, non-modifier key is pressed. - // Modifier keys (Shift, Ctrl, etc.) are excluded so Shift+Tab reverse cycling works. - if event.pressed && self.sticky_mod_state.is_active() { - let is_sm_or_modifier = match action { - Action::StickyMod(_, _) | Action::Modifier(_) => true, - Action::Key(KeyCode::Hid(hid_key)) if hid_key.is_modifier() => true, - _ => false, - }; - if !is_sm_or_modifier { - self.release_sticky_mod_if_active().await; - } - } - // Release StickyKey when any non-SK, non-modifier key is pressed. if event.pressed && self.sticky_key_state.is_active() { let is_sk_or_modifier = match action { @@ -1261,7 +1238,6 @@ impl<'a> Keyboard<'a> { // Reactivate the layer after the key is released if event.pressed { self.keymap.deactivate_layer(layer_num); - self.release_sticky_mod_if_active().await; if self.sticky_key_state.exit_on_layer_change() { self.release_sticky_key_if_active().await; } @@ -1271,7 +1247,6 @@ impl<'a> Keyboard<'a> { // Toggle a layer when the key is released if !event.pressed { self.keymap.toggle_layer(layer_num); - self.release_sticky_mod_if_active().await; if self.sticky_key_state.exit_on_layer_change() { self.release_sticky_key_if_active().await; } @@ -1290,7 +1265,6 @@ impl<'a> Keyboard<'a> { } // Activate the target layer self.keymap.activate_layer(layer_num); - self.release_sticky_mod_if_active().await; if self.sticky_key_state.exit_on_layer_change() { self.release_sticky_key_if_active().await; } @@ -1299,7 +1273,6 @@ impl<'a> Keyboard<'a> { Action::DefaultLayer(layer_num) => { // Set the default layer self.keymap.set_default_layer(layer_num); - self.release_sticky_mod_if_active().await; if self.sticky_key_state.exit_on_layer_change() { self.release_sticky_key_if_active().await; } @@ -1350,9 +1323,6 @@ impl<'a> Keyboard<'a> { // Process OSL to avoid the OSM state stuck when an OSM is followed by an OSL self.update_osl(event); } - Action::StickyMod(key, modifiers) => { - self.process_action_sticky_mod(key, modifiers, event).await; - } Action::StickyKey(params) => { self.process_action_sticky_key(params, event).await; } @@ -1424,11 +1394,6 @@ impl<'a> Keyboard<'a> { result |= osm; }; - // Add StickyMod modifiers if active - if let Some(sm_mods) = self.sticky_mod_state.value() { - result |= *sm_mods; - } - // Add StickyKey modifiers if active if let Some(sk_mods) = self.sticky_key_state.value() { result |= *sk_mods; @@ -1632,8 +1597,6 @@ impl<'a> Keyboard<'a> { self.keymap.activate_layer(layer_num); } else { self.keymap.deactivate_layer(layer_num); - // Clean up StickyMod when layer deactivates - self.release_sticky_mod_if_active().await; if self.sticky_key_state.exit_on_layer_change() { self.release_sticky_key_if_active().await; } diff --git a/rmk/src/keyboard/sticky_key.rs b/rmk/src/keyboard/sticky_key.rs index 08dfb6a25..84542570a 100644 --- a/rmk/src/keyboard/sticky_key.rs +++ b/rmk/src/keyboard/sticky_key.rs @@ -1,7 +1,7 @@ //! StickyKey action implementation. //! //! StickyKey holds a modifier combination across key presses for Alt+Tab-like cycling. -//! Compared to StickyMod, it adds: +//! Features: //! - `max_repeat`: limit fires before auto-release (0 = infinite) //! - `timeout_ms`: per-key timeout override (0 = use global config) //! - `exit_on_layer_change`: whether layer changes release the SK diff --git a/rmk/src/keyboard/sticky_mod.rs b/rmk/src/keyboard/sticky_mod.rs deleted file mode 100644 index 42c2243ff..000000000 --- a/rmk/src/keyboard/sticky_mod.rs +++ /dev/null @@ -1,112 +0,0 @@ -//! StickyMod action implementation -//! -//! StickyMod provides Alt+Tab-like window/tab switching behavior. -//! On first press: sends modifier + key. On release: holds modifier. -//! Subsequent presses send only the key. Modifier releases when any -//! non-SM/non-modifier key is pressed, or when the layer changes, -//! or when the optional timeout fires from the main event loop. - -use embassy_time::{Duration, Instant}; -use rmk_types::keycode::KeyCode; -use rmk_types::modifier::ModifierCombination; - -use crate::event::KeyboardEvent; -use crate::keyboard::Keyboard; - -/// State for StickyMod action -#[derive(Default, Debug)] -pub(crate) enum StickyModState { - /// StickyMod is inactive - #[default] - None, - /// StickyMod is active — modifier is held, optional deadline for auto-release - Active { - mods: ModifierCombination, - /// When to auto-release the modifier. None = no timeout. - deadline: Option, - }, -} - -impl StickyModState { - /// Get the held modifiers if StickyMod is active - pub fn value(&self) -> Option<&ModifierCombination> { - match self { - StickyModState::Active { mods, .. } => Some(mods), - StickyModState::None => None, - } - } - - /// Check if StickyMod is currently active - pub fn is_active(&self) -> bool { - matches!(self, StickyModState::Active { .. }) - } - - /// Return the auto-release deadline if one is set, for use in the main event loop - pub fn deadline(&self) -> Option { - match self { - StickyModState::Active { deadline, .. } => *deadline, - StickyModState::None => None, - } - } -} - -impl Keyboard<'_> { - /// Process StickyMod action - /// - /// Flow: - /// - First press: activate SM state with deadline, register modifier + key, send report - /// - Subsequent press: reset deadline, register key again - /// - Release: unregister key, modifier stays held (deadline unchanged) - /// - Timeout: fires from `run()` loop via `sticky_mod_state.deadline()` - /// - Any non-SM/non-modifier key press: release_sticky_mod_if_active() called before processing - /// - Layer change: release_sticky_mod_if_active() called as cleanup - pub(crate) async fn process_action_sticky_mod( - &mut self, - key: KeyCode, - modifiers: ModifierCombination, - event: KeyboardEvent, - ) { - if event.pressed { - let timeout = self.keymap.sticky_mod_timeout(); - let deadline = (timeout != Duration::MAX).then(|| Instant::now() + timeout); - - match &mut self.sticky_mod_state { - StickyModState::None => { - self.sticky_mod_state = StickyModState::Active { - mods: modifiers, - deadline, - }; - } - StickyModState::Active { deadline: d, .. } => { - // Reset deadline on each SM press (timeout counts from last press) - *d = deadline; - } - } - - if let KeyCode::Hid(hid_key) = key { - self.register_key(hid_key, event); - } - self.send_keyboard_report_with_resolved_modifiers(true).await; - } else { - // Release the key; modifier stays held via StickyModState::Active. - // Deadline remains — the run() loop fires auto-release when it expires. - if let KeyCode::Hid(hid_key) = key { - self.unregister_key(hid_key, event); - } - self.send_keyboard_report_with_resolved_modifiers(false).await; - } - } - - /// Release StickyMod if active. Called when: - /// - A non-SM, non-modifier key is pressed - /// - A layer is deactivated - /// - Timeout deadline fires in the main event loop - pub(crate) async fn release_sticky_mod_if_active(&mut self) { - if self.sticky_mod_state.is_active() { - debug!("Releasing StickyMod"); - self.sticky_mod_state = StickyModState::None; - // Send report to reflect modifier release - self.send_keyboard_report_with_resolved_modifiers(false).await; - } - } -} diff --git a/rmk/src/keymap.rs b/rmk/src/keymap.rs index 05fd1ade6..f7228c74d 100644 --- a/rmk/src/keymap.rs +++ b/rmk/src/keymap.rs @@ -512,10 +512,6 @@ impl<'a> KeyMap<'a> { self.inner.borrow().behavior.one_shot.timeout } - pub(crate) fn sticky_mod_timeout(&self) -> Duration { - self.inner.borrow().behavior.sticky_mod.timeout - } - pub(crate) fn sticky_key_timeout(&self) -> Duration { self.inner.borrow().behavior.sticky_key.timeout } diff --git a/rmk/src/layout_macro.rs b/rmk/src/layout_macro.rs index 5c1de076d..0c80bfd43 100644 --- a/rmk/src/layout_macro.rs +++ b/rmk/src/layout_macro.rs @@ -355,32 +355,6 @@ macro_rules! osm { }; } -/// Create a StickyMod action for Alt+Tab-like switching. -/// -/// Sends modifier + key on first press, holds modifier across subsequent presses. -/// Modifier releases when any other key is pressed or layer changes. -/// -/// # Parameters -/// - `$x`: HID keycode identifier (e.g., `Tab`, `A`) -/// - `$m`: `ModifierCombination` to hold -/// -/// # Example -/// ```ignore -/// // Alt+Tab window switcher -/// sm!(Tab, ModifierCombination::LALT) -/// // Ctrl+Tab browser tab switcher -/// sm!(Tab, ModifierCombination::LCTRL) -/// ``` -#[macro_export] -macro_rules! sm { - ($x: ident, $m: expr) => { - $crate::types::action::KeyAction::Single($crate::types::action::Action::StickyMod( - $crate::types::keycode::KeyCode::Hid($crate::types::keycode::HidKeyCode::$x), - $m, - )) - }; -} - /// Create a StickyKey action. /// /// # Parameters diff --git a/rmk/tests/keyboard_sticky_mod_test.rs b/rmk/tests/keyboard_sticky_mod_test.rs deleted file mode 100644 index b70beca12..000000000 --- a/rmk/tests/keyboard_sticky_mod_test.rs +++ /dev/null @@ -1,297 +0,0 @@ -pub mod common; - -use embassy_time::Duration; -use rmk::config::{BehaviorConfig, PositionalConfig, StickyModConfig}; -use rmk::keyboard::Keyboard; -use rmk::types::action::KeyAction; -use rmk::types::modifier::ModifierCombination; -use rmk::{a, k, mo, sm}; -use rusty_fork::rusty_fork_test; - -use crate::common::{KC_LALT, KC_LCTRL, KC_LSHIFT, wrap_keymap}; - -// KEYMAP -// Layer 0: A B C MO(1) LShift No -// Layer 1: SM(Tab,LAlt) SM(Tab,LCtrl) SM(Tab,LCtrl|LShift) Transparent Transparent No - -const KEYMAP: [[[KeyAction; 6]; 1]; 2] = [ - [[ - // Layer 0 - k!(A), // col 0: A - k!(B), // col 1: B - k!(C), // col 2: C - mo!(1), // col 3: MO(1) — momentary layer - k!(LShift), // col 4: LShift - a!(No), // col 5: No - ]], - [[ - // Layer 1 - sm!(Tab, ModifierCombination::LALT), // col 0: SM(Tab, LAlt) - sm!(Tab, ModifierCombination::LCTRL), // col 1: SM(Tab, LCtrl) - sm!( - Tab, - ModifierCombination::new_from_vals(true, true, false, false, false, false, false, false) - ), // col 2: SM(Tab, LCtrl|LShift) - a!(Transparent), // col 3: Transparent - a!(Transparent), // col 4: Transparent → LShift - a!(No), // col 5: No - ]], -]; - -fn create_test_keyboard() -> Keyboard<'static> { - static BEHAVIOR_CONFIG: static_cell::StaticCell = static_cell::StaticCell::new(); - let behavior_config = BEHAVIOR_CONFIG.init(BehaviorConfig::default()); - static KEY_CONFIG: static_cell::StaticCell> = static_cell::StaticCell::new(); - let per_key_config = KEY_CONFIG.init(PositionalConfig::default()); - Keyboard::new(wrap_keymap(KEYMAP, per_key_config, behavior_config)) -} - -fn create_test_keyboard_with_behavior_config(config: BehaviorConfig) -> Keyboard<'static> { - let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(config)); - let per_key_config: &'static PositionalConfig<1, 6> = Box::leak(Box::new(PositionalConfig::default())); - Keyboard::new(wrap_keymap(KEYMAP, per_key_config, behavior_config)) -} - -rusty_fork_test! { - /// StickyMod Test 1: Basic SM flow — press SM twice while MO held - /// - /// Sequence: - /// - Press MO(1) → layer activates, no report - /// - Press SM(Tab,LAlt) → [KC_LALT, [Tab, ...]] - /// - Release SM → [KC_LALT, [0, ...]] (modifier held) - /// - Press SM again → [KC_LALT, [Tab, ...]] - /// - Release SM → [KC_LALT, [0, ...]] - /// - Release MO(1) → [0, [0, ...]] (layer deactivation cleans up SM) - #[test] - fn test_sm_basic_flow_press_twice() { - key_sequence_test! { - keyboard: create_test_keyboard(), - sequence: [ - [0, 3, true, 10], // Press MO(1) - [0, 0, true, 10], // Press SM(Tab, LAlt) - [0, 0, false, 10], // Release SM - [0, 0, true, 10], // Press SM again - [0, 0, false, 10], // Release SM - [0, 3, false, 10], // Release MO(1) - ], - expected_reports: [ - [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SM press: Alt+Tab - [KC_LALT, [0, 0, 0, 0, 0, 0]], // SM release: Alt held - [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SM press again: Alt+Tab - [KC_LALT, [0, 0, 0, 0, 0, 0]], // SM release: Alt held - [0, [0, 0, 0, 0, 0, 0]], // MO release: SM cleaned up - ] - }; - } - - /// StickyMod Test 2: Layer change cleanup - /// - /// Sequence: - /// - Press MO(1), press SM(Tab,LAlt), release SM, release MO(1) - /// - /// Expected: - /// - SM press: Alt+Tab - /// - SM release: Alt held - /// - MO release: cleans up SM, sends [0, [0,...]] - #[test] - fn test_sm_layer_change_cleanup() { - key_sequence_test! { - keyboard: create_test_keyboard(), - sequence: [ - [0, 3, true, 10], // Press MO(1) - [0, 0, true, 10], // Press SM(Tab, LAlt) - [0, 0, false, 10], // Release SM - [0, 3, false, 10], // Release MO(1) → triggers SM cleanup - ], - expected_reports: [ - [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SM press: Alt+Tab - [KC_LALT, [0, 0, 0, 0, 0, 0]], // SM release: Alt held - [0, [0, 0, 0, 0, 0, 0]], // MO release: SM cleaned up - ] - }; - } - - /// StickyMod Test 3: Shift integration — Shift does NOT release SM - /// - /// Sequence: - /// - Press MO(1), press SM(Tab,LCtrl), release SM - /// - Press LShift (col 4, transparent → LShift) — should NOT release SM - /// - Press SM again, release SM - /// - Release LShift, release MO(1) - /// - /// Expected: - /// - SM press: Ctrl+Tab - /// - SM release: Ctrl held - /// - Shift press: Ctrl+Shift held (SM not released) - /// - SM press: Ctrl+Shift+Tab - /// - SM release: Ctrl+Shift held - /// - Shift release: Ctrl held - /// - MO release: SM cleaned up - #[test] - fn test_sm_shift_does_not_release_sm() { - key_sequence_test! { - keyboard: create_test_keyboard(), - sequence: [ - [0, 3, true, 10], // Press MO(1) - [0, 1, true, 10], // Press SM(Tab, LCtrl) - [0, 1, false, 10], // Release SM - [0, 4, true, 10], // Press LShift (Transparent → LShift on L0) - [0, 1, true, 10], // Press SM again - [0, 1, false, 10], // Release SM - [0, 4, false, 10], // Release LShift - [0, 3, false, 10], // Release MO(1) - ], - expected_reports: [ - [KC_LCTRL, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SM press: Ctrl+Tab - [KC_LCTRL, [0, 0, 0, 0, 0, 0]], // SM release: Ctrl held - [KC_LCTRL | KC_LSHIFT, [0, 0, 0, 0, 0, 0]], // Shift press: Ctrl+Shift (SM not released) - [KC_LCTRL | KC_LSHIFT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SM press: Ctrl+Shift+Tab - [KC_LCTRL | KC_LSHIFT, [0, 0, 0, 0, 0, 0]], // SM release: Ctrl+Shift held - [KC_LCTRL, [0, 0, 0, 0, 0, 0]], // Shift release: Ctrl held - [0, [0, 0, 0, 0, 0, 0]], // MO release: SM cleaned up - ] - }; - } - - /// StickyMod Test 4: Rapid presses — 3x SM press/release while MO held - /// - /// Sequence: - /// - Press MO(1), then 3x (press SM, release SM), release MO(1) - /// - /// Expected: Each SM press sends Alt+Tab; each release holds Alt; MO release cleans up. - #[test] - fn test_sm_rapid_three_presses() { - key_sequence_test! { - keyboard: create_test_keyboard(), - sequence: [ - [0, 3, true, 10], // Press MO(1) - [0, 0, true, 10], // Press SM #1 - [0, 0, false, 10], // Release SM #1 - [0, 0, true, 10], // Press SM #2 - [0, 0, false, 10], // Release SM #2 - [0, 0, true, 10], // Press SM #3 - [0, 0, false, 10], // Release SM #3 - [0, 3, false, 10], // Release MO(1) - ], - expected_reports: [ - [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SM #1 press - [KC_LALT, [0, 0, 0, 0, 0, 0]], // SM #1 release - [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SM #2 press - [KC_LALT, [0, 0, 0, 0, 0, 0]], // SM #2 release - [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SM #3 press - [KC_LALT, [0, 0, 0, 0, 0, 0]], // SM #3 release - [0, [0, 0, 0, 0, 0, 0]], // MO release: SM cleaned up - ] - }; - } - - /// StickyMod Test 5: Combined modifiers LCtrl|LShift - /// - /// Sequence: - /// - Press MO(1), press SM(Tab,LCtrl|LShift) at col 2, release SM, release MO(1) - /// - /// Expected: - /// - SM press: Ctrl+Shift+Tab - /// - SM release: Ctrl+Shift held - /// - MO release: SM cleaned up - #[test] - fn test_sm_combined_modifiers() { - key_sequence_test! { - keyboard: create_test_keyboard(), - sequence: [ - [0, 3, true, 10], // Press MO(1) - [0, 2, true, 10], // Press SM(Tab, LCtrl|LShift) - [0, 2, false, 10], // Release SM - [0, 3, false, 10], // Release MO(1) - ], - expected_reports: [ - [KC_LCTRL | KC_LSHIFT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SM press: Ctrl+Shift+Tab - [KC_LCTRL | KC_LSHIFT, [0, 0, 0, 0, 0, 0]], // SM release: Ctrl+Shift held - [0, [0, 0, 0, 0, 0, 0]], // MO release: SM cleaned up - ] - }; - } - - /// StickyMod Test 6: Timeout — modifier auto-releases after inactivity - /// - /// Config: timeout = 100ms - /// - /// Sequence: - /// - Press MO(1), press SM(Tab,LAlt), release SM → timer starts (100ms) - /// - Wait 150ms → timer fires, Alt auto-released - /// - Release MO(1) (SM already inactive — no cleanup report) - /// - Press C on layer 0 (no modifier), release C - /// - /// Note: MO(1) must be released before pressing the verification key so that - /// col 2 resolves to k!(C) on layer 0 rather than SM(Tab,LCtrl|LShift) on layer 1. - #[test] - fn test_sm_timeout() { - key_sequence_test! { - keyboard: create_test_keyboard_with_behavior_config(BehaviorConfig { - sticky_mod: StickyModConfig { - timeout: Duration::from_millis(100), - }, - ..BehaviorConfig::default() - }), - sequence: [ - [0, 3, true, 10], // Press MO(1) - [0, 0, true, 10], // Press SM(Tab, LAlt) - [0, 0, false, 10], // Release SM → timer starts (100ms) - [0, 3, false, 150], // Wait 150ms (timer fires!), then release MO(1) - [0, 2, true, 10], // Press C on layer 0 (no modifier) - [0, 2, false, 10], // Release C - ], - expected_reports: [ - [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SM press: Alt+Tab - [KC_LALT, [0, 0, 0, 0, 0, 0]], // SM release: Alt held, timer starts - [0, [0, 0, 0, 0, 0, 0]], // Timeout: Alt auto-released - // MO(1) release: SM already inactive, no report - [0, [kc_to_u8!(C), 0, 0, 0, 0, 0]], // C press: no modifier - [0, [0, 0, 0, 0, 0, 0]], // C release - ] - }; - } - - /// StickyMod Test 7: Timeout resets on each SM press - /// - /// Config: timeout = 100ms - /// - /// Sequence: - /// - Press MO(1), press SM #1, release SM #1 → T1 starts (100ms) - /// - At 50ms: press SM #2 → T1 cancelled, SM #2 processed from unprocessed queue - /// - Release SM #2 → T2 starts (100ms reset) - /// - Wait 150ms → T2 fires, Alt auto-released - /// - Release MO(1) (SM already inactive — no cleanup report) - /// - Press C on layer 0 (no modifier), release C - #[test] - fn test_sm_timeout_resets_on_press() { - key_sequence_test! { - keyboard: create_test_keyboard_with_behavior_config(BehaviorConfig { - sticky_mod: StickyModConfig { - timeout: Duration::from_millis(100), - }, - ..BehaviorConfig::default() - }), - sequence: [ - [0, 3, true, 10], // Press MO(1) - [0, 0, true, 10], // Press SM #1 - [0, 0, false, 10], // Release SM #1 → T1 starts (100ms) - [0, 0, true, 50], // At 50ms: press SM #2 → T1 cancelled - [0, 0, false, 10], // Release SM #2 → T2 starts (100ms reset) - [0, 3, false, 150], // Wait 150ms (T2 fires!), then release MO(1) - [0, 2, true, 10], // Press C on layer 0 (no modifier) - [0, 2, false, 10], // Release C - ], - expected_reports: [ - [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SM #1 press: Alt+Tab - [KC_LALT, [0, 0, 0, 0, 0, 0]], // SM #1 release: Alt held (T1 starts) - [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SM #2 press: Alt+Tab (T1 cancelled) - [KC_LALT, [0, 0, 0, 0, 0, 0]], // SM #2 release: Alt held (T2 starts) - [0, [0, 0, 0, 0, 0, 0]], // T2 fires: Alt auto-released - // MO(1) release: SM already inactive, no report - [0, [kc_to_u8!(C), 0, 0, 0, 0, 0]], // C press: no modifier - [0, [0, 0, 0, 0, 0, 0]], // C release - ] - }; - } -} From 270712622e912ff2901bade5d29311eb1a30e882 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Fri, 29 May 2026 19:54:04 -0500 Subject: [PATCH 028/119] chore(test): remove unused StickyKeyAction import --- rmk/tests/keyboard_sticky_key_test.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rmk/tests/keyboard_sticky_key_test.rs b/rmk/tests/keyboard_sticky_key_test.rs index 20f27f86d..dbf4c7aae 100644 --- a/rmk/tests/keyboard_sticky_key_test.rs +++ b/rmk/tests/keyboard_sticky_key_test.rs @@ -3,7 +3,7 @@ pub mod common; use embassy_time::Duration; use rmk::config::{BehaviorConfig, PositionalConfig, StickyKeyConfig}; use rmk::keyboard::Keyboard; -use rmk::types::action::{KeyAction, StickyKeyAction}; +use rmk::types::action::KeyAction; use rmk::types::modifier::ModifierCombination; use rmk::{a, k, mo, sk}; use rusty_fork::rusty_fork_test; From 33c24153af6b2bdea0ba1bb178e87a02184f4314 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Fri, 29 May 2026 20:08:30 -0500 Subject: [PATCH 029/119] fix(keyboard): skip release report when max_repeat deactivates SK silently; simplify repeat check --- rmk/src/keyboard/sticky_key.rs | 15 +++++++++------ rmk/tests/keyboard_sticky_key_test.rs | 4 ++++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/rmk/src/keyboard/sticky_key.rs b/rmk/src/keyboard/sticky_key.rs index 84542570a..473a572f4 100644 --- a/rmk/src/keyboard/sticky_key.rs +++ b/rmk/src/keyboard/sticky_key.rs @@ -92,9 +92,7 @@ impl Keyboard<'_> { .. } => { *repeat_count += 1; - let count = *repeat_count; - let mr_val = *mr; - if mr_val > 0 && count > mr_val { + if *mr > 0 && *repeat_count > *mr { should_deactivate = true; } else { *d = deadline; @@ -112,10 +110,15 @@ impl Keyboard<'_> { self.send_keyboard_report_with_resolved_modifiers(true).await; } } else { - if let KeyCode::Hid(hid_key) = params.key { - self.unregister_key(hid_key, event); + // Only unregister and report if SK was active (key was registered on press). + // If max_repeat deactivated SK silently on the press event, the key was never + // registered, so the release is a no-op. + if self.sticky_key_state.is_active() { + if let KeyCode::Hid(hid_key) = params.key { + self.unregister_key(hid_key, event); + } + self.send_keyboard_report_with_resolved_modifiers(false).await; } - self.send_keyboard_report_with_resolved_modifiers(false).await; } } diff --git a/rmk/tests/keyboard_sticky_key_test.rs b/rmk/tests/keyboard_sticky_key_test.rs index dbf4c7aae..c5d073d74 100644 --- a/rmk/tests/keyboard_sticky_key_test.rs +++ b/rmk/tests/keyboard_sticky_key_test.rs @@ -405,6 +405,8 @@ rusty_fork_test! { [0, 0, true, 10], // Press SK #3 → max_repeat reached, deactivate [0, 0, false, 10], // Release SK #3 [0, 3, false, 10], // Release MO(1) + [0, 0, true, 10], // Press A on layer 0 — SK deactivated, no modifier + [0, 0, false, 10], // Release A ], expected_reports: [ [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK #1 press: Alt+Tab @@ -412,6 +414,8 @@ rusty_fork_test! { [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK #2 press: Alt+Tab [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK #2 release: Alt held [0, [0, 0, 0, 0, 0, 0]], // SK #3: max_repeat reached, SK deactivated + [0, [kc_to_u8!(A), 0, 0, 0, 0, 0]], // A press: no modifier (SK deactivated cleanly) + [0, [0, 0, 0, 0, 0, 0]], // A release ] }; } From ffaf0d758fef249440d52684b4561ba085fbd7c3 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Fri, 29 May 2026 20:46:53 -0500 Subject: [PATCH 030/119] =?UTF-8?q?docs:=20update=20behavior.md=20and=20la?= =?UTF-8?q?yout.md=20for=20StickyKey=20(SK)=20=E2=80=94=20SM=20docs=20were?= =?UTF-8?q?=20never=20updated=20after=20SM=E2=86=92SK=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/docs/main/docs/configuration/behavior.md | 17 +++++++++++++++++ docs/docs/main/docs/configuration/layout.md | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/docs/main/docs/configuration/behavior.md b/docs/docs/main/docs/configuration/behavior.md index fbb5db96a..1df233282 100644 --- a/docs/docs/main/docs/configuration/behavior.md +++ b/docs/docs/main/docs/configuration/behavior.md @@ -76,6 +76,23 @@ Quick-release example: quick_release = true ``` +## Sticky Key + +The `sticky_key` sub-table configures the StickyKey (`SK`) feature. + +`SK(key, [modifier])` holds a modifier across repeated presses of the same key. This is useful for Alt+Tab-style window/tab cycling: the first press sends `modifier + key` (e.g. Alt+Tab), then on each subsequent press the modifier stays held so only `key` is sent again. The modifier releases automatically when any non-SK, non-modifier key is pressed. + +This differs from `OSM` (one-shot modifier), which sends the modifier only once and always releases after the next keypress. + +Default behavior: no timeout, infinite repeats, modifier survives layer changes. + +Optional global timeout example: +```toml +[behavior.sticky_key] +timeout = "5s" # auto-release the modifier after 5 seconds of inactivity +``` + +To use StickyKey in your keymap, see `SK(key, [modifier])` in the [keymap configuration](./layout#keymap-config). ## Combo diff --git a/docs/docs/main/docs/configuration/layout.md b/docs/docs/main/docs/configuration/layout.md index 476f04bf9..b9f3e8045 100644 --- a/docs/docs/main/docs/configuration/layout.md +++ b/docs/docs/main/docs/configuration/layout.md @@ -124,7 +124,7 @@ The `layer.keys` string should follow several rules: 4. Use `LT(n, key, )` to create a layer activate action or tap key(tap/hold). The `key` here is the RMK [`KeyCode`](https://docs.rs/rmk/latest/rmk/keycode/enum.KeyCode.html), The `profile_name` is optional, which defines the key's [profile](./behavior#per-key-profiles-for-morse-tapdance-tap-hold-fine-tuning) 5. Use `OSL(n)` to create a one-shot layer action, `n` is the layer number 6. Use `OSM(modifier)` to create a one-shot modifier action. The modifier can be chained in the same way as `WM` - 7. Use `SM(key, modifier)` to create a sticky modifier action. The modifier stays held across repeated presses of `key` until any non-SM key is pressed or the layer changes — useful for Alt+Tab-style cycling. The modifier can be chained in the same way as `WM`. See [Sticky Modifiers](./behavior#sticky-modifiers) for optional timeout configuration. + 7. Use `SK(key, [modifier])` to create a sticky key action. The modifier stays held across repeated presses of `key` until any non-SK key is pressed — useful for Alt+Tab-style cycling. The modifier can be chained in the same way as `WM` (e.g. `SK(Tab, [LCtrl|LShift])`). Optional positional args: `SK(key, [mod], max_repeat, timeout_ms, exit_on_layer_change)`. See [Sticky Key](./behavior#sticky-key) for global timeout configuration. 8. Use `TT(n)` to create a layer activate or tap toggle action, `n` is the layer number 9. Use `TG(n)` to create a layer toggle action, `n` is the layer number 10. Use `TO(n)` to create a layer toggle only action (activate layer `n` and deactivate all other layers), `n` is the layer number From 3d8d57231313f2d8e33b8628b9e352777a87bfe7 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Sat, 30 May 2026 19:12:46 -0500 Subject: [PATCH 031/119] fix: expand matches! macro in sticky_key.rs for rustfmt; regenerate wire-format snapshots Snapshots changed because SK adds fields to the behavior wire type, shifting endpoint key hashes. --- .../rmk/snapshots/endpoint_keys_base.snap | 20 +++++++++---------- rmk/src/keyboard/sticky_key.rs | 8 +++++++- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_base.snap b/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_base.snap index 7c92a477f..b670edd4b 100644 --- a/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_base.snap +++ b/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_base.snap @@ -8,22 +8,22 @@ behavior/get REQ 79 40 45 f9 6e 78 ce 15 RESP ac 59 82 ee ea 41 6c 64 behavior/set REQ c0 6d 36 93 9c 5a 7a b0 RESP 92 d6 0a 5d 06 93 e2 17 -combo/get REQ 81 6e 51 70 26 48 4d 13 RESP ed b8 e2 19 7a e2 03 54 -combo/set REQ dd a2 0d 56 15 7b 96 a3 RESP 2c 9b 2b 68 fe 35 21 25 +combo/get REQ 81 6e 51 70 26 48 4d 13 RESP f1 99 c1 35 e9 84 6a db +combo/set REQ e1 e8 9a ee c6 51 d3 87 RESP 2c 9b 2b 68 fe 35 21 25 conn/set_type REQ 59 5c 7b 51 0e ff d7 12 RESP 8f e7 08 b9 4d 3f 68 d5 conn/type REQ 4d f1 b2 e7 8d ec 46 a0 RESP 02 58 66 87 39 d7 b5 b5 -encoder/get REQ 4c 0d e1 c9 58 89 4b 52 RESP ca c8 ac d7 23 33 f2 56 -encoder/set REQ b4 93 2b f7 5f cf 4f 34 RESP ea a8 3d 9e dd 6e 67 c7 -fork/get REQ 21 8f a2 dc 1f e8 3a 1b RESP 1d f5 9e 50 cc f6 25 ee -fork/set REQ 6d 03 c3 4b 8a 03 9a 9f RESP 0c 8a ca c0 83 a9 dc be +encoder/get REQ 4c 0d e1 c9 58 89 4b 52 RESP 8e 10 77 70 ae f6 a7 74 +encoder/set REQ 60 2b 3c 0f 87 68 c7 f3 RESP ea a8 3d 9e dd 6e 67 c7 +fork/get REQ 21 8f a2 dc 1f e8 3a 1b RESP d9 a9 03 20 4c 75 a1 cf +fork/set REQ 29 f1 6f 01 e2 4d d8 9d RESP 0c 8a ca c0 83 a9 dc be keymap/default_layer REQ 3b 9b e3 4e c2 47 56 de RESP 79 3f e3 4e c2 11 56 de -keymap/get REQ 9c ce 0f 70 d3 94 0f fb RESP 0f 27 6e 9e 99 49 89 65 -keymap/set REQ 70 14 f9 d8 1a 17 90 87 RESP a7 01 c4 70 bb ea d3 b9 +keymap/get REQ 9c ce 0f 70 d3 94 0f fb RESP d7 9d 71 ea c0 86 69 5a +keymap/set REQ 6c ba 56 c2 1c 7c 6e a7 RESP a7 01 c4 70 bb ea d3 b9 keymap/set_default_layer REQ 6c 6c 14 62 2a 07 9d b3 RESP 2b 67 98 d3 da 4b f3 98 macro/get REQ 0a 43 62 d5 55 40 09 9d RESP 85 2c 14 7a 94 7c e9 f1 macro/set REQ f7 e6 c3 bd 4c 03 a5 e7 RESP 4e 8c 8b 52 00 fa 68 03 -morse/get REQ f5 0c 0d f1 f0 6b 74 e2 RESP 82 df ee f9 ca 8c 96 e5 -morse/set REQ 7a cd c1 f3 55 84 3c 5c RESP 40 c6 f5 18 aa 72 42 a5 +morse/get REQ f5 0c 0d f1 f0 6b 74 e2 RESP 76 58 a9 5b fe 8d b0 19 +morse/set REQ de d3 27 4d 00 0e 63 64 RESP 40 c6 f5 18 aa 72 42 a5 status/layer/get REQ d7 6a 8a 1b 7b bb be 32 RESP 75 45 8a 1b 7b a5 be 32 status/matrix/get REQ 4b ae a1 68 0d d9 90 44 RESP 63 13 83 85 e4 e0 0b 36 sys/bootloader REQ 29 a1 89 88 85 d6 a1 26 RESP 29 a1 89 88 85 d6 a1 26 diff --git a/rmk/src/keyboard/sticky_key.rs b/rmk/src/keyboard/sticky_key.rs index 473a572f4..7fb308295 100644 --- a/rmk/src/keyboard/sticky_key.rs +++ b/rmk/src/keyboard/sticky_key.rs @@ -55,7 +55,13 @@ impl StickyKeyState { } pub fn exit_on_layer_change(&self) -> bool { - matches!(self, StickyKeyState::Active { exit_on_layer_change: true, .. }) + matches!( + self, + StickyKeyState::Active { + exit_on_layer_change: true, + .. + } + ) } } From 3de61454e511b5f1d3116d4293f1d1601a09458c Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Sat, 30 May 2026 20:29:27 -0500 Subject: [PATCH 032/119] fix(ci): regenerate bulk endpoint snapshot under host features; apply nightly rustfmt - endpoint_keys_bulk.snap: the committed bytes were generated under a non-host BULK_SIZE; CI runs rmk-types with --features host, where BULK_SIZE = MAX_BULK_SIZE = 16, so the postcard schema hashes for the bulk_get RESP / bulk_set REQ endpoints differed. Regenerated with --features host to match CI. - Apply nightly rustfmt to sticky_key.rs, layout_macro.rs, action_parser.rs, and keyboard_sticky_key_test.rs (CI uses nightly). --- rmk-macro/src/codegen/action_parser.rs | 20 ++++++-- .../rmk/snapshots/endpoint_keys_bulk.snap | 12 ++--- rmk/src/keyboard/sticky_key.rs | 6 +-- rmk/src/layout_macro.rs | 22 ++++----- rmk/tests/keyboard_sticky_key_test.rs | 47 +++++-------------- 5 files changed, 44 insertions(+), 63 deletions(-) diff --git a/rmk-macro/src/codegen/action_parser.rs b/rmk-macro/src/codegen/action_parser.rs index 546f33cce..c01a9dbde 100644 --- a/rmk-macro/src/codegen/action_parser.rs +++ b/rmk-macro/src/codegen/action_parser.rs @@ -240,7 +240,10 @@ pub(crate) fn parse_key( ) }); - let key_str = internal[..bracket_start].trim().trim_end_matches(',').trim(); + let key_str = internal[..bracket_start] + .trim() + .trim_end_matches(',') + .trim(); let ident = get_key_with_alias(key_str.to_string()); let keep_mods_str = &internal[bracket_start + 1..bracket_end]; @@ -254,16 +257,23 @@ pub(crate) fn parse_key( let optional_args: Vec<&str> = if after_bracket.is_empty() { vec![] } else { - after_bracket.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect() + after_bracket + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .collect() }; - let max_repeat: u16 = optional_args.first() + let max_repeat: u16 = optional_args + .first() .and_then(|s| s.parse().ok()) .unwrap_or(0u16); - let timeout_ms: u16 = optional_args.get(1) + let timeout_ms: u16 = optional_args + .get(1) .and_then(|s| s.parse().ok()) .unwrap_or(0u16); - let exit_on_layer_change: bool = optional_args.get(2) + let exit_on_layer_change: bool = optional_args + .get(2) .map(|s| s.trim() == "true") .unwrap_or(false); diff --git a/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_bulk.snap b/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_bulk.snap index 89e623c1d..1f67e35c4 100644 --- a/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_bulk.snap +++ b/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_bulk.snap @@ -6,9 +6,9 @@ # UPDATE_SNAPSHOTS=1 cargo test -p rmk-types --features rmk_protocol # Format: REQ <8-byte hex> RESP <8-byte hex> -combo/bulk_get REQ 52 b1 93 5f 86 d5 96 53 RESP 15 1a 33 42 ab 3e e9 d7 -combo/bulk_set REQ 63 1a 39 ca 37 c0 75 6f RESP 83 3b 2e b1 a0 96 2f 3d -keymap/bulk_get REQ 11 21 e6 78 15 e5 8a ca RESP 47 7b f1 bd ad cd b6 e7 -keymap/bulk_set REQ 01 63 3c a6 88 c3 fa b9 RESP 42 98 cc 60 91 e5 c5 f3 -morse/bulk_get REQ 46 e8 ff eb aa ed 5f db RESP 6a 78 3f f6 e4 0b eb 9d -morse/bulk_set REQ cc ea c6 89 58 61 8b 16 RESP f7 57 bd 43 2b 0b ec b8 +combo/bulk_get REQ 52 b1 93 5f 86 d5 96 53 RESP 99 8a 7a 05 7b 22 4b f8 +combo/bulk_set REQ 7f 1a c0 79 72 e4 d5 c1 RESP 83 3b 2e b1 a0 96 2f 3d +keymap/bulk_get REQ 11 21 e6 78 15 e5 8a ca RESP 6f 52 99 0c e4 fe 9b 08 +keymap/bulk_set REQ c1 7c e9 3d 96 a2 0c 69 RESP 42 98 cc 60 91 e5 c5 f3 +morse/bulk_get REQ 46 e8 ff eb aa ed 5f db RESP 8e 17 e3 36 0b 09 8d a5 +morse/bulk_set REQ 98 1c 43 26 ee 0a de 23 RESP f7 57 bd 43 2b 0b ec b8 diff --git a/rmk/src/keyboard/sticky_key.rs b/rmk/src/keyboard/sticky_key.rs index 7fb308295..2abec6201 100644 --- a/rmk/src/keyboard/sticky_key.rs +++ b/rmk/src/keyboard/sticky_key.rs @@ -66,11 +66,7 @@ impl StickyKeyState { } impl Keyboard<'_> { - pub(crate) async fn process_action_sticky_key( - &mut self, - params: StickyKeyAction, - event: KeyboardEvent, - ) { + pub(crate) async fn process_action_sticky_key(&mut self, params: StickyKeyAction, event: KeyboardEvent) { if event.pressed { let timeout = if params.timeout_ms > 0 { Duration::from_millis(params.timeout_ms as u64) diff --git a/rmk/src/layout_macro.rs b/rmk/src/layout_macro.rs index 0c80bfd43..9dae12c46 100644 --- a/rmk/src/layout_macro.rs +++ b/rmk/src/layout_macro.rs @@ -366,19 +366,15 @@ macro_rules! osm { #[macro_export] macro_rules! sk { ($key:ident, $keep:expr, $max_repeat:expr, $timeout_ms:expr, $exit_on_layer_change:expr) => { - $crate::types::action::KeyAction::Single( - $crate::types::action::Action::StickyKey( - $crate::types::action::StickyKeyAction { - key: $crate::types::keycode::KeyCode::Hid( - $crate::types::keycode::HidKeyCode::$key, - ), - keep: $keep, - max_repeat: $max_repeat, - timeout_ms: $timeout_ms, - exit_on_layer_change: $exit_on_layer_change, - } - ) - ) + $crate::types::action::KeyAction::Single($crate::types::action::Action::StickyKey( + $crate::types::action::StickyKeyAction { + key: $crate::types::keycode::KeyCode::Hid($crate::types::keycode::HidKeyCode::$key), + keep: $keep, + max_repeat: $max_repeat, + timeout_ms: $timeout_ms, + exit_on_layer_change: $exit_on_layer_change, + }, + )) }; } diff --git a/rmk/tests/keyboard_sticky_key_test.rs b/rmk/tests/keyboard_sticky_key_test.rs index c5d073d74..857f2ac3a 100644 --- a/rmk/tests/keyboard_sticky_key_test.rs +++ b/rmk/tests/keyboard_sticky_key_test.rs @@ -26,7 +26,7 @@ const KEYMAP: [[[KeyAction; 6]; 1]; 2] = [ ]], [[ // Layer 1 - sk!(Tab, ModifierCombination::LALT, 0, 0, true), // col 0: SK(Tab, LAlt, exit=true) + sk!(Tab, ModifierCombination::LALT, 0, 0, true), // col 0: SK(Tab, LAlt, exit=true) sk!(Tab, ModifierCombination::LCTRL, 0, 0, true), // col 1: SK(Tab, LCtrl, exit=true) sk!( Tab, @@ -35,22 +35,15 @@ const KEYMAP: [[[KeyAction; 6]; 1]; 2] = [ 0, true ), // col 2: SK(Tab, LCtrl|LShift, exit=true) - a!(Transparent), // col 3: Transparent - a!(Transparent), // col 4: Transparent → LShift - a!(No), // col 5: No + a!(Transparent), // col 3: Transparent + a!(Transparent), // col 4: Transparent → LShift + a!(No), // col 5: No ]], ]; // KEYMAP_MAX_REPEAT: SK at col 0 has max_repeat=2 const KEYMAP_MAX_REPEAT: [[[KeyAction; 6]; 1]; 2] = [ - [[ - k!(A), - k!(B), - k!(C), - mo!(1), - k!(LShift), - a!(No), - ]], + [[k!(A), k!(B), k!(C), mo!(1), k!(LShift), a!(No)]], [[ sk!(Tab, ModifierCombination::LALT, 2, 0, false), // col 0: max_repeat=2 sk!(Tab, ModifierCombination::LCTRL, 0, 0, false), // col 1 @@ -63,18 +56,11 @@ const KEYMAP_MAX_REPEAT: [[[KeyAction; 6]; 1]; 2] = [ // KEYMAP_PER_KEY_TIMEOUT: SK at col 0 has 50ms per-key timeout const KEYMAP_PER_KEY_TIMEOUT: [[[KeyAction; 6]; 1]; 2] = [ + [[k!(A), k!(B), k!(C), mo!(1), k!(LShift), a!(No)]], [[ - k!(A), - k!(B), - k!(C), - mo!(1), - k!(LShift), - a!(No), - ]], - [[ - sk!(Tab, ModifierCombination::LALT, 0, 50, false), // col 0: 50ms per-key timeout - sk!(Tab, ModifierCombination::LCTRL, 0, 0, false), // col 1 - sk!(Tab, ModifierCombination::LCTRL, 0, 0, false), // col 2 + sk!(Tab, ModifierCombination::LALT, 0, 50, false), // col 0: 50ms per-key timeout + sk!(Tab, ModifierCombination::LCTRL, 0, 0, false), // col 1 + sk!(Tab, ModifierCombination::LCTRL, 0, 0, false), // col 2 a!(Transparent), a!(Transparent), a!(No), @@ -83,18 +69,11 @@ const KEYMAP_PER_KEY_TIMEOUT: [[[KeyAction; 6]; 1]; 2] = [ // KEYMAP_NO_EXIT: SK with exit_on_layer_change=false — SK survives MO release const KEYMAP_NO_EXIT: [[[KeyAction; 6]; 1]; 2] = [ + [[k!(A), k!(B), k!(C), mo!(1), k!(LShift), a!(No)]], [[ - k!(A), - k!(B), - k!(C), - mo!(1), - k!(LShift), - a!(No), - ]], - [[ - sk!(Tab, ModifierCombination::LALT, 0, 0, false), // col 0: exit=false - sk!(Tab, ModifierCombination::LCTRL, 0, 0, false), // col 1 - sk!(Tab, ModifierCombination::LCTRL, 0, 0, false), // col 2 + sk!(Tab, ModifierCombination::LALT, 0, 0, false), // col 0: exit=false + sk!(Tab, ModifierCombination::LCTRL, 0, 0, false), // col 1 + sk!(Tab, ModifierCombination::LCTRL, 0, 0, false), // col 2 a!(Transparent), a!(Transparent), a!(No), From 1af5cde29f2e74c7dde06c27c5dfb08862c3d8f1 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:03:42 -0500 Subject: [PATCH 033/119] docs: design spec for unifying OSM and StickyKey runtime Unify the runtime of one-shot modifiers (OSM) and sticky keys (SK) behind one shared StickyLatch state machine driven by a per-behavior preset, while freezing the public surface (wire format, keycodes, config blocks, TOML syntax, tests) for strict backward compatibility. OSL deferred with a documented seam. Staged plumbing-first then state-merge, each gated on the 36 existing OSM/OSL+SK tests; fixes the OSM select race as a side effect. --- .../2026-06-02-unify-osm-sticky-key-design.md | 312 ++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-02-unify-osm-sticky-key-design.md diff --git a/docs/superpowers/specs/2026-06-02-unify-osm-sticky-key-design.md b/docs/superpowers/specs/2026-06-02-unify-osm-sticky-key-design.md new file mode 100644 index 000000000..c39509ccb --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-unify-osm-sticky-key-design.md @@ -0,0 +1,312 @@ +# Unify One-Shot Modifier (OSM) and Sticky Key (SK) — Design + +**Date:** 2026-06-02 +**Status:** Designed (not yet implemented) +**Branch:** `feat/osm-sticky-key-merge` (forked from `feat/sticky-mod`) +**Repo:** rmk-fork (RMK firmware), consumed by RMKSofleV2 via `[patch.crates-io]` +**Context:** RMK PR [#859](https://github.com/HaoboGu/rmk/pull/859) + +## Overview + +The `feat/sticky-mod` branch added a `StickyKey` (SK) behavior and HaoboGu (RMK +owner) asked, in PR #859, that one-shot modifiers and sticky-mod be **unified** +into a single behavior. As implemented, they are **not** unified — OSM and SK +share essentially no runtime logic. They have separate state types, separate +timeout mechanisms, and separate release-trigger plumbing. The only shared code +is the final modifier-merge sink (`resolve_explicit_modifiers`). + +This design unifies the **runtime** of OSM and SK behind one shared latch state +machine, driven by a small per-behavior **preset**, while keeping the public +surface (wire format, keycodes, config blocks, TOML syntax, tests) frozen for +strict backward compatibility. One-shot **layer** (OSL) is deliberately left on +its own layer-activation path this round, but rides the shared plumbing and is +left with a documented seam to fold in later. + +**Goals:** + +1. **Backward compatible** — no change to the postcard wire format, Via/Vial + keycodes, config blocks, or TOML keymap syntax. +2. **Keep every feature** of both the current OSM implementation *and* SK — + nothing dropped. The existing test suites are the parity oracle. +3. **Cut shared code** — one state type, one timeout mechanism, one + foreign-key release pass, one modifier-resolve sink. +4. **Fix the OSM "select race"** as a natural consequence of moving OSM onto + SK's non-blocking deadline mechanism. + +**Non-goal:** any new user-facing feature. This is internal consolidation plus +the race fix; observable behavior is identical except that the OSM timeout race +goes away. + +--- + +## Current state (why they are not unified) + +Three plumbing layers diverge today. + +### State representation + +- **OSM/OSL** — `OneShotState` (`rmk/src/keyboard/oneshot.rs:10`), a 4-state + machine `Initial(T)` / `Single(T)` / `Held(T)` / `None`, generic over + `T = ModifierCombination` (OSM) or `T = u8` (OSL). +- **SK** — `StickyKeyState` (`rmk/src/keyboard/sticky_key.rs:24`), a 2-state + machine `None` / `Active { mods, repeat_count, max_repeat, exit_on_layer_change, + deadline }`. + +### Timeout mechanism (the core divergence) + +- **SK** is **non-blocking**: it stores `deadline: Option` in its state, + and the `run()` loop (`rmk/src/keyboard.rs:158-185`) races the event subscriber + against `sk_deadline`. Expiry is handled in the main loop. +- **OSM/OSL** are **blocking**: on release they call + `select(Timer::after(timeout), subscriber.next_message())` inline + (`oneshot.rs:75-93` for OSM, `139-152` for OSL). If a real key event wins the + race, it is pushed onto `self.unprocessed_events` to be replayed. This inline + `select` is the source of the documented "select race" (RMKSofleV2 `TODO.md`) + and the `keyboard.rs:146` TODO wondering whether `unprocessed_events` "can be + removed in the future." + +### Release-trigger plumbing + +- **OSM** consume runs in `process_action_key` (`keyboard.rs:1586`): + `update_osm(event)` flips `Single → None`, honoring `quick_release` (consume on + next *press* vs. next *release*) and promoting `Initial → Held` (the held / + mouse-click path). +- **SK** release runs in `process_key_action_normal` (`keyboard.rs:1220-1230`): + any non-SK, non-modifier key tears the latch down. + +### The one shared sink + +`resolve_explicit_modifiers` (`keyboard.rs:1380-1403`) already merges +`held_modifiers + osm_state.value() + sticky_key_state.value()`. This is the +only place the two behaviors meet today. + +### Why HaoboGu's sketch is insufficient + +PR #859 sketches `OSM(mod) == SK(mod, [], 1)`. That is a *simplification* that +drops OSM's richer behaviors (modifier accumulation, held-promotion, double-press +un-latch, `quick_release`, `activate_on_keypress`). The unified mechanism must +therefore be a **superset** with OSM and SK as two **presets**, not a collapse of +one into the other. + +--- + +## Section 1 — Scope & compatibility contract + +**Frozen surface (nothing here moves — this is what guarantees backward compat):** + +- `Action::OneShotModifier`, `Action::OneShotLayer`, `Action::StickyKey` stay as + distinct variants in the **same wire order** (`rmk-types/src/action/mod.rs:57`). + The `Action` enum derives `Serialize`/`Deserialize`/`MaxSize` and postcard + encodes by variant order, so stored keymaps and Vial stay valid. +- Via/Vial keycode mappings for OSM/OSL + (`rmk/src/host/via/keycode_convert.rs`) unchanged. (`StickyKey` is absent from + that map today — confirming it is *not* on the keycode wire — and stays absent.) +- **Two separate config blocks stay:** `[behavior.one_shot]` + + `one_shot_modifiers` (`activate_on_keypress`, `quick_release`) and + `[behavior.sticky_key]` (`timeout`). Independent timeouts are a feature, and + keeping both is also what compat requires. +- TOML keymap syntax `OSM(...)` / `OSL(...)` / `SK(...)` unchanged. +- **Parity oracle:** all existing OSM/OSL tests + (`rmk/tests/keyboard_one_shot_test.rs`, 25 tests) and SK tests + (`rmk/tests/keyboard_sticky_key_test.rs`, 11 tests) stay green, **unmodified**. + They *are* the definition of "all features preserved." + +**In scope:** merge the *runtime* of OSM and SK into one shared mechanism. +**Out of scope this round:** OSL keeps its own layer activate/deactivate code path +(documented seam only). + +--- + +## Section 2 — The unified state + preset model + +Replace `OneShotState` and `StickyKeyState` with **one** internal latch type +that is the union of both: + +```rust +enum StickyLatch { + None, + Engaged { + mods: ModifierCombination, // OSM accumulates; SK = `keep` + key: Option, // SK bundled key; None for OSM + phase: Phase, // Pressed | Latched | Held (≈ OSM Initial/Single/Held) + repeat_count: u16, // SK cycling; OSM stays 1 + preset: Preset, // which feature-set is active + deadline: Option, // unified timeout + }, +} +``` + +`Preset` is the small config that selects *which* behaviors are live, so OSM and +SK become two configurations of one machine: + +| Preset field | OSM value | SK value | +|---|---|---| +| `accumulate` (combine repeated presses) | yes | no | +| `held_promotion` (foreign key while held → normal modifier) | yes | no | +| `double_press_consume` (re-press same mod un-latches) | yes | no | +| `quick_release` / `activate_on_keypress` | from config | n/a | +| `bundles_key` | no | yes | +| `max_repeat` (0 = infinite) | 1 | from action | +| `keep_set` (which keys re-arm vs. release) | empty | `keep` mods | +| `exit_on_layer_change` | no | from action | +| `timeout_source` | `[behavior.one_shot]` | per-key or `[behavior.sticky_key]` | + +The `Action::OneShotModifier` and `Action::StickyKey` dispatch arms +(`keyboard.rs:1321-1328`) become **thin adapters** that build the right `Preset` +and hand off to the shared engine. + +**Readability guardrail:** the engine keeps OSM's and SK's transition logic as +preset-aware paths over this one state. We are explicitly **not** forcing a single +mega-`match` if it hurts readability. The win is one *state type* + one plumbing +layer + one set of release/timeout rules — not necessarily one giant function. + +--- + +## Section 3 — Shared plumbing (and what gets deleted) + +### 3a. Timeout — one mechanism (deadline), delete the inline `select` + +The unified latch carries `deadline: Option` (already in the Section 2 +shape). The `run()` loop's existing deadline race (`keyboard.rs:158-185`) handles +expiry for **both** OSM and SK. When the deadline fires, the engine runs the same +consume/release path SK uses today. + +**Deleted:** the `select(timeout, next_message)` blocks in `process_action_osm` +(`oneshot.rs:75-93`) and `process_action_osl` (`oneshot.rs:139-152`); and — if +nothing else still pushes to it — the `unprocessed_events` re-queue path. + +**Audit gate:** before removing `unprocessed_events`, confirm OSM/OSL are its +only producers (grep). If something else uses it, it stays and only the OSM/OSL +producers are removed. + +This is the part that **fixes the select race** rather than carrying it forward, +and it is what the `keyboard.rs:146` TODO is asking for. + +### 3b. Release-on-foreign-key — one pass + +OSM's `update_osm` consume (`keyboard.rs:1586`) and SK's non-SK-key release +(`keyboard.rs:1220-1230`) merge into **one "foreign key arrived" hook** over the +unified latch, parameterized by the preset: + +- OSM preset: "consume per `quick_release`; promote `Initial → Held` first." +- SK preset: "release unless the key is in `keep_set` or is another SK press that + cycles." + +Same call site; the preset picks the rule. + +### 3c. Layer-change release + the resolve sink + +- Layer-change release is SK-only today (`exit_on_layer_change`, fired from + `process_action_layer_switch:1600` and four spots in + `process_key_action_normal`). It stays, now reading the unified latch's preset + flag. OSM's preset leaves it off → no behavior change for OSM. +- `resolve_explicit_modifiers` (`keyboard.rs:1380-1403`) already merges held + + OSM + SK modifiers. After unification it reads one `latch.value()` instead of + two. Pure simplification. + +### Net deletion target + +- `OneShotState` **and** `StickyKeyState` both go away → replaced by the one + `StickyLatch`. +- The two inline-`select` timeout blocks go away. +- `unprocessed_events` re-queue likely goes away (pending the 3a audit). +- OSL keeps its own layer activate/deactivate calls (documented seam) but rides + the same latch state and the same deadline / foreign-key plumbing. + +--- + +## Section 4 — Staging & test strategy + +Every commit stays test-green against the frozen 25 OSM/OSL + 11 SK tests. The +merge proceeds in dependency order — plumbing first, state second — so any +regression is bisectable to one stage. Tests run via `cargo nextest`. + +- **Stage 0 — Characterize.** Run the full OSM/OSL/SK suite; record the green + baseline. No code change. +- **Stage 1 — Unify the timeout plumbing, keep both state types.** Move OSM/OSL + off inline `select` onto a `deadline` surfaced to the `run()` loop (reusing SK's + deadline race). `OneShotState` and `StickyKeyState` still exist separately — + only the *expiry mechanism* is shared. Delete the `select` blocks; remove the + OSM/OSL `unprocessed_events` producers (pending 3a audit). **Gate: all 36 tests + green.** This isolates the single riskiest change (the timeout-semantics shift) + to one commit — `git bisect` lands here if a hardware surprise appears. +- **Stage 2 — Merge the state representation.** Replace `OneShotState` + + `StickyKeyState` with `StickyLatch` + `Preset`. The `OneShotModifier` / + `StickyKey` dispatch arms become thin preset-building adapters. Fold the + foreign-key hook (3b) and resolve sink (3c) onto the single latch. **Gate: all + 36 tests green.** +- **Stage 3 — Tidy + document the OSL seam.** OSL still does its own layer + activate/deactivate but now rides the shared latch + plumbing. Leave a clearly + commented seam (a `// OSL fold point:` marker + short note) describing what a + future "fold OSL fully in" change would collapse. **Gate: all 36 tests green + + `cargo clippy` clean.** + +**New tests:** none for unchanged behavior — the existing suite already defines +parity. Add a test only if the Stage 1 race fix creates a newly-correct behavior +the old suite did not pin (e.g. a key event arriving in the exact timeout window). +If found, that is one targeted regression test, not a suite. + +--- + +## Section 5 — Risks & non-goals + +**Risks (ranked):** + +1. **OSM timeout semantics shift (Stage 1).** Moving from blocking inline + `select` to the run-loop deadline changes *when* expiry is observed relative + to an incoming event. Mitigated by: isolated to one commit, the 25 OSM/OSL tests, + and a possible targeted race test. This is the one to watch on real hardware. +2. **`unprocessed_events` removal.** Only safe if OSM/OSL are its sole producers. + Mitigated by an explicit grep/audit before deletion; if shared, it stays and + only the OSM/OSL pushes are removed. +3. **Preset adapter drift.** Risk that an OSM behavioral axis (double-press + toggle, `activate_on_keypress`, held-promotion, accumulation, `quick_release`) + is dropped when re-expressed as a preset. Mitigated by the frozen test suite — + each axis has a named test. + +**Non-goals (explicitly out of scope this round):** + +- Folding OSL fully into the latch (keeps its own layer calls — documented seam + only). This is the eventual "everything folds into sticky-key" direction + HaoboGu wants, deferred to a follow-up. +- Touching the wire format, Via/Vial keycodes, or the two config blocks (frozen + per Section 1). +- `OneShotKey` (OSK) — still unsupported, stays a warning (`keyboard.rs:1329`). +- Any new user-facing feature. + +--- + +## File map (anticipated) + +**Modify:** + +- `rmk/src/keyboard/oneshot.rs` — remove inline `select` timeout; OSM/OSL onto + deadline (Stage 1); replaced by `StickyLatch` usage (Stage 2). +- `rmk/src/keyboard/sticky_key.rs` — `StickyKeyState` replaced by `StickyLatch` + (Stage 2); SK becomes a preset adapter. +- `rmk/src/keyboard.rs` — state fields (`osl_state`, `osm_state`, + `sticky_key_state` → unified latch), deadline race, dispatch arms + (`1321-1328`), foreign-key hook (`1220-1230`, `1586`), layer-change release + (`1600` + four spots), `resolve_explicit_modifiers` (`1380-1403`); remove + `unprocessed_events` producers (pending audit). +- Likely a new shared module (e.g. `rmk/src/keyboard/sticky_latch.rs`) housing + `StickyLatch` + `Preset`, depending on how Stage 2 shakes out. + +**Frozen (do not touch):** + +- `rmk-types/src/action/mod.rs` — `Action` variants + wire order. +- `rmk/src/host/via/keycode_convert.rs` — OSM/OSL keycodes. +- `rmk/src/config/behavior.rs` — `OneShotModifiersConfig` + `StickyKeyConfig` + (both blocks stay). +- `rmk/tests/keyboard_one_shot_test.rs`, `rmk/tests/keyboard_sticky_key_test.rs` + — the parity oracle. + +--- + +## Open questions for the implementation plan + +- Exact home of `StickyLatch` + `Preset` (new module vs. folded into + `sticky_key.rs`) — decide during Stage 2. +- Whether the foreign-key hook (3b) is best expressed as one function with a + preset branch, or two small functions sharing the latch — decide by which reads + cleaner once the state is merged. From 10c34d77298098046ce9bfcaa29b7b460343e442 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Wed, 3 Jun 2026 10:51:04 -0500 Subject: [PATCH 034/119] docs: add SK global-default + per-key profile override to design spec --- .../2026-06-02-unify-osm-sticky-key-design.md | 163 ++++++++++++++++-- 1 file changed, 150 insertions(+), 13 deletions(-) diff --git a/docs/superpowers/specs/2026-06-02-unify-osm-sticky-key-design.md b/docs/superpowers/specs/2026-06-02-unify-osm-sticky-key-design.md index c39509ccb..792a1323a 100644 --- a/docs/superpowers/specs/2026-06-02-unify-osm-sticky-key-design.md +++ b/docs/superpowers/specs/2026-06-02-unify-osm-sticky-key-design.md @@ -33,9 +33,13 @@ left with a documented seam to fold in later. 4. **Fix the OSM "select race"** as a natural consequence of moving OSM onto SK's non-blocking deadline mechanism. -**Non-goal:** any new user-facing feature. This is internal consolidation plus -the race fix; observable behavior is identical except that the OSM timeout race -goes away. +**Mostly internal.** The unification itself adds no user-facing feature — +OSM/OSL observable behavior is identical except that the OSM timeout race goes +away. The one intentional new capability is **SK-only**: a global +`[behavior.sticky_key]` default plus an optional per-key **profile** that can +override any SK setting field-by-field (Section 5). It is purely additive — +existing SK configs, the SK keymap syntax, the `StickyKeyAction` wire struct, +and the 11 SK tests are all untouched. --- @@ -104,7 +108,9 @@ one into the other. - **Two separate config blocks stay:** `[behavior.one_shot]` + `one_shot_modifiers` (`activate_on_keypress`, `quick_release`) and `[behavior.sticky_key]` (`timeout`). Independent timeouts are a feature, and - keeping both is also what compat requires. + keeping both is also what compat requires. (`[behavior.sticky_key]` *grows* + additively — optional default fields plus a `profiles` subtable, per + Section 5; a config that sets only `timeout` is unaffected.) - TOML keymap syntax `OSM(...)` / `OSL(...)` / `SK(...)` unchanged. - **Parity oracle:** all existing OSM/OSL tests (`rmk/tests/keyboard_one_shot_test.rs`, 25 tests) and SK tests @@ -240,15 +246,124 @@ regression is bisectable to one stage. Tests run via `cargo nextest`. commented seam (a `// OSL fold point:` marker + short note) describing what a future "fold OSL fully in" change would collapse. **Gate: all 36 tests green + `cargo clippy` clean.** +- **Stage S — SK profile override (independent; Section 5).** Config-resolve + + codegen only; orthogonal to the runtime-merge Stages 1–3, so it can land before + or after them. Adds the new profile resolution tests (below) and keeps the 36 + existing tests green. **Gate: 36 existing tests green + new resolution tests + green.** + +**New tests:** for the *merge* (Stages 1–3), none for unchanged behavior — the +existing suite already defines parity; add one targeted regression test only if +the Stage 1 race fix creates a newly-correct behavior the old suite did not pin +(e.g. a key event arriving in the exact timeout window). For the *SK profile +override* (Stage S), add the small resolution-tier test set described in +Section 5. -**New tests:** none for unchanged behavior — the existing suite already defines -parity. Add a test only if the Stage 1 race fix creates a newly-correct behavior -the old suite did not pin (e.g. a key event arriving in the exact timeout window). -If found, that is one targeted regression test, not a suite. +--- + +## Section 5 — SK config: global default + per-key profile override (new SK capability) + +This is the one intentional new feature in this work. It applies to **SK only** — +OSM/OSL are frozen (Section 1) and keep their global-only config. It is an +**independent workstream**: it touches the config-resolve + codegen layers, not +the runtime engine, so it can land before or after the Section 4 merge stages, +each step staying test-green. + +### Motivation + +SK's current keymap form `SK(key, [mods], max_repeat, timeout_ms, +exit_on_layer_change)` is the only 5-positional-argument action in RMK; the +trailing `0, 0, true` is unreadable and the `0` sentinels are easy to mis-order. +We want a global default that every SK key inherits, with any individual key able +to override any field — **without** inventing inline named-parameter syntax (no +RMK action uses `key=value` inside the action string; named params live only in +`[behavior.*]` TOML tables). + +### The three RMK override patterns (and which we pick) + +RMK already solves "global default, override per key" two ways, and uses a third +(global-only) for OSM: + +1. **Global-only** (OSM): one `[behavior.one_shot]` block, no per-key override. + *Rejected* — too rigid for the stated need. +2. **Sentinel fallback** (SK `timeout_ms = 0` today → inherit global). Works for a + numeric field with a spare sentinel, but can't express "inherit" for a `bool`, + and `max_repeat = 0` already means "infinite" so `0` is taken there. +3. **Option-field merge** (morse / tap-hold profiles): a per-key named profile + whose `Option` fields override the global default *field by field*; unset + fields inherit. Most general, reads naturally in TOML, and is a pattern RMK + users and maintainers already recognize. + +**Chosen: pattern 3 — Option-field merge, mirroring morse profiles.** + +### TOML surface (additive) + +`[behavior.sticky_key]` gains the full set of SK defaults; a new +`[behavior.sticky_key.profiles.]` subtable defines named overrides: + +```toml +[behavior.sticky_key] # global default for every SK key +timeout = "5s" +max_repeat = 0 # 0 = infinite +exit_on_layer_change = false + +[behavior.sticky_key.profiles.tabber] # overrides only what it names +max_repeat = 3 +exit_on_layer_change = true # timeout inherited from the global default +``` + +Purely additive: an existing config that sets only `timeout` keeps working; the +new default fields and the `profiles` table are optional. + +### Keymap DSL (reuses MT/LT/TH's optional profile slot) + +The bare form is unchanged; an optional trailing **profile name** stands in for +the positional numeric tail: + +```toml +SK(Tab, [LAlt]) # every setting from the global default +SK(Tab, [LAlt], tabber) # override per profile "tabber", inherit the rest +``` + +This reuses the same optional 3rd positional slot that `MT` / `LT` / `TH` already +use for their morse profile, so it introduces no new DSL shape. The parser +distinguishes the slot by token kind: a **numeric** token keeps the legacy +positional `SK(key,[mods],max_repeat,timeout_ms,exit)` parse (so the 11 existing +SK tests stay green, unmodified); an **identifier** token is resolved as a +profile name. + +### Resolution — codegen-time merge, no wire change + +Because both the global defaults and the named profiles are compile-time TOML, +the merge happens entirely at **codegen** — exactly as morse's `expand_profile` +bakes resolved values. For each field the resolution order is: + +> explicit per-key value (positional arg, if present) → +> named-profile field (if `Some`) → +> `[behavior.sticky_key]` global default (if set) → +> built-in default (`timeout` sentinel `0`, `max_repeat` `0`, `exit` `false`). + +The codegen folds this down to concrete values and emits the existing +`sk!(key, mods, max_repeat, timeout_ms, exit)` macro. Therefore: + +- **`StickyKeyAction` is unchanged** — still all-concrete `{ key, keep, + max_repeat, timeout_ms, exit_on_layer_change }`. No `Option` fields reach the + wire; `MaxSize` and the postcard encoding are untouched. +- **The SK runtime engine is unchanged** by this feature — it still receives one + fully-resolved action. The profile indirection is a zero-runtime-cost + compile-time convenience. + +### Tests + +The legacy positional form keeps its 11 tests unmodified (parity oracle). Add a +small set of **new** codegen/resolution tests for the profile form: bare key +inherits all global defaults; a profile overrides only its named fields and +inherits the rest; a missing global default falls to the built-in default; +numeric-vs-identifier slot disambiguation. --- -## Section 5 — Risks & non-goals +## Section 6 — Risks & non-goals **Risks (ranked):** @@ -263,14 +378,24 @@ If found, that is one targeted regression test, not a suite. toggle, `activate_on_keypress`, held-promotion, accumulation, `quick_release`) is dropped when re-expressed as a preset. Mitigated by the frozen test suite — each axis has a named test. +4. **SK profile-merge resolution (Section 5).** Risk that the codegen merge + resolves a field from the wrong tier (per-key vs. profile vs. global vs. + built-in), or that numeric-vs-identifier slot disambiguation misreads a token. + Mitigated by: the merge is pure compile-time logic with no runtime state, the + legacy positional path is left intact (existing tests pin it), and the new + resolution tests cover each tier and the slot-kind split. Low blast radius — a + bad resolve produces a wrong baked constant caught at build/test time, not a + runtime hazard. **Non-goals (explicitly out of scope this round):** - Folding OSL fully into the latch (keeps its own layer calls — documented seam only). This is the eventual "everything folds into sticky-key" direction HaoboGu wants, deferred to a follow-up. -- Touching the wire format, Via/Vial keycodes, or the two config blocks (frozen - per Section 1). +- Touching the wire format, Via/Vial keycodes, or the OSM/OSL config blocks + (frozen per Section 1). The SK config block grows additively only (Section 5). +- Extending the per-key profile override to OSM/OSL. OSM stays global-only; the + new profile mechanism is SK-only this round. - `OneShotKey` (OSK) — still unsupported, stays a warning (`keyboard.rs:1329`). - Any new user-facing feature. @@ -292,14 +417,26 @@ If found, that is one targeted regression test, not a suite. - Likely a new shared module (e.g. `rmk/src/keyboard/sticky_latch.rs`) housing `StickyLatch` + `Preset`, depending on how Stage 2 shakes out. +**Modify (Section 5 — SK profile override, independent of the merge stages):** + +- `rmk-config/src/resolved/behavior.rs` — extend the `[behavior.sticky_key]` + resolve to read the new default fields (`max_repeat`, `exit_on_layer_change`) + and a `profiles` map; plus the corresponding raw-TOML config structs. +- `rmk-macro/src/codegen/action_parser.rs` — SK parse path: numeric-vs-identifier + slot disambiguation, profile lookup, and the codegen-time tier merge that bakes + concrete values into the existing `sk!(...)` emission. +- New resolution tests for the profile form (alongside the existing SK tests). + **Frozen (do not touch):** -- `rmk-types/src/action/mod.rs` — `Action` variants + wire order. +- `rmk-types/src/action/mod.rs` — `Action` variants + wire order, **and the + `StickyKeyAction` struct** (Section 5 bakes resolved values into the existing + fields at codegen, so the wire struct stays all-concrete and unchanged). - `rmk/src/host/via/keycode_convert.rs` — OSM/OSL keycodes. - `rmk/src/config/behavior.rs` — `OneShotModifiersConfig` + `StickyKeyConfig` (both blocks stay). - `rmk/tests/keyboard_one_shot_test.rs`, `rmk/tests/keyboard_sticky_key_test.rs` - — the parity oracle. + — the parity oracle (the legacy positional SK form keeps these green unmodified). --- From 16246621303ee3de18bb0f51dea68781336e5f58 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Wed, 3 Jun 2026 11:17:35 -0500 Subject: [PATCH 035/119] docs: add profile-first configuration requirement to SK config section --- .../2026-06-02-unify-osm-sticky-key-design.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/superpowers/specs/2026-06-02-unify-osm-sticky-key-design.md b/docs/superpowers/specs/2026-06-02-unify-osm-sticky-key-design.md index 792a1323a..ffaa6633d 100644 --- a/docs/superpowers/specs/2026-06-02-unify-osm-sticky-key-design.md +++ b/docs/superpowers/specs/2026-06-02-unify-osm-sticky-key-design.md @@ -269,6 +269,20 @@ OSM/OSL are frozen (Section 1) and keep their global-only config. It is an the runtime engine, so it can land before or after the Section 4 merge stages, each step staying test-green. +### Requirement: profile-first configuration + +The TOML profile (`[behavior.sticky_key]` plus its named `profiles`) is the +**preferred and primary** way to specify SK settings. Per-key overrides in the +`SK(...)` action string are a **secondary convenience, retained for now but +explicitly optional** — they may be removed in a later pass to simplify the code. + +Consequence for the design: nothing may architecturally depend on key-level +overrides existing. Because every setting is resolved to a concrete value at +codegen (profile/global folded in), the runtime engine reads only fully-resolved +values and is blind to *where* a value came from. Dropping the per-key override +syntax later must therefore be a clean deletion of parser/codegen arms — no +runtime change, no engine coupling. + ### Motivation SK's current keymap form `SK(key, [mods], max_repeat, timeout_ms, From b076f2afe012fd5b75cacef47d84faa4b93069cd Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:07:43 -0500 Subject: [PATCH 036/119] docs: add spec for SK absorbing one-shot (OSM+OSL), full replacement --- .../2026-06-03-sk-absorbs-oneshot-design.md | 418 ++++++++++++++++++ 1 file changed, 418 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-03-sk-absorbs-oneshot-design.md diff --git a/docs/superpowers/specs/2026-06-03-sk-absorbs-oneshot-design.md b/docs/superpowers/specs/2026-06-03-sk-absorbs-oneshot-design.md new file mode 100644 index 000000000..ef239a47d --- /dev/null +++ b/docs/superpowers/specs/2026-06-03-sk-absorbs-oneshot-design.md @@ -0,0 +1,418 @@ +# Sticky Key Absorbs One-Shot (OSM + OSL) — Design + +**Date:** 2026-06-03 +**Status:** Designed (not yet implemented) +**Branch:** `feat/osm-sticky-key-merge` +**Repo:** rmk-fork (RMK firmware), consumed by RMKSofleV2 via `[patch.crates-io]` +**Context:** RMK PR [#859](https://github.com/HaoboGu/rmk/pull/859) +**Supersedes:** `2026-06-02-unify-osm-sticky-key-design.md` (kept as the historical +record of the strict-backward-compat approach). + +## Overview + +The 06-02 design unified the OSM and SK **runtime** while freezing every public +surface for strict backward compatibility, and deliberately left one-shot +**layer** (OSL) on its own path. That strict-compat work has since been merged +into the branch/PR. + +This design takes the next step HaoboGu approved in PR #859: **completely replace +one-shot with sticky key.** Sticky Key becomes the single behavior; OSM and OSL +are absorbed into it and cease to exist as independent behaviors. The user-facing +`OSM(...)` / `OSL(...)` keymap forms are replaced by `SK(...)` forms, the three +one-shot/sticky config tables collapse into one, and the runtime is a single +engine whose behavior is selected by the **shape of the SK action**, not by which +legacy syntax was used. + +This is intentionally **not** backward compatible — it changes user-facing syntax, +config table names, and default values. Those breaks are accepted: the goal is one +coherent feature set with the maximum code reuse, not preservation of the old +surface. + +**Goals:** + +1. **One behavior.** A single SK engine and a single `Action::StickyKey` path + absorb OSM (one-shot modifier) and OSL (one-shot layer). Reuse as much of the + existing OSM and SK code as possible. +2. **One config table.** Collapse `[behavior.one_shot]`, + `[behavior.one_shot_modifiers]`, and `[behavior.sticky_key]` into a single + `[behavior.sticky_key]`. +3. **Keep every capability** of today's OSM, OSL, and SK — modifier accumulation, + held-promotion, `quick_release`, `activate_on_keypress`, alt-tab cycling + (`max_repeat`), layer-change release, one-shot layers. Nothing is dropped; the + features are reorganized, not removed. +4. **Zero cost when unused.** Because there are no per-key overrides and no named + profiles in this round (deferred — see Section 4), every SK setting resolves to + a concrete value at codegen and the runtime engine reads only fully-resolved + values. RAM/flash reflect the simple single-profile model. + +**The central idea — behavior is selected by action shape.** A single SK action +takes one of three shapes, and the shape alone decides which behaviors are live: + +| Shape | Example | Old equivalent | Engine behavior | +|---|---|---|---| +| **Pure modifier** (`key == No`) | `SK(LGui)` | `OSM(LGui)` | One-shot modifier: honors `activate_on_keypress`/`quick_release`, applies the held mod **to** the terminating key, accumulates across taps. | +| **Tap-key + mods** (`key != No`) | `SK(Tab, [LAlt])` | sticky-mod / alt-tab | Holds mods, taps the key, cycles up to `max_repeat`, releases on a foreign key **without** applying the mod to it. Ignores `activate_on_keypress`/`quick_release`. | +| **Layer** (`SK(MO(n))`) | `SK(MO(1))` | `OSL(1)` | One-shot layer: activates the layer for the next key, then reverts. Reuses OSL's layer activate/deactivate logic. | + +This shape-based dispatch is what lets one engine serve all three without +per-key config flags for the OSM↔alt-tab differences (Section 3). + +--- + +## Section 1 — Syntax migration (user-facing) + +The `SK(...)` action becomes the single entry point. Migration: + +```txt +old: OSM(LGui) new: SK(LGui) +old: OSL(1) new: SK(MO(1)) +old: SK(Tab, [LAlt], 0, 0, true) new: SK(Tab, [LAlt]) +old: SK(Tab, [LCtrl]) new: SK(Tab, [LCtrl]) # unchanged +``` + +**Mental model:** *SK makes the wrapped thing one-shot / sticky.* +`SK(LGui)` = sticky modifier; `SK(MO(1))` = sticky momentary-layer (= one-shot +layer); `SK(Tab, [LAlt])` = sticky-mod (alt-tab). + +**The trailing positional tail is gone.** The legacy +`SK(key, [mods], max_repeat, timeout_ms, exit_on_layer_change)` form — the only +5-positional-arg action in RMK, with its unreadable `0, 0, true` tail — is +**removed**. `max_repeat`, `timeout`, and layer-release now come from the +`[behavior.sticky_key]` table (Section 4). The bare forms above are the entire +surface. + +**`OSM(...)` / `OSL(...)` keymap forms — lowered to `SK(...)` at codegen, then +removed.** To ease migration of existing keymaps, the parser may retain `OSM(m)` +and `OSL(n)` as **deprecated aliases** that lower to `SK(m)` and `SK(MO(n))` +respectively at codegen (zero runtime cost — they produce the identical +`Action::StickyKey`). This is a confirm-or-drop decision (see Open Questions); the +default position is to lower-and-deprecate now, delete later, matching the +"keep for now, simplify once consolidation is proven" approach used for the config. + +**OSL payload requires the SK action to carry a layer.** `SK(MO(n))` cannot be +expressed by today's `StickyKeyAction { key, keep, max_repeat, timeout_ms, +exit_on_layer_change }`. The action gains a layer-carrying shape (Section 5). This +is the accepted wire-format break that absorbing OSL requires. + +--- + +## Section 2 — Config consolidation + +The three tables collapse into one. **Old:** + +```toml +[behavior.one_shot] +timeout = "1s" # shared by OSM + OSL + +[behavior.one_shot_modifiers] +activate_on_keypress = false +quick_release = false + +[behavior.sticky_key] +timeout = "5s" # default was: no timeout (Duration::MAX) +max_repeat = 0 # 0 = infinite +exit_on_layer_change = false +``` + +**New — a single `[behavior.sticky_key]`:** + +```toml +[behavior.sticky_key] +timeout = "1s" # default 1s; applies to every SK shape +activate_on_keypress = false # honored by pure-mod SK only (Section 3) +quick_release = false # honored by pure-mod SK only (Section 3) +max_repeat = 0 # 0 = infinite; governs tap-key cycling +release_on_layer_change = false # renamed from exit_on_layer_change +``` + +**Decisions baked in (all confirmed):** + +- **One shared `timeout`** for all shapes, default **1s**. This intentionally + changes two prior behaviors, both accepted: + - alt-tab SKs previously had *no* timeout; they now auto-release after 1s of no + tap. + - the timeout *action* is uniform — on expiry the latch simply releases. + A future per-key override / named profile is the planned way to give alt-tab a + longer timeout without lengthening OSM's window; it is **deferred** this round + (Section 4). +- **`exit_on_layer_change` → `release_on_layer_change`** (same polarity: `true` = + layer change releases the SK; `false` = SK survives layer changes). Default + `false` (survives), matching the preferred behavior for one-shot mods. +- **`max_repeat` is harmless to pure-mod SK.** A pure-mod SK has no tap-key to + re-press, so cycling never triggers; its termination is the foreign-key path. + The shared default `0` is therefore safe for OSM-shaped keys. + +**Why a single table is correct here.** Of the prior cross-table conflicts, only +`timeout` is a genuine single-default compromise, and it is accepted. The two +transmission fields (`activate_on_keypress`, `quick_release`) are resolved +*structurally* by action shape, not by a config value (Section 3), so they need no +per-key distinction. `max_repeat` and `release_on_layer_change` apply cleanly +across shapes. Nothing forces the tables to stay separate. + +--- + +## Section 3 — The engine model + +### 3a. Shape-driven behavior (the unifying rule) + +The engine reads the action's shape and applies the matching rules. Two fields, +`activate_on_keypress` and `quick_release`, are **honored only for the pure-mod +shape** and **ignored for the tap-key shape** — this is structural, not +configurable: + +| | Pure modifier (`key == No`) | Tap-key (`key != No`) | +|---|---|---| +| transmission on press | per `activate_on_keypress` | **always immediate** | +| `quick_release` | honored | **n/a** | +| termination by foreign key | apply held mod **to** that key, then release | release **without** applying | +| accumulation across taps | yes (`Ctrl` then `Shift` then `P` → `Ctrl+Shift+P`) | no | +| `max_repeat` cycling | n/a (no tap-key) | yes | + +**Why ignoring those two fields for tap-key SK is principled, not a hack:** +`activate_on_keypress` means "defer the mod and fuse it into the *next* key." A +tap-key SK has nothing to defer — the tap *is* the action on each press, and you +cannot fold a `Tab` keystroke into a later key. So "deferred" is undefined for the +tap-key shape; immediate transmission is the only coherent behavior. `quick_release` +(consume on the next key's press vs. release) is likewise meaningless when +termination is a foreign key that the mod is not applied to. Both ride the same +`key == No` axis the engine already needs for the termination rule, so honoring +them only in the pure-mod arm costs no new machinery — one branch, reused. + +This is also what makes a **single** `[behavior.sticky_key]` profile serve both +OSM and alt-tab correctly on the transmission axes from day one: the +`activate_on_keypress = false` default gives clean OSM chords, while alt-tab keys +auto-force immediate transmission by virtue of having a tap-key. The only residual +single-default compromise is `timeout`. + +### 3b. The terminating-key behavior (the real new work) + +Today (`keyboard.rs:1220-1232`) a foreign key press releases the SK **before** the +foreign key is processed, so the foreign key is sent **without** the held mod. +That is correct for alt-tab (no Alt on the Enter that picks a window) but **wrong** +for OSM (`SK(LGui)` then `P` must send `Gui+P`). The engine must therefore branch +on shape: + +- **pure-mod:** the held mod must remain applied **through** the terminating key's + report, then release (on that key's press or release per `quick_release`). This + is OSM's existing "decorate the next key" behavior, now driven from the SK path. +- **tap-key:** unchanged from today — release first, foreign key sent clean. + +### 3c. Modifier accumulation (preserve OSM's behavior) + +Today a second SK press just increments `repeat_count` and **ignores** the new +mods (`sticky_key.rs:90-102`). OSM instead accumulates (`oneshot.rs:42/59`, +`cur | new`). For the pure-mod shape the engine must accumulate so +`SK(LCtrl)` then `SK(LShift)` then `P` yields `Ctrl+Shift+P`. The tap-key shape +keeps the repeat-count behavior. + +### 3d. Layer shape (absorb OSL) + +`SK(MO(n))` activates layer `n` as one-shot. The engine reuses OSL's existing +layer activate/deactivate logic (`oneshot.rs:116-161`, `184-193`) but on the +shared latch + the shared deadline/foreign-key plumbing — fully folded in, not the +"documented seam" the 06-02 spec deferred. + +### 3e. Shared latch + plumbing (reused from the 06-02 design) + +The unification mechanics from the prior design still apply and should be reused: + +- **One latch state** replacing `OneShotState` and `StickyKeyState`, carrying + `mods`, optional `key`, optional `layer`, `phase` (Pressed/Latched/Held), + `repeat_count`, and `deadline: Option`. +- **One timeout mechanism** — the non-blocking deadline raced in the `run()` loop + (`keyboard.rs:158-185`). Delete the blocking inline `select(timeout, …)` blocks + in `process_action_osm`/`process_action_osl` (`oneshot.rs:75-93`, `139-152`) and + the `unprocessed_events` re-queue path (pending the audit that OSM/OSL are its + only producers). This also fixes the documented OSM "select race." +- **One foreign-key hook** and **one modifier-resolve sink** + (`resolve_explicit_modifiers`, `keyboard.rs:1380-1403`), now reading one latch. + +The "preset" concept from the 06-02 spec is subsumed here: the preset is no longer +a tag carried alongside the action — it is **derived from the action shape** +(`key == No` / `key != No` / layer), which is strictly simpler. + +--- + +## Section 4 — Deferred: per-key overrides & named profiles + +Both per-key argument overrides and named `[behavior.sticky_key.profiles.]` +subtables are **out of scope this round.** Rationale: keep the consolidation as +simple as possible, get it working and validated, **then** measure the RAM/flash +impact of adding overrides before committing to them. + +Design constraint this imposes: nothing may architecturally depend on per-key +overrides or named profiles existing. Every setting resolves to a concrete value +at codegen from the single global table, and the runtime engine is blind to where +a value came from. Adding overrides/profiles later must be an additive change to +the config-resolve + codegen layers with **no** runtime/engine coupling — and the +known first use is giving alt-tab keys a longer `timeout` than OSM keys. + +--- + +## Section 5 — Action payload (wire shape) + +Absorbing OSL forces the SK action to carry a layer, which the current +all-concrete `StickyKeyAction` cannot. The action must represent the three shapes. +Two candidate encodings (decide during implementation): + +- **Tagged variant** — `StickyKeyAction` becomes a small enum: + `Mods { keep, key, max_repeat }` | `Layer { layer }`, sharing the + table-sourced `timeout` / `activate_on_keypress` / `quick_release` / + `release_on_layer_change` at runtime. +- **Added optional field** — keep a struct, add `layer: Option`; `Some` + marks the layer shape (`key`/`keep` unused), `None` is the mod/tap-key shape + with `key == No` distinguishing the two. + +Either way this is a **postcard wire-order / struct change** that invalidates +stored keymaps in flash and Vial state. Accepted because (a) full replacement is +the goal, and (b) the firmware is reflashed on every change. Migration note for +users: reflash both halves and re-sync Vial after upgrading. + +The `OneShotModifier` / `OneShotLayer` `Action` variants are **removed** from the +wire once `OSM(...)`/`OSL(...)` are lowered to `SK(...)` at codegen — there is no +remaining producer. (If the deprecated aliases are kept per Section 1, they still +lower to `Action::StickyKey`; the old variants do not survive.) + +--- + +## Section 6 — Documentation requirement + +The pure-mod vs. tap-key shape distinction — and specifically that +`activate_on_keypress` and `quick_release` are **honored only for pure-mod SKs and +silently ignored for tap-key SKs** — MUST be explained clearly and prominently in +the user docs (keymap config reference and the `[behavior.sticky_key]` section). +This is the one piece of "magic" in the model: a setting present in the table that +applies to some SK keys and not others. Leaving it implicit would make tap-key +behavior look like a bug. The docs must state the rule, the rationale (a tap-key +has nothing to defer), and the three-shape table from the Overview. + +--- + +## Section 7 — Staging & tests + +The existing OSM/OSL tests (`keyboard_one_shot_test.rs`, 25) and SK tests +(`keyboard_sticky_key_test.rs`, 11) are the **capability oracle** — but unlike the +06-02 design they will **not** all stay byte-for-byte green, because syntax, +config, and defaults change. They are instead the checklist of *behaviors* that +must still exist after migration; each gets re-expressed against the new surface. +Tests run via `cargo nextest`. + +- **Stage 0 — Characterize.** Catalogue every behavior the 36 tests pin (one row + per OSM/OSL/SK axis). This list is the parity contract for the new surface. +- **Stage 1 — Config + parser.** Collapse the three tables into + `[behavior.sticky_key]` (with the rename); add the `SK(LGui)` / `SK(MO(n))` + parse paths; lower `OSM`/`OSL` (and remove the legacy 5-positional SK tail). + Update keymaps/tests to the new syntax. **Gate: rewritten config/parse tests + green.** +- **Stage 2 — Engine: shape dispatch + absorb OSM.** Single latch; pure-mod path + with terminating-key application (3b), accumulation (3c), and shape-gated + `activate_on_keypress`/`quick_release` (3a). Delete the inline `select` and (per + audit) `unprocessed_events`. **Gate: all OSM-behavior tests green against the new + syntax; SK tests green.** +- **Stage 3 — Engine: absorb OSL.** Fold `SK(MO(n))` onto the latch reusing the + layer activate/deactivate logic; `release_on_layer_change` reads the latch. + **Gate: all OSL-behavior tests green; full suite + `cargo clippy` clean.** +- **Stage 4 — Docs.** Write the Section 6 documentation. **Gate: docs reviewed.** + +**New tests:** a targeted test that the pure-mod terminating-key behavior applies +the mod to the consuming key (the OSM-via-SK regression that today's SK engine +gets wrong), and one for cross-tap accumulation on the pure-mod shape. + +--- + +## Section 8 — Risks & non-goals + +**Risks (ranked):** + +1. **Terminating-key semantics (3b).** Making the held mod survive *through* the + foreign key for pure-mod SK while still dropping it for tap-key SK is the core + behavioral change and the easiest to get subtly wrong (off-by-one on + press/release ordering). Mitigated by the dedicated regression test and the + re-expressed OSM suite. +2. **OSM timeout-semantics shift.** Moving OSM off the blocking inline `select` + onto the run-loop deadline changes *when* expiry is observed relative to an + incoming event (carried over from the 06-02 risk list). Watch on real hardware. +3. **Wire/struct break (Section 5).** Invalidates stored keymaps + Vial. Accepted, + but must be called out in release notes with the reflash/re-sync migration step. +4. **`unprocessed_events` removal.** Only safe if OSM/OSL were its sole producers; + grep/audit before deleting. +5. **Behavior loss during re-expression.** Any OSM/OSL axis (double-press un-latch, + held-promotion, accumulation, `quick_release`, `activate_on_keypress`, layer + one-shot) could be dropped when re-homed into the SK engine. Mitigated by the + Stage 0 catalogue used as the parity checklist. + +**Non-goals (explicitly out of scope this round):** + +- Per-key argument overrides and named `profiles` subtables (Section 4 — + deferred, pending RAM/flash measurement). +- Separate timeouts for OSM vs. alt-tab keys (the deferred override is the planned + mechanism; this round uses one shared `timeout`). +- `OneShotKey` (OSK) — still unsupported, stays a warning. +- Preserving the old `OSM`/`OSL`/legacy-positional-`SK` surfaces beyond the + optional deprecated lowering aliases (Section 1). + +--- + +## File map (anticipated) + +**Modify — config:** + +- `rmk-config/src/lib.rs` — replace `StickyKeyConfig { timeout }` and the + one-shot config structs with the unified `[behavior.sticky_key]` shape + (`timeout`, `activate_on_keypress`, `quick_release`, `max_repeat`, + `release_on_layer_change`); remove `[behavior.one_shot]` / + `[behavior.one_shot_modifiers]`. +- `rmk/src/config/behavior.rs` — collapse `OneShotConfig` + + `OneShotModifiersConfig` + `StickyKeyConfig` into one resolved config; new + defaults (Section 2). + +**Modify — parser/codegen:** + +- `rmk-macro/src/codegen/action_parser.rs` — `SK(LGui)` (pure-mod), `SK(MO(n))` + (layer), `SK(key,[mods])` parse; remove the 5-positional tail; lower + `OSM`/`OSL` to `SK` (or drop them per Open Questions). +- `rmk/src/layout_macro.rs` — update/remove the `SK(...)` macro arms for the new + shapes; layer-carrying payload. + +**Modify — engine:** + +- `rmk/src/keyboard/sticky_key.rs` — `StickyKeyState` → unified latch; shape + dispatch (3a), terminating-key application (3b), accumulation (3c), layer shape + (3d). +- `rmk/src/keyboard/oneshot.rs` — absorb OSM/OSL logic into the latch; delete the + inline `select` timeout blocks; this file likely shrinks to nothing or merges + into `sticky_key.rs`. +- `rmk/src/keyboard.rs` — state fields (`osm_state`, `osl_state`, + `sticky_key_state` → one latch), deadline race, dispatch arms, foreign-key hook + (`1220-1232`, `1586`), layer-change release (`1600` + spots), + `resolve_explicit_modifiers` (`1380-1403`); remove `unprocessed_events` + producers (pending audit). + +**Modify — wire:** + +- `rmk-types/src/action/mod.rs` — `StickyKeyAction` gains the layer shape + (Section 5); remove `OneShotModifier` / `OneShotLayer` variants. +- `rmk/src/host/via/keycode_convert.rs` — drop OSM/OSL keycode mappings. +- `rmk/src/storage/mod.rs` — `one_shot_timeout` persisted field → the unified + config; Vial one-shot-timeout handling. + +**Modify — docs/tests:** + +- User docs — Section 6 documentation requirement. +- `rmk/tests/keyboard_one_shot_test.rs`, `keyboard_sticky_key_test.rs` — + re-expressed against the new surface; add the 3b/3c regression tests. + +--- + +## Open questions for the implementation plan + +1. **Keep `OSM(...)`/`OSL(...)` as deprecated lowering aliases, or drop the syntax + outright?** Default: keep-and-lower now, delete later (low cost, eases keymap + migration). Confirm before Stage 1. +2. **Action payload encoding (Section 5):** tagged variant vs. added + `layer: Option` — decide by which keeps the engine dispatch cleanest once + the latch is merged. +3. **Home of the unified latch** — fold `oneshot.rs` into `sticky_key.rs`, or a + new shared module — decide during Stage 2. +4. **Vial one-shot-timeout control** — does the unified `timeout` keep a Vial + runtime-set path, or is that dropped with the OSM keycodes? Decide in Stage 1. From 4e5ff1f1f23f4e47e8eb0aa3c9dfc2c01360d529 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:12:14 -0500 Subject: [PATCH 037/119] docs: drop OSM/OSL syntax entirely; mark wire/Vial impact and OSK as post-testing TBDs --- .../2026-06-03-sk-absorbs-oneshot-design.md | 63 ++++++++++--------- 1 file changed, 35 insertions(+), 28 deletions(-) diff --git a/docs/superpowers/specs/2026-06-03-sk-absorbs-oneshot-design.md b/docs/superpowers/specs/2026-06-03-sk-absorbs-oneshot-design.md index ef239a47d..6b11497c3 100644 --- a/docs/superpowers/specs/2026-06-03-sk-absorbs-oneshot-design.md +++ b/docs/superpowers/specs/2026-06-03-sk-absorbs-oneshot-design.md @@ -81,13 +81,12 @@ layer); `SK(Tab, [LAlt])` = sticky-mod (alt-tab). `[behavior.sticky_key]` table (Section 4). The bare forms above are the entire surface. -**`OSM(...)` / `OSL(...)` keymap forms — lowered to `SK(...)` at codegen, then -removed.** To ease migration of existing keymaps, the parser may retain `OSM(m)` -and `OSL(n)` as **deprecated aliases** that lower to `SK(m)` and `SK(MO(n))` -respectively at codegen (zero runtime cost — they produce the identical -`Action::StickyKey`). This is a confirm-or-drop decision (see Open Questions); the -default position is to lower-and-deprecate now, delete later, matching the -"keep for now, simplify once consolidation is proven" approach used for the config. +**`OSM(...)` / `OSL(...)` keymap forms are dropped entirely.** No deprecated +aliases, no lowering shim. The parser removes the `OSM` and `OSL` action keywords; +existing keymaps using them must be rewritten to the `SK(...)` forms above. (This +is a fork whose keymaps we control and rewrite as part of the migration, so a +compatibility shim buys nothing.) Encountering `OSM(...)`/`OSL(...)` after this +change is a build error, not a silent fallback. **OSL payload requires the SK action to carry a layer.** `SK(MO(n))` cannot be expressed by today's `StickyKeyAction { key, keep, max_repeat, timeout_ms, @@ -263,15 +262,17 @@ Two candidate encodings (decide during implementation): marks the layer shape (`key`/`keep` unused), `None` is the mod/tap-key shape with `key == No` distinguishing the two. -Either way this is a **postcard wire-order / struct change** that invalidates -stored keymaps in flash and Vial state. Accepted because (a) full replacement is -the goal, and (b) the firmware is reflashed on every change. Migration note for -users: reflash both halves and re-sync Vial after upgrading. +Either way this is a **postcard wire-order / struct change.** Whether it +invalidates stored keymaps in flash and Vial state — and if so, what migration +handling (if any) is needed — is **to be determined after the engine is +implemented and working**; the impact may only become clear during hardware +testing. Do not assume it is harmless. Flag it explicitly for evaluation in the +test phase, and capture whatever is found (reflash needed? Vial re-sync? storage +schema bump?) before this work moves toward PR #859. The `OneShotModifier` / `OneShotLayer` `Action` variants are **removed** from the -wire once `OSM(...)`/`OSL(...)` are lowered to `SK(...)` at codegen — there is no -remaining producer. (If the deprecated aliases are kept per Section 1, they still -lower to `Action::StickyKey`; the old variants do not survive.) +wire — with `OSM(...)`/`OSL(...)` dropped from the parser (Section 1), there is no +remaining producer, and every SK form lowers to `Action::StickyKey`. --- @@ -301,7 +302,7 @@ Tests run via `cargo nextest`. per OSM/OSL/SK axis). This list is the parity contract for the new surface. - **Stage 1 — Config + parser.** Collapse the three tables into `[behavior.sticky_key]` (with the rename); add the `SK(LGui)` / `SK(MO(n))` - parse paths; lower `OSM`/`OSL` (and remove the legacy 5-positional SK tail). + parse paths; remove the `OSM`/`OSL` keywords and the legacy 5-positional SK tail. Update keymaps/tests to the new syntax. **Gate: rewritten config/parse tests green.** - **Stage 2 — Engine: shape dispatch + absorb OSM.** Single latch; pure-mod path @@ -332,8 +333,10 @@ gets wrong), and one for cross-tap accumulation on the pure-mod shape. 2. **OSM timeout-semantics shift.** Moving OSM off the blocking inline `select` onto the run-loop deadline changes *when* expiry is observed relative to an incoming event (carried over from the 06-02 risk list). Watch on real hardware. -3. **Wire/struct break (Section 5).** Invalidates stored keymaps + Vial. Accepted, - but must be called out in release notes with the reflash/re-sync migration step. +3. **Wire/struct break (Section 5).** A postcard wire-order / struct change is + unavoidable (the layer payload requires it). Its blast radius on stored keymaps + and Vial state is **undetermined** — evaluate during hardware testing and record + the finding (and any migration step) before moving toward PR #859. 4. **`unprocessed_events` removal.** Only safe if OSM/OSL were its sole producers; grep/audit before deleting. 5. **Behavior loss during re-expression.** Any OSM/OSL axis (double-press un-latch, @@ -347,9 +350,12 @@ gets wrong), and one for cross-tap accumulation on the pure-mod shape. deferred, pending RAM/flash measurement). - Separate timeouts for OSM vs. alt-tab keys (the deferred override is the planned mechanism; this round uses one shared `timeout`). -- `OneShotKey` (OSK) — still unsupported, stays a warning. -- Preserving the old `OSM`/`OSL`/legacy-positional-`SK` surfaces beyond the - optional deprecated lowering aliases (Section 1). +- `OneShotKey` (OSK) — still unsupported, stays a no-op warning this round. Its + role and whether it folds into SK is **deferred and revisited after the OSM+OSL + migration is confirmed working** (it is not yet understood well enough to design + for here). +- Preserving the old `OSM`/`OSL`/legacy-positional-`SK` surfaces — all dropped + (Section 1); no compatibility shim. --- @@ -369,8 +375,8 @@ gets wrong), and one for cross-tap accumulation on the pure-mod shape. **Modify — parser/codegen:** - `rmk-macro/src/codegen/action_parser.rs` — `SK(LGui)` (pure-mod), `SK(MO(n))` - (layer), `SK(key,[mods])` parse; remove the 5-positional tail; lower - `OSM`/`OSL` to `SK` (or drop them per Open Questions). + (layer), `SK(key,[mods])` parse; remove the 5-positional tail; remove the + `OSM` / `OSL` action keywords entirely (build error if used). - `rmk/src/layout_macro.rs` — update/remove the `SK(...)` macro arms for the new shapes; layer-carrying payload. @@ -406,13 +412,14 @@ gets wrong), and one for cross-tap accumulation on the pure-mod shape. ## Open questions for the implementation plan -1. **Keep `OSM(...)`/`OSL(...)` as deprecated lowering aliases, or drop the syntax - outright?** Default: keep-and-lower now, delete later (low cost, eases keymap - migration). Confirm before Stage 1. -2. **Action payload encoding (Section 5):** tagged variant vs. added +1. **Action payload encoding (Section 5):** tagged variant vs. added `layer: Option` — decide by which keeps the engine dispatch cleanest once the latch is merged. -3. **Home of the unified latch** — fold `oneshot.rs` into `sticky_key.rs`, or a +2. **Home of the unified latch** — fold `oneshot.rs` into `sticky_key.rs`, or a new shared module — decide during Stage 2. -4. **Vial one-shot-timeout control** — does the unified `timeout` keep a Vial +3. **Vial one-shot-timeout control** — does the unified `timeout` keep a Vial runtime-set path, or is that dropped with the OSM keycodes? Decide in Stage 1. +4. **Wire/Vial/storage migration impact (Section 5)** — determine after the engine + works (likely during hardware testing) whether the struct change invalidates + stored keymaps / Vial state, and what migration is needed, before moving toward + PR #859. From 8e8aa09a1bcb0720bb5a12eea9593f484d6e1cbe Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:59:52 -0500 Subject: [PATCH 038/119] docs: staged implementation plan for SK absorbing OSM+OSL --- .../2026-06-03-sk-absorbs-oneshot-plan.md | 658 ++++++++++++++++++ 1 file changed, 658 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md diff --git a/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md b/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md new file mode 100644 index 000000000..017ffd821 --- /dev/null +++ b/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md @@ -0,0 +1,658 @@ +# Sticky Key Absorbs One-Shot (OSM + OSL) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make a single Sticky Key (SK) engine and a single `Action::StickyKey` path fully absorb one-shot modifier (OSM) and one-shot layer (OSL), replacing the `OSM(...)`/`OSL(...)` keymap syntax and the three one-shot/sticky config tables with one `SK(...)` surface and one `[behavior.sticky_key]` table. Behavior is selected by the **shape** of the SK action (pure-mod / tap-key / layer), not by which legacy syntax was used. + +**Architecture:** One latch state replaces `OneShotState` (OSM), `OneShotState` (OSL), and the `StickyKeyState` enum. The engine reads the action's shape — `key == No` → pure-mod (OSM behavior, applies mod *through* the terminating key, accumulates), `key != No` → tap-key (alt-tab, releases clean), layer payload → one-shot layer (OSL behavior). Timeout moves entirely onto the existing non-blocking run-loop deadline race (`keyboard.rs:144-185`); the blocking inline `select(timeout, …)` blocks in `oneshot.rs` are deleted. This is a **deliberate, non-backward-compatible** replacement: user-facing syntax, config table names, defaults, and the wire/struct shape all change. + +**Tech Stack:** Rust `#![no_std]` firmware (RMK fork). Config: `rmk-config` (serde + pest grammar) → `rmk-config/src/resolved` → `rmk-macro` codegen → `rmk` runtime structs. Wire: `rmk-types` (postcard via `MaxSize`/`Serialize`/`Deserialize`). Tests: `cargo nextest` with `embassy-time` MockDriver (virtual time, per-test process isolation required). + +**Test command (memorize — every gate uses it):** +```sh +# from rmk-fork/rmk/ +cargo nextest run --no-default-features --features=split,vial,storage,async_matrix,_ble +# full feature matrix (run before declaring the whole feature done): +sh scripts/test_all.sh # from repo root +``` +`cargo test` will **abort at startup** by design (`rmk/tests/common/mod.rs:30-43` `require_nextest`). Always use nextest. + +**Repo / branch:** All work happens in `/mnt/c/RandomProjects/GitHubRepoProjects/rmk-fork` on branch `feat/osm-sticky-key-merge`. This is consumed by RMKSofleV2 via `[patch.crates-io]`. **Do not push toward PR #859.** The branch merges into the sticky-mod PR *only after* the full local suite passes **and** the user confirms it works on real hardware. + +--- + +## Decision Points (resolve these explicitly — do not silently pick) + +These are the spec's four open questions (Section 5 / "Open questions for the implementation plan"). Each is wired into a specific stage below. Where a stage reaches one, **STOP, state the trade-off, record the choice in this plan file (check the box + write the decision inline), and only then proceed.** Recommendations are given but are not final until confirmed. + +- **DP-1 — Action payload encoding (resolve in Stage 2, Task 2.1).** `StickyKeyAction` must carry an optional layer (OSL) which today's all-concrete struct cannot. Two encodings: + - *(a) Tagged enum:* `StickyKeyAction` becomes `enum { Mods { keep, key, max_repeat }, Layer { layer } }`. + - *(b) Added optional field:* keep the struct, add `layer: Option`; `Some` = layer shape, `None` = mod/tap-key shape with `key == No` distinguishing pure-mod from tap-key. + - **Recommendation:** (b) added `layer: Option` — smaller diff to the existing struct, derives (`MaxSize`/`Serialize`/`Deserialize`/`Schema`) carry over unchanged, and the engine already needs the `key == No` branch for the terminating-key rule, so the three-way match is `(layer, key)` → cheap. Revisit if the engine dispatch reads cleaner as an enum once the latch is merged. + +- **DP-2 — Home of the unified latch (resolve in Stage 2, Task 2.1).** Fold `oneshot.rs` into `sticky_key.rs`, or create a new shared module (e.g. `keyboard/latch.rs`). + - **Recommendation:** fold into `sticky_key.rs` and delete `oneshot.rs`. The spec's file map predicts `oneshot.rs` "likely shrinks to nothing or merges into `sticky_key.rs`." A new module name would orphan the established `sticky_key` mod path used across `keyboard.rs`. + +- **DP-3 — Vial one-shot-timeout runtime path (resolve in Stage 1, Task 1.4).** The unified `timeout` either keeps a Vial runtime-set path (`SettingKey::OneShotTimeout = 0x06`, handlers at `rmk/src/host/via/vial.rs:127-130` and `184-186`, storage `FlashOperationMessage::OneShotTimeout` at `rmk/src/storage/mod.rs:145`) or drops it with the OSM keycodes. + - **Recommendation:** **keep** the Vial setting wire-compatible but re-point it at the unified `sticky_key` timeout (rename the internal `one_shot_timeout` storage field / accessor to `sticky_key_timeout`, leave `SettingKey` numeric value `0x06` and the protocol bytes unchanged). Dropping a Vial `SettingKey` is itself a Vial-protocol break; this round we are already breaking the keymap wire (DP-4) and should not stack a second protocol break unless the user wants it. Confirm with user. + +- **DP-4 — Wire/Vial/storage migration impact (resolve in Stage 5, post-engine).** The `StickyKeyAction` struct/postcard change plus removal of `Action::OneShotModifier`/`OneShotLayer` variants is a wire-order break. Whether it invalidates keymaps stored in flash and Vial state — and what migration is needed (reflash? Vial re-sync? storage schema bump?) — is **TBD after the engine works**, likely only visible during hardware testing. **Do not assume harmless.** Stage 5 has an explicit evaluation task; the finding must be recorded before any move toward PR #859. + +--- + +## File Map (re-verified against `feat/osm-sticky-key-merge`, 2026-06-03) + +Line numbers below are current as of this plan. Re-confirm with a `grep`/Read immediately before editing each file — surrounding edits in earlier stages will shift them. + +**Config — TOML structs & resolve & codegen:** +- `rmk-config/src/lib.rs` — `BehaviorConfig` (570-580), `OneShotConfig` (614-616, `timeout`), `OneShotModifiersConfig` (621-624, `activate_on_keypress`, `quick_release`), `StickyKeyConfig` (629-632, `timeout`). TOML tables `[behavior.one_shot]`, `[behavior.one_shot_modifiers]`, `[behavior.sticky_key]`. +- `rmk-config/src/resolved/behavior.rs` — `one_shot_timeout_ms`, `one_shot_modifiers`, `sticky_key_timeout_ms` (4-17); extraction at 105-110 and 205. +- `rmk-config/src/keymap.pest` — `osm_action` (58), `osl_action` (73), `sk_action` (112-120), `key_action` integration (127). +- `rmk-config/src/layout.rs` — pest AST match arms: `osm_action` (391-397), `sk_action` (399-402), `osl_action` (423-428). +- `rmk-macro/src/codegen/behavior.rs` — `expand_one_shot` (25-39), `expand_one_shot_modifiers` (41-65), `expand_sticky_key` (67-79). +- `rmk-macro/src/codegen/action_parser.rs` — `parse_key` (152); `osl(` arm (201-206), `osm(` arm (207-225), `sk(` arm (226-289); `parse_modifiers` helper (51-85). + +**Runtime config:** +- `rmk/src/config/behavior.rs` — `BehaviorConfig` (11-22), `OneShotConfig` (62-75, default 1s), `OneShotModifiersConfig` (77-83), `StickyKeyConfig` (85-97, default `Duration::MAX`). +- `rmk/src/keymap.rs` — `one_shot_timeout()` (511), `sticky_key_timeout()` (515), `set_one_shot_timeout()` (561). + +**Macros (declarative):** +- `rmk/src/layout_macro.rs` — `osl!` (328-332), `osm!` (352-356), `sk!` (367-379). + +**Engine:** +- `rmk/src/keyboard/sticky_key.rs` — `StickyKeyState` enum (24-36: `None | Active { mods, repeat_count, max_repeat, exit_on_layer_change, deadline }`); helpers `value`/`is_active`/`deadline`/`exit_on_layer_change` (38-66); `process_action_sticky_key` (69-125, repeat-count increment 90-102); `release_sticky_key_if_active` (127-133). +- `rmk/src/keyboard/oneshot.rs` — `OneShotState` enum (10-20: `Initial/Single/Held/None`); `process_action_osm` (33-114, accumulation `cur | new` at 42/59/62, inline `select` 75-93, `unprocessed_events.retain` 49); `process_action_osl` (116-161, activate 119-133, inline `select` 139-152, `unprocessed_events.push` 148, deactivate via `update_osl` 184-193); `update_osm` (165-182). +- `rmk/src/keyboard.rs` — mod decls `oneshot` (44) / `sticky_key` (47), `use` imports (31-32); state fields `osl_state` (217), `osm_state` (220), `sticky_key_state` (223); `run()` loop + deadline race (144-185); action dispatch `OneShotLayer`/`OneShotModifier`/`StickyKey` (1316-1328); foreign-key release (1220-1230); layer-change release calls (`exit_on_layer_change()` at 1241, 1250, 1268, 1276, 1600); `resolve_explicit_modifiers` (1380-1403); `unprocessed_events` consumer (148-150) and **non-OSM producer** Clear Peer BLE (1683, `#[cfg(feature = "split")]`). + +**Wire / Via / storage:** +- `rmk-types/src/action/mod.rs` — `StickyKeyAction` struct (34-50: `key`, `keep`, `max_repeat`, `timeout_ms`, `exit_on_layer_change`); `Action` variants `OneShotLayer(u8)` (83), `OneShotModifier(ModifierCombination)` (85), `OneShotKey(KeyCode)` (87), `StickyKey(StickyKeyAction)` (98). Derives `Serialize, Deserialize, MaxSize, defmt::Format, Schema`. +- `rmk/src/host/via/keycode_convert.rs` — `to_via_keycode` OSL (61-64) / OSM (65-69); `from_via_keycode` OSL (188-192) / OSM (193-197); unit tests (280, 287). +- `rmk/src/storage/mod.rs` — `BehaviorConfig` persisted struct (301-316, `one_shot_timeout: u16` at 310); serialize (336); deserialize (514); `FlashOperationMessage::OneShotTimeout(u16)` (145); handler (803-804). +- `rmk-types/src/protocol/vial.rs` — `SettingKey::OneShotTimeout = 0x06` (101). +- `rmk/src/host/via/vial.rs` — `GetBehaviorSetting` OneShotTimeout (127-130); `SetBehaviorSetting` (184-186). + +**Tests / docs:** +- `rmk/tests/keyboard_one_shot_test.rs` — 25 tests (catalogued in Stage 0). +- `rmk/tests/keyboard_sticky_key_test.rs` — 11 tests (catalogued in Stage 0). +- `rmk/tests/common/mod.rs` — `require_nextest` (30-43), `run_key_sequence_test`; `rmk/tests/common/test_macro.rs` — `key_sequence_test!` (10). +- User docs — keymap config reference + `[behavior.sticky_key]` section (Stage 4 finds exact path). + +--- + +## Stage 0 — Characterize (the capability oracle / parity contract) + +**Goal:** Produce a written behavior catalogue — one row per OSM/OSL/SK axis the 36 existing tests pin. This list is the parity checklist every later stage is graded against. No code changes. + +### Task 0.1: Write the parity catalogue document + +**Files:** +- Create: `docs/superpowers/plans/sk-oneshot-parity-catalogue.md` + +- [ ] **Step 1: Catalogue OSM/OSL tests.** Open `rmk/tests/keyboard_one_shot_test.rs` and for each of the 25 tests write a row: `test name | behavior axis | syntax+config used | which new SK shape/setting it maps to`. The 25 (verified) are: + + - `test_osm_basic_single_behavior` (76) — OSM applies mod to next key then releases → pure-mod SK terminating-key. + - `test_osm_timeout` (108) — OSM expires after timeout; next key clean → shared `timeout`. + - `test_osm_held_behavior` (151) — held past key press, mod stays until OSM release → pure-mod held-promotion. + - `test_osm_multiple_keys` (185) — applies only to next key → pure-mod single-consume. + - `test_osm_rolling_with_tap_hold` (224) — OSM release before key release still applies → pure-mod ordering. + - `test_osm_combined_modifiers` (255) — two OSM presses accumulate (LShift+LCtrl) → pure-mod accumulation (3c). + - `test_osm_multiple_osm_with_wm` (291) — multiple OSM + `wm!` accumulate → accumulation + WM interaction. + - `test_osm_activate_on_keypress` (329) — mod sent immediately on press when enabled → `activate_on_keypress` (pure-mod only). + - `test_osm_combined_modifiers_with_activate_on_keypress` (366) — accumulate + early activation. + - `test_osl_basic_single_behavior` (394) — OSL activates layer for next key only → layer shape (3d). + - `test_osl_held_behavior` (411) — held across key press, layer stays until release → layer held-promotion. + - `test_osl_timeout` (428) — OSL expires; next key on base layer → shared `timeout` on layer shape. + - `test_osl_multiple_keys` (456) — applies only to next key → layer single-consume. + - `test_osm_then_osl` (477) — OSM+OSL combine, mod applies to layer-switched key. + - `test_osl_then_osm` (496) — OSL+OSM combine. + - `test_osm_and_osl_timeout` (515) — both time out independently. + - `test_osm_chain_mode_basic` (546) — `quick_release=false`: mod held until key release. + - `test_osm_chain_mode_multiple_keys` (567) — chain mode, only first key modified. + - `test_osm_chain_mode_activate_on_keypress` (592) — chain + early activation. + - `test_osm_quick_release_basic` (616) — `quick_release=true`: mod released mid key-press. + - `test_osm_quick_release_multiple_keys` (637). + - `test_osm_quick_release_combined_modifiers` (665). + - `test_osm_quick_release_with_wm` (688) — OSM mod released, WM mod persists. + - `test_osm_quick_release_activate_on_keypress` (711). + - `test_osm_quick_release_combined_activate_on_keypress` (734). + +- [ ] **Step 2: Catalogue SK tests.** Open `rmk/tests/keyboard_sticky_key_test.rs` and add the 11 (verified) rows. Note for each whether the axis is preserved, and which axes prove the **tap-key** shape (so they must keep `key != No` semantics): + + - `test_sk_basic_flow_press_twice` (131) — press sends key+mod; release holds; re-press repeats; layer exit cleans up → tap-key core. + - `test_sk_layer_change_cleanup` (162) — `exit_on_layer_change=true` cleanup on MO release → `release_on_layer_change`. + - `test_sk_shift_does_not_release_sk` (196) — a real modifier press does NOT release SK; they stack → foreign-key rule excludes modifiers. + - `test_sk_rapid_three_presses` (228) — three rapid presses each send key+mod. + - `test_sk_combined_modifiers` (263) — SK with `LCtrl|LShift` sends both. + - `test_sk_timeout` (293) — auto-release after global timeout; next key clean. + - `test_sk_timeout_resets_on_press` (332) — timeout resets each press. + - `test_sk_max_repeat` (375) — deactivates silently after `max_repeat=2` (3rd press deactivates) → `max_repeat` cycling. + - `test_sk_per_key_timeout_overrides_global` (414) — per-key `timeout_ms` overrides global. **NOTE:** per-key timeout override is *removed* this round (Section 4 deferred). This test must be **re-expressed or retired** — flag it in the catalogue as "capability deferred; convert to global-timeout assertion or delete with justification." + - `test_sk_exits_on_layer_change` (444) — `exit_on_layer_change=true`. + - `test_sk_survives_layer_change` (478) — `exit_on_layer_change=false` survives; released only by key press → new default `release_on_layer_change=false`. + +- [ ] **Step 3: Mark the two known new tests required by the spec (Section 7).** Add rows for tests that do **not** exist yet and must be authored in Stage 2: + - *pure-mod terminating-key regression* — `SK(LGui)` then `P` emits `Gui+P` (today's SK engine gets this wrong; today's OSM gets it right). This is the core 3b proof. + - *cross-tap accumulation on pure-mod* — `SK(LCtrl)` then `SK(LShift)` then `P` emits `Ctrl+Shift+P` (3c proof). + +- [ ] **Step 4: Mark capability deltas (accepted breaks) explicitly.** Add a short "Accepted behavior changes" section so reviewers don't mistake them for regressions: + - alt-tab SKs gain a default 1s timeout (previously none / `Duration::MAX`). + - default `release_on_layer_change=false` (was effectively `exit_on_layer_change=true` in several SK tests via the keymap). + - per-key `timeout_ms` and the 5-positional `SK(...)` tail are removed. + +- [ ] **Step 5: Commit.** +```bash +cd /mnt/c/RandomProjects/GitHubRepoProjects/rmk-fork +git add docs/superpowers/plans/sk-oneshot-parity-catalogue.md +git commit -m "docs: characterize OSM/OSL/SK behavior parity catalogue (Stage 0)" +``` + +--- + +## Stage 1 — Config + parser (collapse three tables → one; add `SK(LGui)`/`SK(MO(n))`; remove `OSM`/`OSL` and the 5-positional tail) + +**Goal:** The build accepts the new `[behavior.sticky_key]` table (with `activate_on_keypress`, `quick_release`, `max_repeat`, `release_on_layer_change`, `timeout`) and the new `SK(...)` parse forms; it **rejects** `OSM(...)`/`OSL(...)` and the legacy 5-positional `SK(...)` with a clear build error. Keymaps/tests are rewritten to the new syntax. + +**Gate:** Rewritten config/parse tests green. (Engine still references old state — it will be migrated in Stage 2; keep it compiling by leaving the runtime structs in place but feeding them from the new resolved values where needed, or stub as noted per task.) + +> **Ordering note:** Stage 1 changes the wire shape (`StickyKeyAction` gains a layer payload, `OneShotModifier`/`OneShotLayer` variants are removed). That touches the engine's `match` arms in `keyboard.rs:1316-1328`. To keep the crate compiling between Stage 1 and Stage 2, this stage **adds** the new payload shape and parse paths and makes the old `OneShotModifier`/`OneShotLayer` dispatch arms forward to the existing OSM/OSL engine functions *temporarily* (the producers are gone, so they're dead, but they keep types resolved). Stage 2 deletes them. If you prefer, do DP-1 here and thread it forward — but **resolve DP-1 before writing the wire struct (Task 2.1 references it; pull it earlier if needed).** + +### Task 1.1: Unified runtime `StickyKeyConfig` + +**Files:** +- Modify: `rmk/src/config/behavior.rs:85-97` (and `BehaviorConfig` 11-22) + +- [ ] **Step 1: Re-read the file** to confirm current line numbers for `OneShotConfig`, `OneShotModifiersConfig`, `StickyKeyConfig`, and `BehaviorConfig`. + +- [ ] **Step 2: Replace the three config structs with one.** New `StickyKeyConfig`: +```rust +/// Unified sticky-key configuration. Absorbs the former one_shot, one_shot_modifiers, +/// and sticky_key tables. `activate_on_keypress`/`quick_release` are honored only for +/// the pure-modifier SK shape (key == No); see docs. +#[derive(Clone, Copy, Debug)] +pub struct StickyKeyConfig { + /// Applies to every SK shape. Default 1s. + pub timeout: Duration, + /// Honored only by pure-mod SK. Default false. + pub activate_on_keypress: bool, + /// Honored only by pure-mod SK. Default false. + pub quick_release: bool, + /// 0 = infinite; governs tap-key cycling. Default 0. + pub max_repeat: u16, + /// true = a layer change releases the SK. Default false (survives). + pub release_on_layer_change: bool, +} + +impl Default for StickyKeyConfig { + fn default() -> Self { + Self { + timeout: Duration::from_secs(1), + activate_on_keypress: false, + quick_release: false, + max_repeat: 0, + release_on_layer_change: false, + } + } +} +``` + +- [ ] **Step 3: Update `BehaviorConfig`.** Remove the `one_shot: OneShotConfig` and `one_shot_modifiers: OneShotModifiersConfig` fields; keep only `sticky_key: StickyKeyConfig`. Delete `OneShotConfig` and `OneShotModifiersConfig` struct defs. Fix the `Default` impl of `BehaviorConfig` accordingly. + +- [ ] **Step 4: Build the config crate.** Run: `cargo build -p rmk --no-default-features --features=split,vial,storage,async_matrix,_ble` and fix any references that read `behavior.one_shot*` (you'll find them in `keymap.rs`, `storage/mod.rs`, the engine — expect failures; resolve only the config-crate-local ones now, defer engine ones to Stage 2 by leaving TODO and a temporary shim if needed). Expected: incremental compile errors that map the blast radius. + +- [ ] **Step 5: Commit.** +```bash +git add rmk/src/config/behavior.rs +git commit -m "feat(config): collapse one_shot/one_shot_modifiers/sticky_key into unified StickyKeyConfig" +``` + +### Task 1.2: Unified TOML table + resolve + codegen + +**Files:** +- Modify: `rmk-config/src/lib.rs:614-632` (TOML structs), `rmk-config/src/resolved/behavior.rs:4-17,105-110,205`, `rmk-macro/src/codegen/behavior.rs:25-79` + +- [ ] **Step 1: TOML struct (`rmk-config/src/lib.rs`).** Delete `OneShotConfig` (614-616) and `OneShotModifiersConfig` (621-624). Replace `StickyKeyConfig` (629-632) with the full surface: +```rust +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StickyKeyConfig { + pub timeout: Option, + pub activate_on_keypress: Option, + pub quick_release: Option, + pub max_repeat: Option, + pub release_on_layer_change: Option, +} +``` +Remove the `one_shot` and `one_shot_modifiers` fields from the parent `BehaviorConfig` (570-580); keep `sticky_key`. + +- [ ] **Step 2: Resolved struct (`rmk-config/src/resolved/behavior.rs`).** Replace `one_shot_timeout_ms` / `one_shot_modifiers` / `sticky_key_timeout_ms` (4-17) with a single resolved shape: +```rust +pub sticky_key_timeout_ms: Option, +pub sticky_key_activate_on_keypress: Option, +pub sticky_key_quick_release: Option, +pub sticky_key_max_repeat: Option, +pub sticky_key_release_on_layer_change: Option, +``` +Update extraction (was 105-110 one_shot, 205 sticky_key) to read all five fields off `[behavior.sticky_key]`. + +- [ ] **Step 3: Codegen (`rmk-macro/src/codegen/behavior.rs`).** Delete `expand_one_shot` (25-39) and `expand_one_shot_modifiers` (41-65). Replace `expand_sticky_key` (67-79) so it emits the full `StickyKeyConfig { timeout, activate_on_keypress, quick_release, max_repeat, release_on_layer_change }` using the Stage-1.1 defaults (1s / false / false / 0 / false) for any `None`. Update the `BehaviorConfig` assembly site that called the three deleted expanders. + +- [ ] **Step 4: Build both crates.** Run: `cargo build -p rmk-config && cargo build -p rmk-macro`. Expected: PASS. + +- [ ] **Step 5: Commit.** +```bash +git add rmk-config/src/lib.rs rmk-config/src/resolved/behavior.rs rmk-macro/src/codegen/behavior.rs +git commit -m "feat(config): single [behavior.sticky_key] TOML table, resolve, and codegen" +``` + +### Task 1.3: Parser — add `SK(LGui)`/`SK(MO(n))`, remove `OSM`/`OSL` and 5-positional tail + +**Files:** +- Modify: `rmk-config/src/keymap.pest:58,73,112-120,127`, `rmk-config/src/layout.rs:391-428`, `rmk-macro/src/codegen/action_parser.rs:152,201-289`, `rmk/src/layout_macro.rs:328-379` + +- [ ] **Step 1: Grammar (`keymap.pest`).** Delete `osm_action` (58) and `osl_action` (73). Rewrite `sk_action` (112-120) to accept the three bare shapes: +```pest +// SK(key, [mods]) | SK(modifier) | SK(MO(n)) +sk_action = { + ^"SK" ~ "(" ~ ( + layer_action // SK(MO(n)) — layer shape + | (keycode_name ~ "," ~ modifier_keep_list) // SK(key, [mods]) — tap-key shape + | modifier_combination // SK(LGui) — pure-mod shape + ) ~ ")" +} +``` +Remove `osm_action`/`osl_action` from the `key_action` rule (127). + +- [ ] **Step 2: pest AST (`layout.rs`).** Delete the `Rule::osm_action` (391-397) and `Rule::osl_action` (423-428) match arms. Keep the `Rule::sk_action` arm (399-402) — it forwards the raw string to codegen — but ensure it no longer assumes the 5-positional shape downstream. + +- [ ] **Step 3: codegen parse (`action_parser.rs`).** Delete the `osl(` arm (201-206) and `osm(` arm (207-225). Rewrite the `sk(` arm (226-289) to dispatch on the inner text: + - inner starts with `MO(` → emit `::rmk::sk_layer!(n)`. + - inner contains `[` → tap-key: parse `key` + `[mods]` (reuse existing bracket parse and `parse_modifiers`); emit `::rmk::sk!(key, mods)`. + - else → pure-mod: `parse_modifiers(inner)`; emit `::rmk::sk_mod!(mods)`. + - If the inner text still contains extra positional args after `]` (the legacy tail), `panic!` with a clear migration message: `"❌ keyboard.toml: the 5-positional SK(...) form is removed; use SK(key, [mods]). max_repeat/timeout/release_on_layer_change now live in [behavior.sticky_key]."` + +- [ ] **Step 4: declarative macros (`layout_macro.rs`).** Delete `osl!` (328-332) and `osm!` (352-356). Replace `sk!` (367-379) with three macros matching the new payload (uses DP-1 — pull DP-1 decision here if encoding the layer payload). Example assuming DP-1 recommendation (b), `layer: Option`: +```rust +#[macro_export] +macro_rules! sk { // tap-key shape + ($key:ident, $keep:expr) => { + $crate::types::action::KeyAction::Single($crate::types::action::Action::StickyKey( + $crate::types::action::StickyKeyAction { + key: $crate::types::keycode::KeyCode::Hid($crate::types::keycode::HidKeyCode::$key), + keep: $keep, + layer: None, + }, + )) + }; +} +#[macro_export] +macro_rules! sk_mod { // pure-mod shape (key == No) + ($m:expr) => { + $crate::types::action::KeyAction::Single($crate::types::action::Action::StickyKey( + $crate::types::action::StickyKeyAction { + key: $crate::types::keycode::KeyCode::No, + keep: $m, + layer: None, + }, + )) + }; +} +#[macro_export] +macro_rules! sk_layer { // layer shape (OSL) + ($n:literal) => { + $crate::types::action::KeyAction::Single($crate::types::action::Action::StickyKey( + $crate::types::action::StickyKeyAction { + key: $crate::types::keycode::KeyCode::No, + keep: $crate::types::modifier::ModifierCombination::new(), + layer: Some($n), + }, + )) + }; +} +``` +> The exact `StickyKeyAction` field set depends on **DP-1**. If DP-1 picks the tagged-enum encoding, these three macros emit the three enum variants instead. **Do not write this task until DP-1 is recorded** (it forces the field names used in Stage 2 too). + +- [ ] **Step 5: Build.** `cargo build -p rmk-config -p rmk-macro -p rmk`. Expect engine-side errors only (handled Stage 2) — config/parse/macro crates must build clean. + +- [ ] **Step 6: Commit.** +```bash +git add rmk-config/src/keymap.pest rmk-config/src/layout.rs rmk-macro/src/codegen/action_parser.rs rmk/src/layout_macro.rs +git commit -m "feat(parser): SK(LGui)/SK(MO(n)) shapes; drop OSM/OSL keywords and 5-positional SK tail" +``` + +### Task 1.4: Wire `StickyKeyAction` + remove OSM/OSL variants + Vial decision (DP-3) + +**Files:** +- Modify: `rmk-types/src/action/mod.rs:34-50,83-98`, `rmk/src/host/via/keycode_convert.rs:61-69,188-197,280-293`, `rmk-types/src/protocol/vial.rs:101`, `rmk/src/host/via/vial.rs:127-130,184-186`, `rmk/src/storage/mod.rs:145,310,336,514,803-804` + +- [ ] **Step 1: `StickyKeyAction` (rmk-types).** Apply the DP-1 encoding. For recommendation (b): replace `max_repeat`/`timeout_ms`/`exit_on_layer_change` fields (45-49) with `layer: Option`, keeping `key` and `keep`: +```rust +pub struct StickyKeyAction { + pub key: KeyCode, // No = pure-mod or layer shape + pub keep: ModifierCombination, // unused for layer shape + pub layer: Option, // Some = one-shot layer (OSL) shape +} +``` +Keep all derives (`Serialize, Deserialize, MaxSize, defmt::Format, Schema`). Delete the `Action::OneShotLayer` (83) and `Action::OneShotModifier` (85) variants. **Leave `Action::OneShotKey` (87) untouched** (OSK is an explicit non-goal this round — stays a no-op warning). + +- [ ] **Step 2: Via keycode_convert.** Delete the OSL/OSM arms in `to_via_keycode` (61-69) and `from_via_keycode` (188-197), and delete/replace the OSL(3)/OSM tests (280, 287). (These map OSM/OSL ranges `0x5280-0x52BF`, which no longer have producers.) + +- [ ] **Step 3: DP-3 — Vial one-shot-timeout path.** **STOP. Record the DP-3 decision.** Then: + - *If keeping (recommended):* rename the storage field `one_shot_timeout` → `sticky_key_timeout` and the keymap accessor `set_one_shot_timeout`/`one_shot_timeout` (`keymap.rs:511,561`) to `sticky_key_timeout`/`set_sticky_key_timeout`, but **leave `SettingKey::OneShotTimeout = 0x06` numeric value and the Vial byte layout unchanged** (rename the enum variant label only if desired; the wire value must not move). Re-point the `vial.rs` Get/Set handlers (127-130, 184-186) at the unified timeout. + - *If dropping:* delete `SettingKey::OneShotTimeout`, the two `vial.rs` handlers, `FlashOperationMessage::OneShotTimeout` (storage 145, handler 803-804), and the persisted field (310, 336, 514). **This is a second Vial-protocol break — flag it loudly in DP-4's evaluation.** + +- [ ] **Step 4: Storage (`storage/mod.rs`).** Per the DP-3 choice, update the persisted `BehaviorConfig` field (310), serialize (336), deserialize (514) to read the unified `sticky_key.timeout`. (Today's deserialize at 514 writes `behavior_config.one_shot.timeout`; re-point to `behavior_config.sticky_key.timeout`.) + +- [ ] **Step 5: Build + snapshot check.** `cargo build -p rmk-types -p rmk`. The wire-format change will likely break a postcard/Schema **snapshot test** (the branch has regenerated snapshots before — see commits `3de61454`, `3d8d5723`). If a snapshot test fails, regenerate it deliberately (do not hand-edit) and **note in the commit that the wire format changed** — this is the DP-4 break surfacing early. Run the snapshot regen exactly as the existing CI/scripts do (look for `insta` or a `*_snapshot` test + `cargo insta review` / `INSTA_UPDATE`). + +- [ ] **Step 6: Commit.** +```bash +git add rmk-types/src/action/mod.rs rmk/src/host/via/keycode_convert.rs rmk-types/src/protocol/vial.rs rmk/src/host/via/vial.rs rmk/src/storage/mod.rs +git commit -m "feat(wire): StickyKeyAction carries layer payload; remove OneShotModifier/OneShotLayer variants" +``` + +### Task 1.5: Rewrite config/parse-facing tests to the new syntax + +**Files:** +- Modify: `rmk/tests/keyboard_one_shot_test.rs`, `rmk/tests/keyboard_sticky_key_test.rs` (syntax/config only this stage — behavior assertions stay; they'll be the Stage 2/3 gates) + +- [ ] **Step 1: Mechanical syntax migration.** In both test files, rewrite keymap macros and configs: + - `osm!(mods)` → `sk_mod!(mods)` + - `osl!(n)` → `sk_layer!(n)` + - `sk!(key, mods, max_repeat, timeout_ms, exit)` → `sk!(key, mods)` (drop the tail; move `max_repeat`/`release_on_layer_change` intent into the `StickyKeyConfig` the test builds) + - `OneShotConfig { timeout }` / `OneShotModifiersConfig { activate_on_keypress, quick_release }` / `StickyKeyConfig { timeout }` → one `StickyKeyConfig { timeout, activate_on_keypress, quick_release, max_repeat, release_on_layer_change }`. + - `test_sk_per_key_timeout_overrides_global` (414): per the Stage 0 flag, **delete** it (capability deferred) and add a one-line comment in the file `// per-key timeout removed this round (deferred, spec Section 4); see parity catalogue`. + +- [ ] **Step 2: Adjust accepted-break expectations.** Tests that relied on alt-tab having *no* timeout, or SK defaulting to `exit_on_layer_change=true`, must set the config explicitly (`release_on_layer_change: true` where the old test assumed exit-on-change). Use the Stage 0 "Accepted behavior changes" list as the checklist. + +- [ ] **Step 3: Compile the test crate only (do not expect green yet).** `cargo nextest run --no-default-features --features=split,vial,storage,async_matrix,_ble --no-run`. Expected: compiles (engine still old → behavior tests may fail at runtime, that's Stage 2/3). If it does not compile, the parser/macro/wire work from 1.1-1.4 has a gap — fix before proceeding. + +- [ ] **Step 4: Commit.** +```bash +git add rmk/tests/keyboard_one_shot_test.rs rmk/tests/keyboard_sticky_key_test.rs +git commit -m "test: migrate one-shot/sticky tests to SK(...) syntax and unified config (Stage 1)" +``` + +**Stage 1 Gate:** Config/parse/macro/wire crates build; test crate compiles; `OSM(...)`/`OSL(...)`/5-positional-`SK(...)` now produce build errors. Behavior tests not yet green (engine pending). Run `cargo build` across the workspace to confirm only the engine `keyboard.rs`/`oneshot.rs`/`sticky_key.rs` arms remain to migrate. + +--- + +## Stage 2 — Engine: shape dispatch + absorb OSM + +**Goal:** One latch state; pure-mod path with terminating-key application (3b), accumulation (3c), and shape-gated `activate_on_keypress`/`quick_release` (3a). Delete the inline `select` timeout blocks. Remove the **OSM/OSL producers** of `unprocessed_events` (but **keep** the queue + consumer — the Clear Peer BLE producer at `keyboard.rs:1683` remains; spec Risk #4 audit = NOT sole producers). + +**Gate:** all OSM-behavior tests green against the new syntax; all (non-deferred) SK tests green. New 3b + 3c regression tests green. + +### Task 2.1: Define the unified latch (resolves DP-1 + DP-2) + +**Files:** +- Modify: `rmk/src/keyboard/sticky_key.rs:24-66` (latch state + helpers) +- Decision: DP-1 (payload encoding — must already be recorded from Task 1.4), DP-2 (latch home) + +- [ ] **Step 1: STOP — record DP-2.** Write the decision (recommended: fold `oneshot.rs` into `sticky_key.rs`, delete `oneshot.rs`) into the Decision Points section above. + +- [ ] **Step 2: Replace `StickyKeyState`** (enum `None | Active{...}` at 24-36) with the unified latch carrying everything the spec lists (Section 3e): `mods`, optional `key`, optional `layer`, `phase` (Pressed/Latched/Held), `repeat_count`, `deadline: Option`. Suggested shape: +```rust +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub(crate) enum SkPhase { + #[default] + Pressed, // SK pressed, not yet consumed + Latched, // armed, waiting for the next (foreign) key + Held, // promoted to held (key released after another key was used) +} + +#[derive(Clone, Copy, Default)] +pub(crate) enum StickyKeyState { + #[default] + None, + Active { + mods: ModifierCombination, + key: KeyCode, // No = pure-mod / layer shape + layer: Option, // Some = OSL shape + phase: SkPhase, + repeat_count: u16, + deadline: Option, + }, +} +``` +- [ ] **Step 3: Re-implement the helper methods** (`value`/`is_active`/`deadline` at 38-66, plus new shape predicates `is_pure_mod()` = `key == No && layer.is_none()`, `is_tap_key()` = `key != No`, `is_layer()` = `layer.is_some()`). `value()` returns the held mods for `resolve_explicit_modifiers`. Replace `exit_on_layer_change()` (it was per-action; now read `release_on_layer_change` from config — the helper becomes a config read in `keyboard.rs`, see Task 2.4). + +- [ ] **Step 4: Build.** `cargo build -p rmk ...` — expect failures only in the `process_*`/dispatch sites (next tasks). Commit the state shape alone: +```bash +git add rmk/src/keyboard/sticky_key.rs +git commit -m "feat(engine): unified SK latch carrying mods/key/layer/phase/repeat/deadline (DP-1, DP-2)" +``` + +### Task 2.2: Pure-mod path — accumulation, activate_on_keypress, quick_release, terminating-key application + +**Files:** +- Modify: `rmk/src/keyboard/sticky_key.rs` (`process_action_sticky_key`, was 69-125), fold in OSM logic from `oneshot.rs:33-114` +- Modify: `rmk/src/keyboard.rs:1380-1403` (`resolve_explicit_modifiers`), `1220-1230` (foreign-key hook) + +- [ ] **Step 1: Write the failing regression test first (3b — terminating-key).** Add to `rmk/tests/keyboard_sticky_key_test.rs`: +```rust +#[test] +fn test_sk_pure_mod_applies_to_terminating_key() { + // SK(LGui) then P must emit Gui+P (OSM-via-SK; today's SK engine drops the mod). + key_sequence_test! { + keyboard: create_test_keyboard_with_config(/* default StickyKeyConfig */), + sequence: [ + // press+release the SK(LGui) key, then press+release P + [SK_GUI_ROW, SK_GUI_COL, true, 10], [SK_GUI_ROW, SK_GUI_COL, false, 10], + [P_ROW, P_COL, true, 10], [P_ROW, P_COL, false, 10], + ], + expected_reports: [ + [KC_LGUI, [kc_to_u8!(P), 0,0,0,0,0]], // P sent WITH Gui + [0, [0,0,0,0,0,0]], + ] + }; +} +``` +(Adapt row/col + keymap to the file's existing harness; mirror an existing OSM test's scaffolding.) + +- [ ] **Step 2: Run it — verify it fails.** `cargo nextest run ... -E 'test(test_sk_pure_mod_applies_to_terminating_key)'`. Expected: FAIL (mod not applied / wrong report). + +- [ ] **Step 3: Implement pure-mod transmission.** In `process_action_sticky_key`, branch on shape. For pure-mod (`key == No && layer.is_none()`): + - On press: accumulate into the latch (`mods |= params.keep`) — port `cur | new` from `oneshot.rs:42/59/62` (this is 3c). Honor `activate_on_keypress` (config): if true, the mod is emitted immediately (set phase/flag so `resolve_explicit_modifiers` includes it now); if false, defer to the terminating key. + - Set `deadline` from `config.timeout` (replaces the inline `select`). + - Port the OSM state transitions (`Initial/Single/Held`) from `update_osm` (`oneshot.rs:165-182`) into the `SkPhase` transitions. + +- [ ] **Step 4: Terminating-key application (3b) in `resolve_explicit_modifiers` + foreign-key hook.** In `resolve_explicit_modifiers` (`keyboard.rs:1380-1403`) the latch `value()` already contributes held mods. The new work: for a **pure-mod** active latch, the held mod must remain applied **through** the terminating key's report, then release on that key's press or release per `quick_release`. Port OSM's "decorate the next key then release" from the OSM path. In the foreign-key hook (`keyboard.rs:1220-1230`), branch: pure-mod → do **not** release before the foreign key (apply mod, release after per `quick_release`); tap-key → release first (unchanged, clean foreign key). + +- [ ] **Step 5: Run the 3b test + the migrated OSM suite.** `cargo nextest run --no-default-features --features=split,vial,storage,async_matrix,_ble`. Iterate until all `test_osm_*` (basic, held, multiple_keys, rolling, chain_mode_*, quick_release_*) and the new 3b test pass. Use `superpowers:systematic-debugging` on any failure — the parity catalogue says exactly which axis each test pins. + +- [ ] **Step 6: Add + pass the accumulation regression (3c).** +```rust +#[test] +fn test_sk_pure_mod_accumulates_across_taps() { + // SK(LCtrl) then SK(LShift) then P -> Ctrl+Shift+P + // ... harness ... + expected_reports: [ [KC_LCTRL | KC_LSHIFT, [kc_to_u8!(P),0,0,0,0,0]], [0,[0,0,0,0,0,0]] ] +} +``` +Run; verify green (Step 3 already ported accumulation). + +- [ ] **Step 7: Commit.** +```bash +git add rmk/src/keyboard/sticky_key.rs rmk/src/keyboard.rs rmk/tests/keyboard_sticky_key_test.rs +git commit -m "feat(engine): pure-mod SK shape — accumulation, activate_on_keypress/quick_release, terminating-key application (3a/3b/3c)" +``` + +### Task 2.3: Tap-key path parity (preserve alt-tab) + timeout on run-loop deadline + +**Files:** +- Modify: `rmk/src/keyboard/sticky_key.rs` (tap-key branch), `rmk/src/keyboard.rs:144-185` (deadline race already races `sticky_key_state.deadline()`) + +- [ ] **Step 1: Implement tap-key branch.** For `key != No`: always immediate transmission (ignore `activate_on_keypress`/`quick_release`), send `keep` mods + `key` on each press, `repeat_count += 1`, deactivate silently when `max_repeat > 0 && repeat_count > max_repeat` (port the existing `sticky_key.rs:90-102` logic), refresh `deadline` from `config.timeout` each press. On a foreign key, release **without** applying the mod (existing behavior). + +- [ ] **Step 2: Confirm the deadline race already drives SK timeout.** `keyboard.rs:158-177` already races `self.sticky_key_state.deadline()` and calls `release_sticky_key_if_active()` on expiry. Verify the unified latch's `deadline()` helper returns the right `Option` for all shapes (pure-mod, tap-key, layer). No new race machinery — this is the single timeout mechanism the spec wants (Section 3e). + +- [ ] **Step 3: Run the SK suite.** `cargo nextest run ...`. Iterate until `test_sk_basic_flow_press_twice`, `test_sk_shift_does_not_release_sk`, `test_sk_rapid_three_presses`, `test_sk_combined_modifiers`, `test_sk_timeout`, `test_sk_timeout_resets_on_press`, `test_sk_max_repeat` pass. (`test_sk_exits_on_layer_change`/`test_sk_survives_layer_change` finish in Task 2.4.) + +- [ ] **Step 4: Commit.** +```bash +git add rmk/src/keyboard/sticky_key.rs rmk/src/keyboard.rs +git commit -m "feat(engine): tap-key SK shape parity (alt-tab cycling, max_repeat) on unified latch" +``` + +### Task 2.4: Delete inline `select`, remove OSM/OSL `unprocessed_events` producers, retire OSM dispatch + +**Files:** +- Modify: `rmk/src/keyboard/oneshot.rs` (delete OSM logic + inline `select` 75-93), `rmk/src/keyboard.rs:1316-1328` (dispatch), `1380-1403`, `1241/1250/1268/1276/1600` (layer-change release → config `release_on_layer_change`) + +- [ ] **Step 1: Audit `unprocessed_events` (Risk #4) — record the finding.** Verified producers: `oneshot.rs:89` (OSM), `oneshot.rs:148` (OSL), **`keyboard.rs:1683` (Clear Peer BLE, `#[cfg(feature="split")]`)**. Consumer: `keyboard.rs:148-150`. **Conclusion: OSM/OSL are NOT the sole producers — the queue and consumer must STAY for Clear Peer.** Only delete the OSM/OSL push (89, 148) and the OSM `retain` (49). Write this conclusion as a code comment near the consumer so a future reader doesn't re-delete the queue. + +- [ ] **Step 2: Delete OSM from `oneshot.rs`.** Remove `process_action_osm` (33-114) including the inline `select(timeout, …)` (75-93) and the `retain` (49), and `update_osm` (165-182). (OSL removal is Stage 3; if folding `oneshot.rs` into `sticky_key.rs` per DP-2, keep OSL temporarily here or move it — your call, but keep it compiling.) + +- [ ] **Step 3: Retire the OSM dispatch arm.** In `keyboard.rs:1316-1328`, delete the `Action::OneShotModifier(m)` arm (now an unreachable/removed variant) and the cross-wise `update_osm`/`update_osl` calls tied to it. Remove the `osm_state` field (220) and its `use`/init. `resolve_explicit_modifiers` (1380-1403) now reads only the unified latch (the `osm_state` branch at ~1384-1389 is deleted). + +- [ ] **Step 4: Layer-change release → config.** The five `sticky_key_state.exit_on_layer_change()` call sites (1241, 1250, 1268, 1276, 1600) must now read `config.sticky_key.release_on_layer_change` instead of a per-action field (which no longer exists). Replace each `if self.sticky_key_state.exit_on_layer_change()` with `if self.keymap...sticky_key_config().release_on_layer_change` (use the actual config accessor; add one to `keymap.rs` if absent). + +- [ ] **Step 5: Run the full suite.** `cargo nextest run --no-default-features --features=split,vial,storage,async_matrix,_ble`. Iterate until **every** OSM-behavior and SK test is green (OSL tests will still fail until Stage 3 — that's expected; note which). Run `cargo clippy --no-default-features --features=... ` and clear warnings in touched files. + +- [ ] **Step 6: Commit.** +```bash +git add rmk/src/keyboard/oneshot.rs rmk/src/keyboard.rs rmk/src/keymap.rs +git commit -m "refactor(engine): retire OSM path + inline select; remove OSM/OSL unprocessed_events producers (keep queue for Clear Peer)" +``` + +**Stage 2 Gate:** All OSM-behavior tests + all (non-deferred) SK tests + the 3b/3c regressions green. Inline `select` gone. `unprocessed_events` queue retained (Clear Peer), OSM/OSL producers removed. Clippy clean in touched files. + +--- + +## Stage 3 — Engine: absorb OSL + +**Goal:** `SK(MO(n))` activates layer `n` as one-shot on the shared latch + shared deadline/foreign-key plumbing, reusing OSL's activate/deactivate logic. `release_on_layer_change` reads the config. + +**Gate:** all OSL-behavior tests green; full suite + `cargo clippy` clean. + +### Task 3.1: Layer shape on the unified latch + +**Files:** +- Modify: `rmk/src/keyboard/sticky_key.rs` (layer branch), `rmk/src/keyboard.rs:1316-1328` (dispatch), `rmk/src/keyboard/oneshot.rs` (port OSL activate/deactivate 119-133/184-193, then delete) + +- [ ] **Step 1: Run the migrated OSL tests — confirm current failure.** `cargo nextest run ... -E 'test(/osl/) or test(/osm_then_osl/) or test(/osl_then_osm/)'`. Expected: FAIL (no layer handling yet). + +- [ ] **Step 2: Implement the layer branch.** In `process_action_sticky_key`, for `layer.is_some()`: + - On press: activate the layer (`self.keymap.activate_layer(n)`) — port from `oneshot.rs:119-133` (including deactivating a previously-latched OSL layer if any). + - Arm the latch (phase transitions mirror OSL's `update_osl` at 184-193: deactivate on the Single→consume transition). + - Set `deadline` from `config.timeout`. + - On the terminating (foreign) key and on timeout: deactivate the layer, clear the latch. Reuse `release_sticky_key_if_active` so the deadline race (Task 2.3 Step 2) covers layer expiry too. + +- [ ] **Step 3: Dispatch.** `keyboard.rs` already routes all `Action::StickyKey` to `process_action_sticky_key` (1326-1328). Delete the `Action::OneShotLayer(l)` arm (1316-1320) and `osl_state` field (217) + its `use`/init + `update_osl`. + +- [ ] **Step 4: Delete OSL from `oneshot.rs`.** Remove `process_action_osl` (116-161) including inline `select` (139-152), the `unprocessed_events.push` (148), and `update_osl` (184-193). If `oneshot.rs` is now empty, delete the file and its `mod oneshot;` decl (`keyboard.rs:44`) + `use` (31) per DP-2. + +- [ ] **Step 5: Run OSL suite + full suite.** `cargo nextest run --no-default-features --features=split,vial,storage,async_matrix,_ble`. Iterate until `test_osl_*`, `test_osm_then_osl`, `test_osl_then_osm`, `test_osm_and_osl_timeout` all pass **and** nothing earlier regressed. + +- [ ] **Step 6: Commit.** +```bash +git add rmk/src/keyboard/sticky_key.rs rmk/src/keyboard.rs rmk/src/keyboard/oneshot.rs +git commit -m "feat(engine): absorb OSL — SK(MO(n)) layer shape on unified latch; delete oneshot.rs" +``` + +### Task 3.2: Full-suite + clippy gate + +**Files:** none (verification task) + +- [ ] **Step 1: Full feature matrix.** From repo root: `sh scripts/test_all.sh`. Expected: all green. (If the script enumerates feature combos, every combo must pass — the wire/snapshot changes from Stage 1 may surface here.) + +- [ ] **Step 2: Clippy across the workspace.** `cargo clippy --workspace --no-default-features --features=split,vial,storage,async_matrix,_ble -- -D warnings` (match the project's lint invocation if different). Fix warnings in touched files only (per surgical-changes rule). + +- [ ] **Step 3: Confirm OSK untouched.** Grep `OneShotKey` — confirm it remains a no-op warning (non-goal this round). No code change; just verify it wasn't accidentally altered. + +- [ ] **Step 4: Commit any lint fixes.** +```bash +git add -A +git commit -m "chore: clippy clean + full-suite green after OSM/OSL absorption (Stage 3 gate)" +``` + +**Stage 3 Gate:** Full suite + full feature matrix + clippy all green. `oneshot.rs` gone (or empty + removed). OSK untouched. + +--- + +## Stage 4 — Docs (Section 6 requirement) + +**Goal:** Document the pure-mod vs tap-key shape distinction — specifically that `activate_on_keypress` and `quick_release` are honored **only for pure-mod SKs and silently ignored for tap-key SKs** — prominently in the keymap config reference and the `[behavior.sticky_key]` section. Include the rationale (a tap-key has nothing to defer) and the three-shape table from the spec Overview. + +### Task 4.1: Write the docs + +**Files:** +- Modify: user docs — locate exact files first (likely `docs/` keymap config reference + a behavior/config page). Grep the repo's docs tree for the old `OSM`/`OSL`/`one_shot` documentation and the `[behavior.sticky_key]`/`[behavior.one_shot*]` sections. + +- [ ] **Step 1: Find the doc pages.** `grep -rn "OSM\|OSL\|one_shot\|sticky_key" docs/ *.md` (in rmk-fork). Identify the keymap-action reference and the behavior-config reference pages. + +- [ ] **Step 2: Replace OSM/OSL syntax docs with the SK shapes.** Document `SK(LGui)` (pure-mod = old OSM), `SK(MO(n))` (layer = old OSL), `SK(key, [mods])` (tap-key = alt-tab). Add the migration table from spec Section 1. + +- [ ] **Step 3: Replace the three config tables' docs with the single `[behavior.sticky_key]`.** Document all five keys (`timeout`, `activate_on_keypress`, `quick_release`, `max_repeat`, `release_on_layer_change`) with defaults (1s / false / false / 0 / false). + +- [ ] **Step 4: Add the shape-magic note prominently.** A callout/warning block stating: `activate_on_keypress` and `quick_release` apply **only to pure-mod SKs** (`SK(LGui)`); they are **silently ignored for tap-key SKs** (`SK(Tab, [LAlt])`) because a tap-key has nothing to defer. Include the three-shape table. + +- [ ] **Step 5: Note the accepted breaks.** Document that `OSM(...)`/`OSL(...)` and the 5-positional `SK(...)` form are removed (build errors), that alt-tab SKs now have a 1s default timeout, and that `exit_on_layer_change` is renamed `release_on_layer_change` (default false). + +- [ ] **Step 6: Commit.** +```bash +git add docs/ +git commit -m "docs: SK shapes + unified [behavior.sticky_key]; pure-mod vs tap-key magic-field rule (Section 6)" +``` + +**Stage 4 Gate:** Docs reviewed (the user reviews; surface the diff). The "magic field" rule is explicit and the three-shape table is present. + +--- + +## Stage 5 — Local verification, hardware testing, and wire/Vial migration evaluation (DP-4) + +**Goal:** Run the complete local suite and capture the **DP-4** wire/Vial/storage migration finding before any move toward PR #859. **This stage does not push to the PR.** The user runs hardware testing personally. + +### Task 5.1: Full local verification + +**Files:** none + +- [ ] **Step 1: Full suite, exact command.** From `rmk-fork/rmk/`: `cargo nextest run --no-default-features --features=split,vial,storage,async_matrix,_ble`. From repo root: `sh scripts/test_all.sh`. Capture output. Per `superpowers:verification-before-completion`, paste the real pass/fail counts — no "should pass." + +- [ ] **Step 2: Parity audit against Stage 0 catalogue.** Walk the Stage 0 catalogue row by row; confirm each behavior axis has a green test on the new surface (or is explicitly recorded as a deferred capability — only the per-key timeout). List any axis with no covering test and add a test if found missing. + +- [ ] **Step 3: Build the consumer firmware.** In RMKSofleV2, the `[patch.crates-io]` points at this fork. Build both layouts to confirm the new syntax/wire compiles end-to-end against a real keymap: `cargo make uf2` (from `/mnt/c/RandomProjects/GitHubRepoProjects/RMKSofleV2`). **The Sofle keymaps use `OSM(...)`/`OSL(...)`? If so they must be rewritten to `SK(...)` first** — grep the `keyboard_*.toml` files and migrate. Expected: 4 `.uf2` files build. + +### Task 5.2: DP-4 — evaluate wire/Vial/storage migration impact + +**Files:** +- Append findings to: `docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md` (this file) or a sibling `sk-oneshot-migration-findings.md` + +- [ ] **Step 1: Determine the storage blast radius.** The `StickyKeyAction` struct + `Action` enum changed (postcard wire order; removed variants). Determine whether keymaps stored in flash from a *pre-change* firmware deserialize correctly under the new layout, or are corrupted. Inspect the storage schema/version handling in `rmk/src/storage/mod.rs` — is there a schema-version field that triggers a wipe-on-mismatch? Record: **reflash needed? storage schema bump needed?** + +- [ ] **Step 2: Determine Vial state impact.** Per the DP-3 decision: if the Vial `SettingKey::OneShotTimeout` value was preserved, Vial sees no break on that setting; if dropped, Vial loses the setting. Also check whether removed OSM/OSL keycodes (`0x5280-0x52BF`) appear in any stored Vial keymap — if a user's Vial layout referenced them, what happens on load? Record: **Vial re-sync needed?** + +- [ ] **Step 3: Write the finding.** Record concretely: (reflash needed Y/N, Vial re-sync Y/N, storage schema bump Y/N, any migration code required). This is the spec's explicit DP-4 requirement and **must exist before the work moves toward PR #859.** + +- [ ] **Step 4: Hand off to hardware testing.** Stop here. Report to the user: full local suite results, the parity audit, the uf2 build result, and the DP-4 findings. **The user performs hardware testing.** Do not merge, do not push toward PR #859, do not run `finishing-a-development-branch` until the user confirms hardware works. + +- [ ] **Step 5: Commit the findings.** +```bash +git add docs/superpowers/plans/ +git commit -m "docs: DP-4 wire/Vial/storage migration findings; local verification complete (pre-hardware)" +``` + +**Stage 5 Gate:** Full local suite green (with real numbers), parity audit complete, consumer firmware builds, DP-4 findings recorded. **Awaiting user hardware confirmation before any PR movement.** + +--- + +## Self-Review (run against the spec) + +**Spec coverage:** +- Section 1 (syntax migration) → Stage 1 Tasks 1.3-1.5. ✔ +- Section 2 (config consolidation) → Stage 1 Tasks 1.1-1.2. ✔ +- Section 3a (shape dispatch / gated fields) → Stage 2 Task 2.2-2.3. ✔ +- Section 3b (terminating-key) → Stage 2 Task 2.2 (+ regression test). ✔ +- Section 3c (accumulation) → Stage 2 Task 2.2 (+ regression test). ✔ +- Section 3d (absorb OSL) → Stage 3 Task 3.1. ✔ +- Section 3e (shared latch/timeout/foreign-key/resolve sink) → Tasks 2.1, 2.3, 2.4. ✔ +- Section 4 (deferred overrides/profiles) → respected: per-key timeout test retired (1.5), no profile machinery added; DP-1/config resolve to concrete values. ✔ +- Section 5 (action payload) → DP-1 (Task 1.4/2.1); wire variant removal (1.4). ✔ +- Section 6 (docs) → Stage 4. ✔ +- Section 7 (staging/tests) → Stages 0-5 mirror the spec's Stage 0-4 + a verification stage. ✔ +- Section 8 risks: #1 terminating-key (3b test), #2 timeout-shift (Stage 5 hardware watch), #3 wire break (DP-4), #4 unprocessed_events (audited — NOT sole producers, queue kept), #5 behavior loss (Stage 0 catalogue + parity audit). ✔ +- All four open questions → DP-1 (2.1), DP-2 (2.1), DP-3 (1.4), DP-4 (5.2). ✔ + +**Decision-point integrity:** No decision is silently made — DP-1/2/3/4 each have an explicit STOP-and-record step with a stated recommendation that requires confirmation. + +**Known re-verification need:** All file:line anchors were re-checked on 2026-06-03 against `feat/osm-sticky-key-merge`, but every editing task re-greps before touching, because earlier-stage edits shift later-stage lines. The most important corrected fact vs. the spec: **`unprocessed_events` has a third (Clear Peer BLE) producer at `keyboard.rs:1683`**, so the spec's "delete `unprocessed_events`" is downgraded to "remove only the OSM/OSL producers; keep the queue" (Task 2.4 Step 1). From fea8f3497faf5636523ba4b61edc8f002f852d5a Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Wed, 3 Jun 2026 19:56:49 -0500 Subject: [PATCH 039/119] docs: characterize OSM/OSL/SK behavior parity catalogue (Stage 0) --- .../plans/sk-oneshot-parity-catalogue.md | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 docs/superpowers/plans/sk-oneshot-parity-catalogue.md diff --git a/docs/superpowers/plans/sk-oneshot-parity-catalogue.md b/docs/superpowers/plans/sk-oneshot-parity-catalogue.md new file mode 100644 index 000000000..d6c071d02 --- /dev/null +++ b/docs/superpowers/plans/sk-oneshot-parity-catalogue.md @@ -0,0 +1,160 @@ +# SK / OneShot Parity Catalogue (Stage 0) + +This document is the **parity checklist** for the effort to make a single "Sticky Key" +(SK) engine fully absorb one-shot modifier (OSM) and one-shot layer (OSL) behaviors. +It has one row per behavior axis pinned by the existing tests. Every later stage is +graded against this catalogue. + +**Source files characterized (verified, not copied from the plan):** + +- `rmk/tests/keyboard_one_shot_test.rs` — 25 tests (verified count == 25). +- `rmk/tests/keyboard_sticky_key_test.rs` — 11 tests (verified count == 11). + +**Verification method:** each row below was derived by reading the keymap definitions, +the per-test `BehaviorConfig` / `OneShotModifiersConfig` / `StickyKeyConfig`, and the +literal `expected_reports` assertions in the test bodies — not from the plan's prose. + +## Shared keymap context + +### OSM/OSL keymap (`keyboard_one_shot_test.rs`) + +```text +Layer 0: OSM(LShift) OSL(1) A TH(B,C) OSM(LCtrl) WM(B, LGui) +Layer 1: OSM(LShift|LCtrl) No C D E F +``` + +Cols by index: `0=OSM(LShift)`, `1=OSL(1)`, `2=A`, `3=TH(B,C)`, `4=OSM(LCtrl)`, +`5=WM(B,LGui)`. + +`OneShotConfig` default `timeout = 1000ms`. `OneShotModifiersConfig` fields exercised: +`activate_on_keypress` (default false), `quick_release` (default — see note below). + +### SK keymap (`keyboard_sticky_key_test.rs`) + +```text +Layer 0: A B C MO(1) LShift No +Layer 1: SK(Tab,LAlt,exit=true) SK(Tab,LCtrl,exit=true) SK(Tab,LCtrl|LShift,exit=true) Transparent Transparent No +``` + +SK macro shape used today is **5-positional**: +`sk!(key, mods, max_repeat, per_key_timeout_ms, exit_on_layer_change)`. +Default `StickyKeyConfig { timeout }` (global). Several alternate keymaps exist +(`KEYMAP_MAX_REPEAT`, `KEYMAP_PER_KEY_TIMEOUT`, `KEYMAP_NO_EXIT`). + +--- + +## Step 1 — OSM / OSL test catalogue (25 rows) + +| test name | behavior axis | syntax+config used | maps to (new SK shape/setting) | +|---|---|---|---| +| `test_osm_basic_single_behavior` | OSM applies mod to next key then releases | `osm!(LShift)`; default cfg (timeout 1000ms, activate_on_keypress=false). Tap OSM, tap A → `[LShift, A]` then `[0]` | pure-mod SK, terminating-key (3b): SK(LShift) then A emits Shift+A, mod auto-clears | +| `test_osm_timeout` | OSM expires after timeout; next key clean | `OneShotConfig.timeout=100ms`; A pressed at 150ms → `[0, A]` (no Shift) | shared global timeout on pure-mod SK | +| `test_osm_held_behavior` | held past key press; mod stays until OSM release | press OSM, press A (mod held), release A → `[LShift, A]`, `[LShift]`, then release OSM → `[0]` | pure-mod held-promotion (hold past consuming key keeps mod live) | +| `test_osm_multiple_keys` | mod applies only to the next key | tap OSM, tap A (`[LShift,A]`), tap B (`[0,B]` no Shift) | pure-mod single-consume (one terminating key only) | +| `test_osm_rolling_with_tap_hold` | mod ordering: OSM released before key release still applies | press OSM, press B (col 3 `TH(B,C)`, 10ms tap → B), release OSM, release B → `[LShift, B]` | pure-mod ordering / rolling-release: mod sticks through interleaved release | +| `test_osm_combined_modifiers` | two OSM presses accumulate | tap OSM(LShift) col0, tap OSM(LCtrl) col4, tap A → `[LShift\|LCtrl, A]` | pure-mod accumulation (3c): cross-tap mods stack onto one terminating key | +| `test_osm_multiple_osm_with_wm` | accumulation + WM interaction | OSM(LShift)+OSM(LCtrl)+`WM(B,LGui)` col5 → `[LShift\|LCtrl\|LGui, B]` | accumulation merges with WM's own mod (mods union, not overwrite) | +| `test_osm_activate_on_keypress` | mod emitted immediately on OSM press | `activate_on_keypress=true`; tap OSM → `[LShift]` emitted at once, then A → `[LShift, A]`, `[0]` | `activate_on_keypress` setting (pure-mod only — early mod emission) | +| `test_osm_combined_modifiers_with_activate_on_keypress` | accumulate + early activation | `activate_on_keypress=true`; two OSM then A → `[LShift]`, `[LShift\|LCtrl]`, `[LShift\|LCtrl, A]`, `[0]` | accumulation under `activate_on_keypress` (incremental mod reports) | +| `test_osl_basic_single_behavior` | OSL activates layer for next key only | `osl!(1)`; tap OSL, tap col2 → C (layer-1 key), then `[0]` | layer-shape SK (3d): one-shot layer for next key | +| `test_osl_held_behavior` | held across key press; layer stays until release | press OSL, press col2 (→C), release, release OSL → `[C]`, `[0]` | layer held-promotion | +| `test_osl_timeout` | OSL expires; next key on base layer | `OneShotConfig.timeout=100ms`; col2 at 150ms → A (layer 0) | shared global timeout on layer shape | +| `test_osl_multiple_keys` | layer applies only to the next key | OSL, col2→C (layer 1), col3→B (layer 0) | layer single-consume | +| `test_osm_then_osl` | OSM + OSL combine; mod applies to layer-switched key | OSM(LShift), OSL(1), col2 → **`[0, C]`** (C from layer 1). NOTE: report shows **no LShift modifier** — see discrepancy D1 | combined mod-shape + layer-shape ordering | +| `test_osl_then_osm` | OSL + OSM combine | OSL(1), then col0 OSM resolves (layer1 OSM is `LShift\|LCtrl`; col0 layer-1 is OSM(LShift\|LCtrl)), col2 → `[LShift\|LCtrl, A]`. NOTE: emits **both** Shift+Ctrl, not just Shift | layer-then-mod combination; mod set comes from the layer-active OSM | +| `test_osm_and_osl_timeout` | both time out independently | timeout=100ms; col2 at 200ms → `[A]` (layer 0, no mod) | independent expiry of mod-shape and layer-shape under shared timeout | +| `test_osm_chain_mode_basic` | `quick_release=false`: mod held until key RELEASE | `quick_release=false`; tap A → `[LShift, A]`, release A → `[0]` | chain mode: terminating-key holds mod until its release | +| `test_osm_chain_mode_multiple_keys` | chain mode: only first key modified | `quick_release=false`; A → `[LShift,A]`,`[0]`; B → `[0,B]`,`[0]` | chain single-consume | +| `test_osm_chain_mode_activate_on_keypress` | chain + early activation | `activate_on_keypress=true, quick_release=false`; `[LShift]`,`[LShift,A]`,`[0]` | chain mode under `activate_on_keypress` | +| `test_osm_quick_release_basic` | `quick_release=true`: mod released mid key-press | `quick_release=true`; press A → `[LShift,A]`, then **`[0,A]`** (mod dropped while key still held), release → `[0]` | quick-release mode: mod cleared as soon as terminating key registers | +| `test_osm_quick_release_multiple_keys` | quick-release single-consume | `quick_release=true`; A → `[LShift,A]`,`[0,A]`,`[0]`; B → `[0,B]`,`[0]` | quick-release single-consume | +| `test_osm_quick_release_combined_modifiers` | quick-release with accumulated mods | `quick_release=true`; OSM(LShift)+OSM(LCtrl)+A → `[LShift\|LCtrl,A]`,`[0,A]`,`[0]` | quick-release + accumulation | +| `test_osm_quick_release_with_wm` | OSM mod released, WM mod persists | `quick_release=true`; OSM(LShift)+OSM(LCtrl)+`WM(B,LGui)` → `[LShift\|LCtrl\|LGui,B]`, then **`[LGui,B]`** (only OSM mods dropped; WM's LGui stays), `[0]` | quick-release drops only SK-owned mods, leaves WM/other mods intact | +| `test_osm_quick_release_activate_on_keypress` | quick-release + early activation | `activate_on_keypress=true, quick_release=true`; `[LShift]`,`[LShift,A]`,`[0,A]`,`[0]` | quick-release under `activate_on_keypress` | +| `test_osm_quick_release_combined_activate_on_keypress` | quick-release + accumulation + early activation | both flags true; `[LShift]`,`[LShift\|LCtrl]`,`[LShift\|LCtrl,A]`,`[0,A]`,`[0]` | full combination: accumulation + early activation + quick-release | + +**Removed upstream (noted in the file, not a discrepancy):** the plan's +`test_osm_quick_release_rolling` does **not** exist — it was intentionally deleted +(comment at `keyboard_one_shot_test.rs:661-662`: "OSM + morse/tap-hold interaction +has a known bug where the OSM deadline loop times out before the tap resolves"). +This is why the OSM/OSL file holds 25 tests, not 26. The 25 present all match the +plan's list. + +--- + +## Step 2 — SK test catalogue (11 rows) + +For each row, "axis preserved?" states whether the SK engine must keep the axis after +the merge. Rows that **prove the tap-key shape** require `key != No` semantics +(SK actually emits a HID key, not just a modifier) and are flagged **[TAP-KEY PROOF]**. + +| test name | behavior axis | syntax+config used | maps to (new SK shape/setting) — preserved? | +|---|---|---|---| +| `test_sk_basic_flow_press_twice` | press sends key+mod; release holds mod; re-press repeats; layer exit cleans up | default keymap `sk!(Tab,LAlt,0,0,true)`; MO↓, SK↓→`[LAlt,Tab]`, SK↑→`[LAlt]`, SK↓→`[LAlt,Tab]`, SK↑→`[LAlt]`, MO↑→`[0]` | **[TAP-KEY PROOF]** tap-key core (key Tab + mod LAlt). Preserved — must keep `key != No` | +| `test_sk_layer_change_cleanup` | `exit_on_layer_change=true` → cleanup on MO release | `sk!(...,true)`; MO↑ produces `[0]` cleanup report | maps to new `release_on_layer_change`. **Behavior change:** new default is `false` (see Accepted changes); this test pins the `=true` path | +| `test_sk_shift_does_not_release_sk` | a real modifier press does NOT release SK; they stack | press LShift (col4 transparent→LShift) between SK presses → `[LCtrl\|LShift,...]`; SK stays active | foreign-key rule **excludes bare modifiers**: pressing a modifier stacks, does not terminate SK. Preserved | +| `test_sk_rapid_three_presses` | three rapid presses each send key+mod | `sk!(Tab,LAlt,...)`; 3×(SK↓→`[LAlt,Tab]`, SK↑→`[LAlt]`) | **[TAP-KEY PROOF]** repeated tap-key emission. Preserved | +| `test_sk_combined_modifiers` | SK with `LCtrl\|LShift` sends both | col2 `sk!(Tab, LCtrl\|LShift, ...)` → `[LCtrl\|LShift, Tab]` | **[TAP-KEY PROOF]** multi-mod tap-key. Preserved | +| `test_sk_timeout` | auto-release after global timeout; next key clean | `StickyKeyConfig.timeout=100ms`; SK↑ then 150ms wait → `[0]`; later C clean | shared global timeout on tap-key SK. Preserved | +| `test_sk_timeout_resets_on_press` | timeout resets on each press | timeout=100ms; SK#1↑ (T1), SK#2 at 50ms cancels T1, SK#2↑ (T2), 150ms→fire | timeout-reset-on-press. Preserved | +| `test_sk_max_repeat` | deactivates silently after `max_repeat=2` (3rd press deactivates) | `KEYMAP_MAX_REPEAT` `sk!(Tab,LAlt,2,0,false)`; press#3 → `[0]`, then A clean | `max_repeat` cycling. Preserved | +| `test_sk_per_key_timeout_overrides_global` | per-key `timeout_ms` overrides global | `KEYMAP_PER_KEY_TIMEOUT` `sk!(Tab,LAlt,0,50,false)`, global=100ms; releases at 50ms | **CAPABILITY DEFERRED.** Per-key timeout override is removed this round. This test must be **re-expressed or retired**: convert to a global-timeout assertion (drop the 50ms positional, assert release at the global 100ms boundary) **or delete with justification**. Flagged here per plan Step 2. | +| `test_sk_exits_on_layer_change` | `exit_on_layer_change=true` | duplicates Test 2's exit=true path (default keymap) → `[LAlt,Tab]`,`[LAlt]`,`[0]` | `release_on_layer_change=true` path. Preserved (explicit) | +| `test_sk_survives_layer_change` | `exit_on_layer_change=false` survives; released only by a key press | `KEYMAP_NO_EXIT` `sk!(...,false)`; MO↑ no report; later A↓ releases SK then sends A → `[0]`,`[0,A]`,`[0]` | new **default** `release_on_layer_change=false`. Preserved as the new default behavior | + +--- + +## Step 3 — New tests required by the spec (do NOT exist yet; author in Stage 2) + +| test name (proposed) | behavior axis | syntax+config | maps to (proof) | +|---|---|---|---| +| `test_sk_puremod_terminating_key` | pure-mod SK then a normal key emits mod+key, then mod clears | `sk!` with **no tap key** (pure-mod, e.g. `SK(LGui)`); press P → `[LGui, P]`, then `[0, P]`/`[0]` | **Core 3b proof.** SK(LGui) then P must emit Gui+P. Today's SK engine gets this wrong; today's OSM (`test_osm_basic_single_behavior`) gets it right. This regression test pins the absorbed OSM behavior. | +| `test_sk_puremod_cross_tap_accumulation` | two pure-mod SK taps accumulate onto one key | `SK(LCtrl)` then `SK(LShift)` then P → `[LCtrl\|LShift, P]` | **3c proof.** Mirrors `test_osm_combined_modifiers` but via the SK engine. Pins cross-tap mod accumulation for pure-mod SKs. | + +--- + +## Step 4 — Accepted behavior changes (deltas — NOT regressions) + +Reviewers must not mistake these intentional changes for regressions: + +1. **Alt-tab SKs gain a default 1s timeout.** Previously a tap-key SK could hold its + modifier indefinitely (effectively `Duration::MAX` / no timeout); after the merge + the shared one-shot timeout (default `1000ms`) applies. Behavioral effect: a stuck + Alt auto-clears after 1s of inactivity. +2. **Default `release_on_layer_change=false`.** Several existing SK tests used the + default keymap with `exit_on_layer_change=true` (e.g. `test_sk_basic_flow_press_twice`, + `test_sk_layer_change_cleanup`, `test_sk_exits_on_layer_change`). The new default is + `false` (SK survives a layer change), matching `test_sk_survives_layer_change`. Tests + that assert the `=true` cleanup path must opt in explicitly. +3. **`per-key timeout_ms` and the 5-positional `SK(...)` tail are removed.** The current + macro is `sk!(key, mods, max_repeat, per_key_timeout_ms, exit_on_layer_change)`. The + per-key timeout positional is dropped (capability deferred), so the positional tail + shrinks. `test_sk_per_key_timeout_overrides_global` is directly affected (Step 2). + +--- + +## Discrepancies found between the plan and the actual test files + +- **D0 — `test_osm_quick_release_rolling` absent (expected).** The plan's preamble said + "25 tests" but its bullet list and the actual file agree on 25; the 26th + (`..._rolling`) was deleted upstream with an explanatory comment. No action needed + beyond noting it. All 25 named tests in the plan exist with matching names. +- **D1 — `test_osm_then_osl` does NOT emit the OSM modifier.** The plan describes it as + "OSM+OSL combine, mod applies to layer-switched key." The **actual assertion is + `[0, C]` — no LShift modifier on the layer-switched key C.** The OSM appears to be + consumed/dropped by the intervening OSL activation rather than carried onto C. The + catalogue row reflects the real assertion. This is a meaningful parity detail for the + merge: the SK engine must reproduce this "OSM-then-OSL drops the mod" outcome, or the + behavior must be explicitly re-decided. **Flagged for design review.** +- **D2 — `test_osl_then_osm` emits `LShift|LCtrl`, not just `LShift`.** The plan row + says only "OSL+OSM combine." In reality, after OSL(1) the col-0 key resolves to the + **layer-1** OSM which is `OSM(LShift|LCtrl)`, so the final key A carries **both** + modifiers (`[LShift|LCtrl, A]`). The catalogue row captures the real mod set. +- **D3 — `test_sk_exits_on_layer_change` is an intentional duplicate of + `test_sk_layer_change_cleanup`.** Both pin the `exit=true` MO-release cleanup on the + default keymap; the file's own doc comment acknowledges this ("This is the same as + Test 2"). Not a problem, but flagged so a reviewer doesn't think one is redundant by + mistake — both should be migrated to the explicit `release_on_layer_change=true` opt-in. + +No test-count or test-name mismatches otherwise: 25 OSM/OSL + 11 SK = 36 existing tests, +matching the plan's "36 existing tests" total. From cbb75169b1dae9e12b37f31ef1c6a2bd23f9db56 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Wed, 3 Jun 2026 20:57:02 -0500 Subject: [PATCH 040/119] feat(config): collapse one_shot/one_shot_modifiers/sticky_key into unified StickyKeyConfig --- rmk/src/config/behavior.rs | 46 +++++++++++++++----------------------- rmk/src/config/mod.rs | 4 ++-- 2 files changed, 20 insertions(+), 30 deletions(-) diff --git a/rmk/src/config/behavior.rs b/rmk/src/config/behavior.rs index f20bdba45..f03b49790 100644 --- a/rmk/src/config/behavior.rs +++ b/rmk/src/config/behavior.rs @@ -11,8 +11,6 @@ use crate::{COMBO_MAX_NUM, FORK_MAX_NUM, MACRO_SPACE_SIZE, MORSE_MAX_NUM, MOUSE_ pub struct BehaviorConfig { pub tri_layer: Option<[u8; 3]>, pub tap: TapConfig, - pub one_shot: OneShotConfig, - pub one_shot_modifiers: OneShotModifiersConfig, pub combo: CombosConfig, pub fork: ForksConfig, pub morse: MorsesConfig, @@ -59,40 +57,32 @@ impl Default for MorsesConfig { } } -/// Config for one shot behavior +/// Unified sticky-key configuration. Absorbs the former one_shot, one_shot_modifiers, +/// and sticky_key tables. `activate_on_keypress`/`quick_release` are honored only for +/// the pure-modifier SK shape (key == No); see docs. #[derive(Clone, Copy, Debug)] -pub struct OneShotConfig { - /// Timeout after which modifiers/layers are canceled/released +pub struct StickyKeyConfig { + /// Applies to every SK shape. Default 1s. pub timeout: Duration, -} - -impl Default for OneShotConfig { - fn default() -> Self { - Self { - timeout: Duration::from_secs(1), - } - } -} -/// Config for one-shot behavior -#[derive(Clone, Copy, Debug, Default)] -pub struct OneShotModifiersConfig { - /// Should modifiers be active from keypress (sticky modifiers) + /// Honored only by pure-mod SK. Default false. pub activate_on_keypress: bool, - /// If true, OSM releases on next key press (ZMK skq); if false, on next key release (ZMK skn) + /// Honored only by pure-mod SK. Default false. pub quick_release: bool, -} - -/// Configuration for StickyKey behavior -#[derive(Clone, Copy, Debug)] -pub struct StickyKeyConfig { - /// Global timeout before auto-releasing held modifiers. - /// Duration::MAX = no timeout — modifier held until key press or layer change. - pub timeout: Duration, + /// 0 = infinite; governs tap-key cycling. Default 0. + pub max_repeat: u16, + /// true = a layer change releases the SK. Default false (survives). + pub release_on_layer_change: bool, } impl Default for StickyKeyConfig { fn default() -> Self { - Self { timeout: Duration::MAX } + Self { + timeout: Duration::from_secs(1), + activate_on_keypress: false, + quick_release: false, + max_repeat: 0, + release_on_layer_change: false, + } } } diff --git a/rmk/src/config/mod.rs b/rmk/src/config/mod.rs index 4967e67d5..53cc79727 100644 --- a/rmk/src/config/mod.rs +++ b/rmk/src/config/mod.rs @@ -7,8 +7,8 @@ mod storage; mod vial; pub use behavior::{ - BehaviorConfig, CombosConfig, ForksConfig, KeyboardMacrosConfig, MorsesConfig, MouseKeyConfig, OneShotConfig, - OneShotModifiersConfig, StickyKeyConfig, TapConfig, + BehaviorConfig, CombosConfig, ForksConfig, KeyboardMacrosConfig, MorsesConfig, MouseKeyConfig, StickyKeyConfig, + TapConfig, }; #[cfg(feature = "_ble")] pub use ble_battery::BleBatteryConfig; From 9ae4008cb69e950593716a6b9308502c18fc4e5c Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Thu, 4 Jun 2026 12:22:16 -0500 Subject: [PATCH 041/119] feat(config): single [behavior.sticky_key] TOML table, resolve, and codegen --- rmk-config/src/behavior.rs | 3 +- rmk-config/src/lib.rs | 24 ++------- rmk-config/src/resolved/behavior.rs | 31 ++++++------ rmk-macro/src/codegen/behavior.rs | 75 +++++++---------------------- 4 files changed, 38 insertions(+), 95 deletions(-) diff --git a/rmk-config/src/behavior.rs b/rmk-config/src/behavior.rs index eda41b845..66aa96df0 100644 --- a/rmk-config/src/behavior.rs +++ b/rmk-config/src/behavior.rs @@ -19,8 +19,7 @@ impl crate::KeyboardTomlConfig { } None => default.tri_layer, }; - behavior.one_shot = behavior.one_shot.or(default.one_shot); - behavior.one_shot_modifiers = behavior.one_shot_modifiers.or(default.one_shot_modifiers); + behavior.sticky_key = behavior.sticky_key.or(default.sticky_key); behavior.combo = behavior.combo.or(default.combo); if let Some(combo) = &behavior.combo { if combo.combos.len() > self.rmk.combo_max_num { diff --git a/rmk-config/src/lib.rs b/rmk-config/src/lib.rs index 7301e8e2c..11011565b 100644 --- a/rmk-config/src/lib.rs +++ b/rmk-config/src/lib.rs @@ -569,8 +569,6 @@ pub struct KeyInfo { #[serde(deny_unknown_fields)] pub(crate) struct BehaviorConfig { pub tri_layer: Option, - pub one_shot: Option, - pub one_shot_modifiers: Option, pub combo: Option, #[serde(alias = "macro")] pub macros: Option, @@ -608,27 +606,15 @@ pub(crate) struct TriLayerConfig { pub adjust: u8, } -/// Configurations for oneshot modifiers/layers -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct OneShotConfig { - pub timeout: Option, -} - -/// Configurations for oneshot modifiers -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct OneShotModifiersConfig { - pub activate_on_keypress: Option, - pub quick_release: Option, -} - /// Configurations for sticky key -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] pub struct StickyKeyConfig { - /// Timeout for sticky key auto-release (e.g., "5000ms", "5s") pub timeout: Option, + pub activate_on_keypress: Option, + pub quick_release: Option, + pub max_repeat: Option, + pub release_on_layer_change: Option, } /// Configurations for combos diff --git a/rmk-config/src/resolved/behavior.rs b/rmk-config/src/resolved/behavior.rs index 9dd1f9f25..f9fa30cd8 100644 --- a/rmk-config/src/resolved/behavior.rs +++ b/rmk-config/src/resolved/behavior.rs @@ -3,18 +3,15 @@ use std::collections::HashMap; /// Resolved behavioral configuration. pub struct Behavior { pub tri_layer: Option<[u8; 3]>, - pub one_shot_timeout_ms: Option, - pub one_shot_modifiers: Option, pub combos: Option, pub macros: Option, pub forks: Option, pub morse: Option, pub sticky_key_timeout_ms: Option, -} - -pub struct OneShot { - pub activate_on_keypress: Option, - pub quick_release: Option, + pub sticky_key_activate_on_keypress: Option, + pub sticky_key_quick_release: Option, + pub sticky_key_max_repeat: Option, + pub sticky_key_release_on_layer_change: Option, } pub struct Combos { @@ -102,13 +99,6 @@ impl crate::KeyboardTomlConfig { let tri_layer = toml_behavior.tri_layer.map(|t| [t.upper, t.lower, t.adjust]); - let one_shot_timeout_ms = toml_behavior.one_shot.and_then(|o| o.timeout.map(|t| t.0)); - - let one_shot_modifiers = toml_behavior.one_shot_modifiers.map(|o| OneShot { - activate_on_keypress: o.activate_on_keypress, - quick_release: o.quick_release, - }); - let combos = toml_behavior.combo.map(|c| Combos { combos: c .combos @@ -202,17 +192,24 @@ impl crate::KeyboardTomlConfig { } }); - let sticky_key_timeout_ms = toml_behavior.sticky_key.and_then(|s| s.timeout.map(|t| t.0)); + let sticky_key = toml_behavior.sticky_key; + let sticky_key_timeout_ms = sticky_key.as_ref().and_then(|s| s.timeout.as_ref().map(|t| t.0)); + let sticky_key_activate_on_keypress = sticky_key.as_ref().and_then(|s| s.activate_on_keypress); + let sticky_key_quick_release = sticky_key.as_ref().and_then(|s| s.quick_release); + let sticky_key_max_repeat = sticky_key.as_ref().and_then(|s| s.max_repeat); + let sticky_key_release_on_layer_change = sticky_key.as_ref().and_then(|s| s.release_on_layer_change); Ok(Behavior { tri_layer, - one_shot_timeout_ms, - one_shot_modifiers, combos, macros, forks, morse, sticky_key_timeout_ms, + sticky_key_activate_on_keypress, + sticky_key_quick_release, + sticky_key_max_repeat, + sticky_key_release_on_layer_change, }) } } diff --git a/rmk-macro/src/codegen/behavior.rs b/rmk-macro/src/codegen/behavior.rs index 1fc5024f1..96a5dc044 100644 --- a/rmk-macro/src/codegen/behavior.rs +++ b/rmk-macro/src/codegen/behavior.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; use quote::quote; use rmk_config::resolved::Behavior; use rmk_config::resolved::behavior::{ - Combos, Forks, MacroOperation, Macros, Morse, MorseActionPair, MorseKey, MorseProfile, OneShot, + Combos, Forks, MacroOperation, Macros, Morse, MorseActionPair, MorseKey, MorseProfile, }; use super::action_parser::{expand_profile, expand_profile_name, get_key_with_alias, parse_key}; @@ -22,59 +22,24 @@ fn expand_tri_layer(tri_layer: &Option<[u8; 3]>) -> proc_macro2::TokenStream { } } -fn expand_one_shot(one_shot_timeout_ms: &Option) -> proc_macro2::TokenStream { - let default = quote! {::rmk::config::OneShotConfig::default()}; - match one_shot_timeout_ms { - Some(millis) => { - let timeout = quote! {::embassy_time::Duration::from_millis(#millis)}; +fn expand_sticky_key(behavior: &Behavior) -> proc_macro2::TokenStream { + let timeout = match behavior.sticky_key_timeout_ms { + Some(millis) => quote! { ::embassy_time::Duration::from_millis(#millis) }, + None => quote! { ::embassy_time::Duration::from_secs(1) }, + }; + let activate_on_keypress = behavior.sticky_key_activate_on_keypress.unwrap_or(false); + let quick_release = behavior.sticky_key_quick_release.unwrap_or(false); + let max_repeat = behavior.sticky_key_max_repeat.unwrap_or(0); + let release_on_layer_change = behavior.sticky_key_release_on_layer_change.unwrap_or(false); - quote! { - ::rmk::config::OneShotConfig { - timeout: #timeout, - } - } - } - None => default, - } -} - -fn expand_one_shot_modifiers(one_shot_modifiers: &Option) -> proc_macro2::TokenStream { - let default = quote! { ::core::default::Default::default() }; - - match one_shot_modifiers { - Some(one_shot_modifier) => { - let activate_on_keypress = match one_shot_modifier.activate_on_keypress { - Some(value) => quote! { activate_on_keypress: #value, }, - None => quote! {}, - }; - let quick_release = match one_shot_modifier.quick_release { - Some(value) => quote! { quick_release: #value, }, - None => quote! {}, - }; - - quote! { - ::rmk::config::OneShotModifiersConfig { - #activate_on_keypress - #quick_release - ..Default::default() - } - } - } - None => default, - } -} - -fn expand_sticky_key(sticky_key_timeout_ms: &Option) -> proc_macro2::TokenStream { - match sticky_key_timeout_ms { - Some(millis) => { - let timeout = quote! { ::embassy_time::Duration::from_millis(#millis) }; - quote! { - ::rmk::config::StickyKeyConfig { - timeout: #timeout, - } - } + quote! { + ::rmk::config::StickyKeyConfig { + timeout: #timeout, + activate_on_keypress: #activate_on_keypress, + quick_release: #quick_release, + max_repeat: #max_repeat, + release_on_layer_change: #release_on_layer_change, } - None => quote! { ::rmk::config::StickyKeyConfig::default() }, } } @@ -504,20 +469,16 @@ pub(crate) fn expand_behavior_config(behavior: &Behavior) -> proc_macro2::TokenS .filter(|p| !p.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 macros = expand_macros(&behavior.macros); let forks = expand_forks(&behavior.forks, &profiles); let morse = expand_morse(&behavior.morse); - let sticky_key = expand_sticky_key(&behavior.sticky_key_timeout_ms); + let sticky_key = expand_sticky_key(behavior); quote! { #[allow(clippy::needless_update)] let mut behavior_config = ::rmk::config::BehaviorConfig { tri_layer: #tri_layer, - one_shot: #one_shot, - one_shot_modifiers: #one_shot_modifiers, combo: #combos, fork: #forks, morse: #morse, From a24b198a997b41065811c91f2538f9c9eee6fd1b Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Fri, 5 Jun 2026 17:21:28 -0500 Subject: [PATCH 042/119] feat(parser): SK(LGui)/SK(MO(n)) shapes; drop OSM/OSL keywords and 5-positional SK tail Adds the three SK action shapes to the keymap grammar/codegen/macros: pure-mod SK(LGui), tap-key SK(Tab,[LAlt]), and layer SK(MO(n)). Removes the OSM/OSL keywords, their pest rules and parse arms, and the legacy 5-positional SK(...) tail (now rejected with a migration panic). Macros emit the DP-1 field set { key, keep, layer: Option }. Also adds field-level doc comments to the TOML StickyKeyConfig (Task 1.2 follow-up). --- rmk-config/src/keymap.pest | 27 ++--- rmk-config/src/layout.rs | 12 --- rmk-config/src/lib.rs | 5 + rmk-macro/src/codegen/action_parser.rs | 133 ++++++++++--------------- rmk/src/layout_macro.rs | 72 ++++++------- 5 files changed, 104 insertions(+), 145 deletions(-) diff --git a/rmk-config/src/keymap.pest b/rmk-config/src/keymap.pest index b66040fe4..159e31d3f 100644 --- a/rmk-config/src/keymap.pest +++ b/rmk-config/src/keymap.pest @@ -54,9 +54,6 @@ transparent_action = @{ ("_")+ | (^"Trns" ~ !ASCII_ALPHANUMERIC) } // One or mor // Rule 1: WM(key, modifier) - Key with Modifier wm_action = { ^"WM" ~ "(" ~ keycode_name ~ "," ~ modifier_combination ~ ")" } -// Rule 4.6: OSM(modifier) - One-Shot Modifier (requires quotes) -osm_action = { ^"OSM" ~ "(" ~ modifier_combination ~ ")" } - // Rule 4.1: DF(n) - Switch Default Layer df_action = { ^"DF" ~ "(" ~ layer_reference ~ ")" } @@ -69,9 +66,6 @@ lm_action = { ^"LM" ~ "(" ~ layer_reference ~ "," ~ modifier_combination ~ ")" } // Rule 4.4: LT(n, key) - Layer Activate or Tap Key (Tap/Hold) lt_action = { ^"LT" ~ "(" ~ layer_reference ~ "," ~ keycode_name ~ ("," ~ profile_name)? ~ ")" } -// Rule 4.5: OSL(n) - One-Shot Layer -osl_action = { ^"OSL" ~ "(" ~ layer_reference ~ ")" } - // Rule 4.7: TT(n) - Layer Activate or Tap Toggle tt_action = { ^"TT" ~ "(" ~ layer_reference ~ ")" } @@ -84,7 +78,7 @@ to_action = { ^"TO" ~ "(" ~ layer_reference ~ ")" } // Grouping for Layer Actions layer_action = _{ df_action | mo_action | lm_action | lt_action | - osl_action | tt_action | tg_action | to_action + tt_action | tg_action | to_action } // Rule 5: MT(key, modifier) - Modifier Tap-Hold @@ -102,21 +96,16 @@ morse_action = { (^"TD" | ^"MORSE") ~ "(" ~ number ~ ")" } // Rule 9: Macro(n) - Trigger Macro trigger_macro_action = { ^"MACRO" ~ "(" ~ number ~ ")" } -// boolean literal for SK optional args -boolean = @{ ^"true" | ^"false" } - // bracketed modifier list for SK keep parameter: [LAlt] or [LAlt|LShift] or [] modifier_keep_list = { "[" ~ modifier_combination ~ "]" | "[" ~ "]" } -// SK(key, [keep], max_repeat?, timeout_ms?, exit_on_layer_change?) — StickyKey +// SK(key, [mods]) | SK(modifier) | SK(MO(n)) sk_action = { - ^"SK" ~ "(" ~ - keycode_name ~ "," ~ - modifier_keep_list ~ - ("," ~ number)? ~ - ("," ~ number)? ~ - ("," ~ boolean)? ~ - ")" + ^"SK" ~ "(" ~ ( + layer_action // SK(MO(n)) — layer shape + | (keycode_name ~ "," ~ modifier_keep_list) // SK(key, [mods]) — tap-key shape + | modifier_combination // SK(LGui) — pure-mod shape + ) ~ ")" } // --- Top Level Rules --- @@ -124,7 +113,7 @@ sk_action = { // 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 | osm_action | layer_action | mt_action | th_action | shifted_action | morse_action | trigger_macro_action | sk_action | no_action | transparent_action | simple_keycode + wm_action | layer_action | mt_action | th_action | shifted_action | morse_action | trigger_macro_action | sk_action | no_action | transparent_action | simple_keycode } // The entire key map string: Start, zero or more key actions, End. diff --git a/rmk-config/src/layout.rs b/rmk-config/src/layout.rs index 5abd88011..198f963e6 100644 --- a/rmk-config/src/layout.rs +++ b/rmk-config/src/layout.rs @@ -391,11 +391,6 @@ impl KeyboardTomlConfig { key_action_sequence.push(action); } - Rule::osm_action => { - let action = inner_pair.as_str().to_string(); - key_action_sequence.push(action); - } - Rule::sk_action => { let action = inner_pair.as_str().to_string(); key_action_sequence.push(action); @@ -420,13 +415,6 @@ impl KeyboardTomlConfig { key_action_sequence.push(Self::layer_name_resolver("LT", inner_pair, layer_names)?); //"LT(".to_owned() + &Self::layer_name_resolver(inner_pair, layer_names)? + ")"); } - Rule::osl_action => { - key_action_sequence.push(Self::layer_name_resolver( - "OSL", - inner_pair, - layer_names, - )?); - } Rule::tt_action => { key_action_sequence.push(Self::layer_name_resolver("TT", inner_pair, layer_names)?); } diff --git a/rmk-config/src/lib.rs b/rmk-config/src/lib.rs index 11011565b..d248337f7 100644 --- a/rmk-config/src/lib.rs +++ b/rmk-config/src/lib.rs @@ -610,10 +610,15 @@ pub(crate) struct TriLayerConfig { #[derive(Clone, Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] pub struct StickyKeyConfig { + /// Timeout before an unused sticky key auto-releases (e.g., "1000ms", "1s"). Default 1s. pub timeout: Option, + /// Pure-modifier sticky keys only: activate on the next key press instead of release. Default false. pub activate_on_keypress: Option, + /// Pure-modifier sticky keys only: release the modifier as soon as the next key is pressed. Default false. pub quick_release: Option, + /// Max number of held keys the sticky modifier applies to; 0 = unlimited. Default 0. pub max_repeat: Option, + /// Whether a layer change releases the sticky key. Default false (it survives layer changes). pub release_on_layer_change: Option, } diff --git a/rmk-macro/src/codegen/action_parser.rs b/rmk-macro/src/codegen/action_parser.rs index c01a9dbde..475c98776 100644 --- a/rmk-macro/src/codegen/action_parser.rs +++ b/rmk-macro/src/codegen/action_parser.rs @@ -198,92 +198,67 @@ pub(crate) fn parse_key( ::rmk::mo!(#layer) } } - s if s.to_lowercase().starts_with("osl(") => { - let layer = get_number(s.clone(), s.get(0..4).unwrap(), ")"); - quote! { - ::rmk::osl!(#layer) - } - } - s if s.to_lowercase().starts_with("osm(") => { - let prefix = s.get(0..4).unwrap(); - if let Some(internal) = s.trim_start_matches(prefix).strip_suffix(")") { - let modifiers = parse_modifiers(internal); - - 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" - ); - } - quote! { - ::rmk::osm!(#modifiers) - } - } else { - panic!( - "\n\u{274c} keyboard.toml: OSM(modifier) invalid, please check the documentation: https://rmk.rs/docs/features/configuration/layout.html" - ); - } - } s if s.to_lowercase().starts_with("sk(") => { let prefix = s.get(0..3).unwrap(); if let Some(internal) = s.trim_start_matches(prefix).strip_suffix(")") { - // Parse: "Tab, [LAlt]" or "Tab, [LAlt], 5, 3000, true" - let bracket_start = internal.find('[').unwrap_or_else(|| { - panic!( - "\n\u{274c} keyboard.toml: SK requires a bracketed keep-mod list. \ - Usage: SK(Tab, [LAlt]) or SK(Tab, [LCtrl|LShift], 3, 2000, true)" - ) - }); - let bracket_end = internal.find(']').unwrap_or_else(|| { - panic!( - "\n\u{274c} keyboard.toml: SK has unclosed '['. \ - Usage: SK(Tab, [LAlt])" - ) - }); - - let key_str = internal[..bracket_start] - .trim() - .trim_end_matches(',') - .trim(); - let ident = get_key_with_alias(key_str.to_string()); - - let keep_mods_str = &internal[bracket_start + 1..bracket_end]; - let keep_modifiers = if keep_mods_str.trim().is_empty() { - ModifierCombinationMacro::new() - } else { - parse_modifiers(keep_mods_str) - }; - - let after_bracket = internal[bracket_end + 1..].trim_start_matches(',').trim(); - let optional_args: Vec<&str> = if after_bracket.is_empty() { - vec![] + let inner = internal.trim(); + let inner_lower = inner.to_lowercase(); + + if inner_lower.starts_with("mo(") { + // Layer shape: SK(MO(n)) — OSL replacement + let layer = get_number(inner.to_string(), inner.get(0..3).unwrap(), ")"); + quote! { + ::rmk::sk_layer!(#layer) + } + } else if inner.contains('[') { + // Tap-key shape: SK(key, [mods]) + let bracket_start = inner.find('[').unwrap(); + let bracket_end = inner.find(']').unwrap_or_else(|| { + panic!( + "\n\u{274c} keyboard.toml: SK has unclosed '['. \ + Usage: SK(LGui) | SK(Tab, [LAlt]) | SK(MO(n))" + ) + }); + + let key_str = inner[..bracket_start].trim().trim_end_matches(',').trim(); + let ident = get_key_with_alias(key_str.to_string()); + + let keep_mods_str = &inner[bracket_start + 1..bracket_end]; + let keep_modifiers = if keep_mods_str.trim().is_empty() { + ModifierCombinationMacro::new() + } else { + parse_modifiers(keep_mods_str) + }; + + // Legacy-tail guard: reject the old 5-positional form. + let after_bracket = inner[bracket_end + 1..].trim_start_matches(',').trim(); + if !after_bracket.is_empty() { + panic!( + "\n\u{274c} keyboard.toml: the 5-positional SK(...) form is removed; use SK(key, [mods]). max_repeat/timeout/release_on_layer_change now live in [behavior.sticky_key]." + ); + } + + quote! { + ::rmk::sk!(#ident, #keep_modifiers) + } } else { - after_bracket - .split(',') - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .collect() - }; - - let max_repeat: u16 = optional_args - .first() - .and_then(|s| s.parse().ok()) - .unwrap_or(0u16); - let timeout_ms: u16 = optional_args - .get(1) - .and_then(|s| s.parse().ok()) - .unwrap_or(0u16); - let exit_on_layer_change: bool = optional_args - .get(2) - .map(|s| s.trim() == "true") - .unwrap_or(false); - - quote! { - ::rmk::sk!(#ident, #keep_modifiers, #max_repeat, #timeout_ms, #exit_on_layer_change) + // Pure-mod shape: SK(LGui) — OSM replacement + let modifiers = parse_modifiers(inner); + + if modifiers.is_empty() { + panic!( + "\n\u{274c} keyboard.toml: SK(modifier) is not valid! \ + Usage: SK(LGui) | SK(Tab, [LAlt]) | SK(MO(n))" + ); + } + quote! { + ::rmk::sk_mod!(#modifiers) + } } } else { panic!( "\n\u{274c} keyboard.toml: SK(...) invalid. \ - Usage: SK(Tab, [LAlt]) or SK(Tab, [LCtrl|LShift], 3, 2000, true)" + Usage: SK(LGui) | SK(Tab, [LAlt]) | SK(MO(n))" ); } } @@ -550,7 +525,7 @@ pub(crate) fn parse_key( } } -/// Parse the string literal like `MO(1)`, `OSL(1)`, `TD(0)`, etc, get the number in it. +/// Parse the string literal like `MO(1)`, `TD(0)`, etc, get the number in it. /// The caller should pass the trimmed prefix and suffix fn get_number(key: String, prefix: &str, suffix: &str) -> u8 { let layer_str = key.trim_start_matches(prefix).trim_end_matches(suffix); diff --git a/rmk/src/layout_macro.rs b/rmk/src/layout_macro.rs index 9dae12c46..d947c693f 100644 --- a/rmk/src/layout_macro.rs +++ b/rmk/src/layout_macro.rs @@ -311,68 +311,70 @@ macro_rules! thp { }; } -/// Create a one-shot layer action. -/// -/// This macro creates a key that activates a layer for the next keypress only. -/// After the next key is pressed, the layer automatically deactivates. +/// Create a StickyKey tap-key action (alt-tab shape). /// /// # Parameters -/// - `$x`: Layer number (0-255) +/// - `$key`: HID keycode identifier (e.g., `Tab`, `A`) +/// - `$keep`: `ModifierCombination` held between presses /// /// # Example /// ```ignore -/// osl!(1) // Next key will be from layer 1, then return to current layer -/// osl!(2) // Next key will be from layer 2, then return to current layer +/// sk!(Tab, ModifierCombination::LALT) // SK(Tab, [LAlt]) /// ``` #[macro_export] -macro_rules! osl { - ($x: literal) => { - $crate::types::action::KeyAction::Single($crate::types::action::Action::OneShotLayer($x)) +macro_rules! sk { + ($key:ident, $keep:expr) => { + $crate::types::action::KeyAction::Single($crate::types::action::Action::StickyKey( + $crate::types::action::StickyKeyAction { + key: $crate::types::keycode::KeyCode::Hid($crate::types::keycode::HidKeyCode::$key), + keep: $keep, + layer: None, + }, + )) }; } -/// Create a one-shot modifier action. +/// Create a StickyKey pure-modifier action (one-shot modifier shape). /// -/// This macro creates a key that applies modifiers for the next keypress only. -/// They automatically deactivate if: -/// - other key that sends keyboard report is pressed, -/// - timeout has passed before next key is triggered. +/// `key` is `No` to signal the pure-mod shape. /// /// # Parameters /// - `$m`: `ModifierCombination` to apply for the next keypress /// /// # Example /// ```ignore -/// // Next key will be shifted -/// osm!(ModifierCombination::LSHIFT) -/// // Next key will have both Shift and Ctrl applied -/// osm!(ModifierCombination::LSHIFT | ModifierCombination::LCTRL) +/// sk_mod!(ModifierCombination::LSHIFT) // SK(LShift) /// ``` #[macro_export] -macro_rules! osm { - ($m: expr) => { - $crate::types::action::KeyAction::Single($crate::types::action::Action::OneShotModifier($m)) +macro_rules! sk_mod { + ($m:expr) => { + $crate::types::action::KeyAction::Single($crate::types::action::Action::StickyKey( + $crate::types::action::StickyKeyAction { + key: $crate::types::keycode::KeyCode::No, + keep: $m, + layer: None, + }, + )) }; } -/// Create a StickyKey action. +/// Create a StickyKey layer action (one-shot layer shape). /// /// # Parameters -/// - `$key`: HID keycode identifier (e.g., `Tab`, `A`) -/// - `$keep`: `ModifierCombination` held between presses -/// - `$max_repeat`: `u16` — max fires before auto-release; 0 = infinite -/// - `$timeout_ms`: `u16` — per-key timeout in ms; 0 = use global config -/// - `$exit_on_layer_change`: `bool` — release SK when any layer changes +/// - `$n`: Layer number (0-255) +/// +/// # Example +/// ```ignore +/// sk_layer!(1) // SK(MO(1)) +/// ``` #[macro_export] -macro_rules! sk { - ($key:ident, $keep:expr, $max_repeat:expr, $timeout_ms:expr, $exit_on_layer_change:expr) => { +macro_rules! sk_layer { + ($n:literal) => { $crate::types::action::KeyAction::Single($crate::types::action::Action::StickyKey( $crate::types::action::StickyKeyAction { - key: $crate::types::keycode::KeyCode::Hid($crate::types::keycode::HidKeyCode::$key), - keep: $keep, - max_repeat: $max_repeat, - timeout_ms: $timeout_ms, - exit_on_layer_change: $exit_on_layer_change, + key: $crate::types::keycode::KeyCode::No, + keep: $crate::types::modifier::ModifierCombination::new(), + layer: Some($n), }, )) }; From f19c4734f22cbdfbdc1f5f1b880f726ccd4afbeb Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Fri, 5 Jun 2026 17:21:30 -0500 Subject: [PATCH 043/119] docs(plan): record DP-1 (layer Option) and DP-3 (keep Vial 0x06) decisions; mark Tasks 1.1-1.3 progress --- .../plans/2026-06-03-sk-absorbs-oneshot-plan.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md b/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md index 017ffd821..23eb80c01 100644 --- a/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md +++ b/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md @@ -29,12 +29,14 @@ These are the spec's four open questions (Section 5 / "Open questions for the im - *(a) Tagged enum:* `StickyKeyAction` becomes `enum { Mods { keep, key, max_repeat }, Layer { layer } }`. - *(b) Added optional field:* keep the struct, add `layer: Option`; `Some` = layer shape, `None` = mod/tap-key shape with `key == No` distinguishing pure-mod from tap-key. - **Recommendation:** (b) added `layer: Option` — smaller diff to the existing struct, derives (`MaxSize`/`Serialize`/`Deserialize`/`Schema`) carry over unchanged, and the engine already needs the `key == No` branch for the terminating-key rule, so the three-way match is `(layer, key)` → cheap. Revisit if the engine dispatch reads cleaner as an enum once the latch is merged. + - **DECISION (2026-06-04, confirmed by user):** **(b) — add `layer: Option`.** Grounding: every action-parameter type in rmk-types is a plain struct (`Combo`, `Morse`, `Fork`, `EncoderAction`, `StickyKeyAction`); none is a tagged enum — only the top-level `Action` is. Keeping `StickyKeyAction` a struct matches that convention and `MorseProfile`'s `Option<...>` sub-setting pattern. The engine currently reads `params.{key,keep,...}` as plain field accesses; adding one optional field keeps those and adds a single `(layer, key)` dispatch, whereas an enum would force every access site to a `match` (larger, riskier diff). Both encodings break the postcard wire identically (DP-4), so the struct option is strictly the lower-churn / lower-bug-risk path. Field set: `{ key: KeyCode, keep: ModifierCombination, layer: Option }` — `Some(n)` = layer shape; `None` + `key == No` = pure-mod; `None` + `key != No` = tap-key. - **DP-2 — Home of the unified latch (resolve in Stage 2, Task 2.1).** Fold `oneshot.rs` into `sticky_key.rs`, or create a new shared module (e.g. `keyboard/latch.rs`). - **Recommendation:** fold into `sticky_key.rs` and delete `oneshot.rs`. The spec's file map predicts `oneshot.rs` "likely shrinks to nothing or merges into `sticky_key.rs`." A new module name would orphan the established `sticky_key` mod path used across `keyboard.rs`. - **DP-3 — Vial one-shot-timeout runtime path (resolve in Stage 1, Task 1.4).** The unified `timeout` either keeps a Vial runtime-set path (`SettingKey::OneShotTimeout = 0x06`, handlers at `rmk/src/host/via/vial.rs:127-130` and `184-186`, storage `FlashOperationMessage::OneShotTimeout` at `rmk/src/storage/mod.rs:145`) or drops it with the OSM keycodes. - **Recommendation:** **keep** the Vial setting wire-compatible but re-point it at the unified `sticky_key` timeout (rename the internal `one_shot_timeout` storage field / accessor to `sticky_key_timeout`, leave `SettingKey` numeric value `0x06` and the protocol bytes unchanged). Dropping a Vial `SettingKey` is itself a Vial-protocol break; this round we are already breaking the keymap wire (DP-4) and should not stack a second protocol break unless the user wants it. Confirm with user. + - **DECISION (2026-06-04, confirmed by user):** **KEEP, re-pointed.** Leave `SettingKey::OneShotTimeout = 0x06` and its protocol bytes unchanged; rename the internal storage field/accessor from `one_shot_timeout` → `sticky_key_timeout` so Vial still live-sets the unified timeout. Zero Vial-protocol break (the keymap-wire break in DP-4 stays the only one this round). Chosen explicitly for minimum breakage. - **DP-4 — Wire/Vial/storage migration impact (resolve in Stage 5, post-engine).** The `StickyKeyAction` struct/postcard change plus removal of `Action::OneShotModifier`/`OneShotLayer` variants is a wire-order break. Whether it invalidates keymaps stored in flash and Vial state — and what migration is needed (reflash? Vial re-sync? storage schema bump?) — is **TBD after the engine works**, likely only visible during hardware testing. **Do not assume harmless.** Stage 5 has an explicit evaluation task; the finding must be recorded before any move toward PR #859. @@ -161,9 +163,9 @@ git commit -m "docs: characterize OSM/OSL/SK behavior parity catalogue (Stage 0) **Files:** - Modify: `rmk/src/config/behavior.rs:85-97` (and `BehaviorConfig` 11-22) -- [ ] **Step 1: Re-read the file** to confirm current line numbers for `OneShotConfig`, `OneShotModifiersConfig`, `StickyKeyConfig`, and `BehaviorConfig`. +- [x] **Step 1: Re-read the file** to confirm current line numbers for `OneShotConfig`, `OneShotModifiersConfig`, `StickyKeyConfig`, and `BehaviorConfig`. -- [ ] **Step 2: Replace the three config structs with one.** New `StickyKeyConfig`: +- [x] **Step 2: Replace the three config structs with one.** New `StickyKeyConfig`: ```rust /// Unified sticky-key configuration. Absorbs the former one_shot, one_shot_modifiers, /// and sticky_key tables. `activate_on_keypress`/`quick_release` are honored only for @@ -195,11 +197,11 @@ impl Default for StickyKeyConfig { } ``` -- [ ] **Step 3: Update `BehaviorConfig`.** Remove the `one_shot: OneShotConfig` and `one_shot_modifiers: OneShotModifiersConfig` fields; keep only `sticky_key: StickyKeyConfig`. Delete `OneShotConfig` and `OneShotModifiersConfig` struct defs. Fix the `Default` impl of `BehaviorConfig` accordingly. +- [x] **Step 3: Update `BehaviorConfig`.** Remove the `one_shot: OneShotConfig` and `one_shot_modifiers: OneShotModifiersConfig` fields; keep only `sticky_key: StickyKeyConfig`. Delete `OneShotConfig` and `OneShotModifiersConfig` struct defs. Fix the `Default` impl of `BehaviorConfig` accordingly. -- [ ] **Step 4: Build the config crate.** Run: `cargo build -p rmk --no-default-features --features=split,vial,storage,async_matrix,_ble` and fix any references that read `behavior.one_shot*` (you'll find them in `keymap.rs`, `storage/mod.rs`, the engine — expect failures; resolve only the config-crate-local ones now, defer engine ones to Stage 2 by leaving TODO and a temporary shim if needed). Expected: incremental compile errors that map the blast radius. +- [x] **Step 4: Build the config crate.** Run: `cargo build -p rmk --no-default-features --features=split,vial,storage,async_matrix,_ble` and fix any references that read `behavior.one_shot*` (you'll find them in `keymap.rs`, `storage/mod.rs`, the engine — expect failures; resolve only the config-crate-local ones now, defer engine ones to Stage 2 by leaving TODO and a temporary shim if needed). Expected: incremental compile errors that map the blast radius. -- [ ] **Step 5: Commit.** +- [x] **Step 5: Commit.** ```bash git add rmk/src/config/behavior.rs git commit -m "feat(config): collapse one_shot/one_shot_modifiers/sticky_key into unified StickyKeyConfig" From 28416fa5724567361a20a1e6b487ad1322fdb15a Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Fri, 5 Jun 2026 21:03:36 -0500 Subject: [PATCH 044/119] feat(wire): StickyKeyAction carries layer payload; remove OneShotModifier/OneShotLayer variants Reshape StickyKeyAction to { key, keep, layer: Option } (DP-1): Some(n)=layer, None+key==No=pure-mod, None+key!=No=tap-key. Remove the OneShotModifier/OneShotLayer Action variants and their via keycode_convert arms/tests (OneShotKey kept). DP-3 (keep, re-pointed): leave Vial SettingKey::OneShotTimeout=0x06 byte-identical; rename internal field/accessors one_shot_timeout -> sticky_key_timeout across keymap, host/context, via/vial, and storage, now reading behavior.sticky_key.timeout. Wire format changed: regenerated endpoint_keys_{base,bulk} snapshots under --features host (the CI feature set). rmk-types green; rmk engine intentionally pending Stage 2. --- rmk-types/src/action/mod.rs | 24 ++++------ .../rmk/snapshots/endpoint_keys_base.snap | 20 ++++----- .../rmk/snapshots/endpoint_keys_bulk.snap | 12 ++--- rmk/src/host/context.rs | 9 ++-- rmk/src/host/via/keycode_convert.rs | 45 ------------------- rmk/src/host/via/vial.rs | 6 +-- rmk/src/keymap.rs | 8 +--- rmk/src/storage/mod.rs | 14 +++--- 8 files changed, 42 insertions(+), 96 deletions(-) diff --git a/rmk-types/src/action/mod.rs b/rmk-types/src/action/mod.rs index 0f949a1ff..29fb22d3c 100644 --- a/rmk-types/src/action/mod.rs +++ b/rmk-types/src/action/mod.rs @@ -36,17 +36,15 @@ use crate::steno::StenoKey; #[cfg_attr(feature = "defmt", derive(defmt::Format))] #[cfg_attr(feature = "rmk_protocol", derive(Schema))] pub struct StickyKeyAction { - /// Key sent on each SK press. + /// Key sent on each SK press. `KeyCode::No` selects the pure-mod (OSM) shape + /// when `layer` is `None`; otherwise it's the tap-key (alt-tab) shape. pub key: KeyCode, - /// Modifiers held between presses (0 = none). + /// Modifiers held between presses (0 = none). Unused for the layer (OSL) shape. pub keep: ModifierCombination, - /// Maximum presses before auto-release; 0 = infinite. - /// Fires key on presses 1..=max_repeat, deactivates silently on press max_repeat+1. - pub max_repeat: u16, - /// Per-key timeout in ms; 0 = use global BehaviorConfig default. - pub timeout_ms: u16, - /// Release SK when any layer activates or deactivates. - pub exit_on_layer_change: bool, + /// `Some(n)` = one-shot-layer (OSL) shape activating layer `n`. + /// `None` + `key == KeyCode::No` = pure-mod (OSM) shape. + /// `None` + `key != KeyCode::No` = tap-key (alt-tab) shape. + pub layer: Option, } /// A single basic action that a keyboard can execute. @@ -79,10 +77,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(KeyCode), /// Actions for controlling lights @@ -93,8 +87,8 @@ pub enum Action { Special(SpecialKey), /// User Keys User(u8), - /// Sticky key: sends modifier + key on each press, holds modifiers between presses. - /// Supports max_repeat, per-key timeout, and conditional exit on layer change. + /// Sticky key: a unified one-shot action whose shape (pure-mod, tap-key, or + /// one-shot-layer) is determined by the [`StickyKeyAction`] payload. StickyKey(StickyKeyAction), /// A Plover HID stenography key. Press/release of this key updates the /// in-progress steno chord; on first release the accumulated chord is diff --git a/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_base.snap b/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_base.snap index b670edd4b..c5dff11d6 100644 --- a/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_base.snap +++ b/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_base.snap @@ -8,22 +8,22 @@ behavior/get REQ 79 40 45 f9 6e 78 ce 15 RESP ac 59 82 ee ea 41 6c 64 behavior/set REQ c0 6d 36 93 9c 5a 7a b0 RESP 92 d6 0a 5d 06 93 e2 17 -combo/get REQ 81 6e 51 70 26 48 4d 13 RESP f1 99 c1 35 e9 84 6a db -combo/set REQ e1 e8 9a ee c6 51 d3 87 RESP 2c 9b 2b 68 fe 35 21 25 +combo/get REQ 81 6e 51 70 26 48 4d 13 RESP ad bb e5 66 68 fc a2 76 +combo/set REQ 5d 58 4e 1a b3 62 e0 40 RESP 2c 9b 2b 68 fe 35 21 25 conn/set_type REQ 59 5c 7b 51 0e ff d7 12 RESP 8f e7 08 b9 4d 3f 68 d5 conn/type REQ 4d f1 b2 e7 8d ec 46 a0 RESP 02 58 66 87 39 d7 b5 b5 -encoder/get REQ 4c 0d e1 c9 58 89 4b 52 RESP 8e 10 77 70 ae f6 a7 74 -encoder/set REQ 60 2b 3c 0f 87 68 c7 f3 RESP ea a8 3d 9e dd 6e 67 c7 -fork/get REQ 21 8f a2 dc 1f e8 3a 1b RESP d9 a9 03 20 4c 75 a1 cf -fork/set REQ 29 f1 6f 01 e2 4d d8 9d RESP 0c 8a ca c0 83 a9 dc be +encoder/get REQ 4c 0d e1 c9 58 89 4b 52 RESP 92 d6 f5 1b a3 21 c7 3c +encoder/set REQ 0c 2a f3 5c 2d 5b c6 9b RESP ea a8 3d 9e dd 6e 67 c7 +fork/get REQ 21 8f a2 dc 1f e8 3a 1b RESP 1d 78 43 76 c1 18 11 67 +fork/set REQ 2d 29 59 14 f9 b4 70 9e RESP 0c 8a ca c0 83 a9 dc be keymap/default_layer REQ 3b 9b e3 4e c2 47 56 de RESP 79 3f e3 4e c2 11 56 de -keymap/get REQ 9c ce 0f 70 d3 94 0f fb RESP d7 9d 71 ea c0 86 69 5a -keymap/set REQ 6c ba 56 c2 1c 7c 6e a7 RESP a7 01 c4 70 bb ea d3 b9 +keymap/get REQ 9c ce 0f 70 d3 94 0f fb RESP 7f 28 75 9b 67 4c 80 2b +keymap/set REQ 30 09 7f 61 b7 d6 1c c9 RESP a7 01 c4 70 bb ea d3 b9 keymap/set_default_layer REQ 6c 6c 14 62 2a 07 9d b3 RESP 2b 67 98 d3 da 4b f3 98 macro/get REQ 0a 43 62 d5 55 40 09 9d RESP 85 2c 14 7a 94 7c e9 f1 macro/set REQ f7 e6 c3 bd 4c 03 a5 e7 RESP 4e 8c 8b 52 00 fa 68 03 -morse/get REQ f5 0c 0d f1 f0 6b 74 e2 RESP 76 58 a9 5b fe 8d b0 19 -morse/set REQ de d3 27 4d 00 0e 63 64 RESP 40 c6 f5 18 aa 72 42 a5 +morse/get REQ f5 0c 0d f1 f0 6b 74 e2 RESP 75 2e e2 fd 5e 26 42 a4 +morse/set REQ 5d 84 bf 04 56 58 fe cb RESP 40 c6 f5 18 aa 72 42 a5 status/layer/get REQ d7 6a 8a 1b 7b bb be 32 RESP 75 45 8a 1b 7b a5 be 32 status/matrix/get REQ 4b ae a1 68 0d d9 90 44 RESP 63 13 83 85 e4 e0 0b 36 sys/bootloader REQ 29 a1 89 88 85 d6 a1 26 RESP 29 a1 89 88 85 d6 a1 26 diff --git a/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_bulk.snap b/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_bulk.snap index 1f67e35c4..a56e73d74 100644 --- a/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_bulk.snap +++ b/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_bulk.snap @@ -6,9 +6,9 @@ # UPDATE_SNAPSHOTS=1 cargo test -p rmk-types --features rmk_protocol # Format: REQ <8-byte hex> RESP <8-byte hex> -combo/bulk_get REQ 52 b1 93 5f 86 d5 96 53 RESP 99 8a 7a 05 7b 22 4b f8 -combo/bulk_set REQ 7f 1a c0 79 72 e4 d5 c1 RESP 83 3b 2e b1 a0 96 2f 3d -keymap/bulk_get REQ 11 21 e6 78 15 e5 8a ca RESP 6f 52 99 0c e4 fe 9b 08 -keymap/bulk_set REQ c1 7c e9 3d 96 a2 0c 69 RESP 42 98 cc 60 91 e5 c5 f3 -morse/bulk_get REQ 46 e8 ff eb aa ed 5f db RESP 8e 17 e3 36 0b 09 8d a5 -morse/bulk_set REQ 98 1c 43 26 ee 0a de 23 RESP f7 57 bd 43 2b 0b ec b8 +combo/bulk_get REQ 52 b1 93 5f 86 d5 96 53 RESP 55 8b 20 7b 5f 52 b0 6d +combo/bulk_set REQ 83 cd e3 86 65 86 9d bc RESP 83 3b 2e b1 a0 96 2f 3d +keymap/bulk_get REQ 11 21 e6 78 15 e5 8a ca RESP 77 a0 78 c0 bd 05 33 f7 +keymap/bulk_set REQ 19 d8 41 49 38 f9 de 0c RESP 42 98 cc 60 91 e5 c5 f3 +morse/bulk_get REQ 46 e8 ff eb aa ed 5f db RESP 6d bd c6 e3 20 99 94 cc +morse/bulk_set REQ 6f 2d d4 44 6f 75 ad 1f RESP f7 57 bd 43 2b 0b ec b8 diff --git a/rmk/src/host/context.rs b/rmk/src/host/context.rs index 15d0bb3b6..d27327c56 100644 --- a/rmk/src/host/context.rs +++ b/rmk/src/host/context.rs @@ -225,8 +225,8 @@ impl<'a> KeyboardContext<'a> { self.keymap.combo_timeout() } - pub fn one_shot_timeout(&self) -> Duration { - self.keymap.one_shot_timeout() + pub fn sticky_key_timeout(&self) -> Duration { + self.keymap.sticky_key_timeout() } pub fn tap_interval(&self) -> u16 { @@ -253,8 +253,9 @@ impl<'a> KeyboardContext<'a> { FLASH_CHANNEL.send(FlashOperationMessage::ComboTimeout(ms)).await; } - pub async fn set_one_shot_timeout(&self, ms: u16) { - self.keymap.set_one_shot_timeout(Duration::from_millis(ms as u64)); + pub async fn set_sticky_key_timeout(&self, ms: u16) { + self.keymap.set_sticky_key_timeout(Duration::from_millis(ms as u64)); + // `OneShotTimeout` variant label is kept for storage-format stability; it persists the sticky_key timeout. #[cfg(feature = "storage")] FLASH_CHANNEL.send(FlashOperationMessage::OneShotTimeout(ms)).await; } diff --git a/rmk/src/host/via/keycode_convert.rs b/rmk/src/host/via/keycode_convert.rs index a8615e93d..215e7402f 100644 --- a/rmk/src/host/via/keycode_convert.rs +++ b/rmk/src/host/via/keycode_convert.rs @@ -58,15 +58,6 @@ pub(crate) fn to_via_keycode(key_action: KeyAction) -> u16 { // 0x0 // } } - Action::OneShotLayer(l) => { - // One-shot layer - if l < 16 { 0x5280 | l as u16 } else { 0x0000 } - } - Action::OneShotModifier(m) => { - // One-shot modifier - let modifier_bits = m.into_packed_bits(); - 0x52A0 | modifier_bits as u16 - } Action::LayerOnWithModifier(l, m) => { if l < 16 { 0x5000 | ((l as u16) << 5) | ((m.into_packed_bits() & 0b11111) as u16) @@ -185,16 +176,6 @@ pub(crate) fn from_via_keycode(via_keycode: u16) -> KeyAction { let layer = via_keycode as u8 & 0x0F; KeyAction::Single(Action::LayerToggle(layer)) } - 0x5280..=0x529F => { - // One-shot layer - let layer = via_keycode as u8 & 0xF; - KeyAction::Single(Action::OneShotLayer(layer)) - } - 0x52A0..=0x52BF => { - // One-shot modifier - let m = ModifierCombination::from_packed_bits((via_keycode & 0x1F) as u8); - KeyAction::Single(Action::OneShotModifier(m)) - } 0x52C0..=0x52DF => { // TODO: Layer tap toggle warn!("Layer tap toggle {:#X} not supported", via_keycode); @@ -276,22 +257,6 @@ mod test { let via_keycode = 0x5223; assert_eq!(KeyAction::Single(Action::LayerOn(3)), from_via_keycode(via_keycode)); - // OSL(3) - let via_keycode = 0x5283; - assert_eq!( - KeyAction::Single(Action::OneShotLayer(3)), - from_via_keycode(via_keycode) - ); - - // OSM RCtrl - let via_keycode = 0x52B1; - assert_eq!( - KeyAction::Single(Action::OneShotModifier(ModifierCombination::new_from( - true, false, false, false, true - ))), - from_via_keycode(via_keycode) - ); - // LCtrl(A) -> WithModifier(A) let via_keycode = 0x104; assert_eq!( @@ -465,16 +430,6 @@ mod test { let a = KeyAction::Single(Action::LayerOn(3)); assert_eq!(0x5223, to_via_keycode(a)); - // OSL(3) - let a = KeyAction::Single(Action::OneShotLayer(3)); - assert_eq!(0x5283, to_via_keycode(a)); - - // OSM RCtrl - let a = KeyAction::Single(Action::OneShotModifier(ModifierCombination::new_from( - true, false, false, false, true, - ))); - assert_eq!(0x52B1, to_via_keycode(a)); - // LCtrl(A) -> WithModifier(A) let a = KeyAction::Single(Action::KeyWithModifier( KeyCode::Hid(HidKeyCode::A), diff --git a/rmk/src/host/via/vial.rs b/rmk/src/host/via/vial.rs index 0f7c01000..4053f4994 100644 --- a/rmk/src/host/via/vial.rs +++ b/rmk/src/host/via/vial.rs @@ -125,8 +125,8 @@ pub(crate) async fn process_vial<'a>( LittleEndian::write_u16(&mut report.input_data[1..3], tapping_term); } SettingKey::OneShotTimeout => { - let one_shot_timeout = ctx.one_shot_timeout().as_millis() as u16; - LittleEndian::write_u16(&mut report.input_data[1..3], one_shot_timeout); + let sticky_key_timeout = ctx.sticky_key_timeout().as_millis() as u16; + LittleEndian::write_u16(&mut report.input_data[1..3], sticky_key_timeout); } SettingKey::TapInterval => { let tap_interval = ctx.tap_interval(); @@ -183,7 +183,7 @@ pub(crate) async fn process_vial<'a>( } SettingKey::OneShotTimeout => { let timeout_time = u16::from_le_bytes([report.output_data[4], report.output_data[5]]); - ctx.set_one_shot_timeout(timeout_time).await; + ctx.set_sticky_key_timeout(timeout_time).await; } SettingKey::TapInterval => { let tap_interval = u16::from_le_bytes([report.output_data[4], report.output_data[5]]); diff --git a/rmk/src/keymap.rs b/rmk/src/keymap.rs index f7228c74d..275187f4d 100644 --- a/rmk/src/keymap.rs +++ b/rmk/src/keymap.rs @@ -508,10 +508,6 @@ impl<'a> KeyMap<'a> { self.inner.borrow().behavior.combo.prior_idle_time } - pub(crate) fn one_shot_timeout(&self) -> Duration { - self.inner.borrow().behavior.one_shot.timeout - } - pub(crate) fn sticky_key_timeout(&self) -> Duration { self.inner.borrow().behavior.sticky_key.timeout } @@ -558,8 +554,8 @@ impl<'a> KeyMap<'a> { self.inner.borrow_mut().behavior.combo.timeout = timeout; } - pub(crate) fn set_one_shot_timeout(&self, timeout: Duration) { - self.inner.borrow_mut().behavior.one_shot.timeout = timeout; + pub(crate) fn set_sticky_key_timeout(&self, timeout: Duration) { + self.inner.borrow_mut().behavior.sticky_key.timeout = timeout; } pub(crate) fn set_tap_interval(&self, interval: u16) { diff --git a/rmk/src/storage/mod.rs b/rmk/src/storage/mod.rs index f6be667f2..e0b8a253a 100644 --- a/rmk/src/storage/mod.rs +++ b/rmk/src/storage/mod.rs @@ -141,7 +141,7 @@ pub(crate) enum FlashOperationMessage { ConnectionType(ConnectionType), // Timeout time for combos ComboTimeout(u16), - // Timeout time for one-shot keys + // Timeout time for sticky keys (variant name kept for storage-format stability) OneShotTimeout(u16), // Interval for tap actions TapInterval(u16), @@ -306,8 +306,8 @@ pub(crate) struct BehaviorConfig { // Timeout time for combos pub(crate) combo_timeout: u16, - // Timeout time for one-shot keys - pub(crate) one_shot_timeout: u16, + // Timeout time for sticky (one-shot) keys + pub(crate) sticky_key_timeout: u16, // Interval for tap actions pub(crate) tap_interval: u16, // Interval for tapping capslock. @@ -333,7 +333,7 @@ impl From<&config::BehaviorConfig> for StorageData { prior_idle_time: behavior.morse.prior_idle_time.as_millis() as u16, morse_default_profile: behavior.morse.default_profile, combo_timeout: behavior.combo.timeout.as_millis() as u16, - one_shot_timeout: behavior.one_shot.timeout.as_millis() as u16, + sticky_key_timeout: behavior.sticky_key.timeout.as_millis() as u16, tap_interval: behavior.tap.tap_interval, tap_capslock_interval: behavior.tap.tap_capslock_interval, }) @@ -511,7 +511,7 @@ impl { update_storage_field!(&mut self.flash, &mut self.buffer, BehaviorConfig, combo_timeout) } - FlashOperationMessage::OneShotTimeout(one_shot_timeout) => { - update_storage_field!(&mut self.flash, &mut self.buffer, BehaviorConfig, one_shot_timeout) + FlashOperationMessage::OneShotTimeout(sticky_key_timeout) => { + update_storage_field!(&mut self.flash, &mut self.buffer, BehaviorConfig, sticky_key_timeout) } FlashOperationMessage::TapInterval(tap_interval) => { update_storage_field!(&mut self.flash, &mut self.buffer, BehaviorConfig, tap_interval) From cefc6e1b54dcb096c50352f413a542be2abf8ab1 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Fri, 5 Jun 2026 23:32:44 -0500 Subject: [PATCH 045/119] test: migrate one-shot/sticky tests to SK(...) syntax and unified config (Stage 1) Mechanically migrate both test files off the removed OSM/OSL macros and the 5-positional SK form: - osm!(mods) -> sk_mod!(mods), osl!(n) -> sk_layer!(n) - sk!(key, mods, max_repeat, timeout, exit) -> sk!(key, mods) - OneShotConfig/OneShotModifiersConfig -> unified StickyKeyConfig on BehaviorConfig.sticky_key; per-key max_repeat/exit intent moved to the global config the helper builds (release_on_layer_change / max_repeat). - Delete the per-key-timeout test/const/helper (capability deferred this round). Behavior assertions and test names are unchanged (they are the Stage 2/3 gates). The test crate cannot be compile-verified yet because the engine (keyboard.rs/ oneshot.rs/sticky_key.rs/keymap.rs) is intentionally pending Stage 2; that verification merges into the Stage 2 gate. --- rmk/tests/keyboard_one_shot_test.rs | 80 ++++++++++---------- rmk/tests/keyboard_sticky_key_test.rs | 101 ++++++++------------------ 2 files changed, 66 insertions(+), 115 deletions(-) diff --git a/rmk/tests/keyboard_one_shot_test.rs b/rmk/tests/keyboard_one_shot_test.rs index b169d1381..3db911379 100644 --- a/rmk/tests/keyboard_one_shot_test.rs +++ b/rmk/tests/keyboard_one_shot_test.rs @@ -1,14 +1,14 @@ pub mod common; use embassy_time::Duration; -use rmk::config::{BehaviorConfig, OneShotModifiersConfig}; +use rmk::config::{BehaviorConfig, StickyKeyConfig}; use rmk::types::modifier::ModifierCombination; mod one_shot_test { - use rmk::config::{OneShotConfig, PositionalConfig}; + use rmk::config::PositionalConfig; use rmk::keyboard::Keyboard; use rmk::types::action::KeyAction; - use rmk::{k, osl, osm, th, wm}; + use rmk::{k, sk_layer, sk_mod, th, wm}; use super::*; use crate::common::{KC_LCTRL, KC_LGUI, KC_LSHIFT, wrap_keymap}; @@ -20,16 +20,16 @@ mod one_shot_test { const KEYMAP: [[[KeyAction; 6]; 1]; 2] = [ [[ // Layer 0 - osm!(ModifierCombination::new_from(false, false, false, true, false)), // OSM LShift - osl!(1), // OSL Layer 1 + sk_mod!(ModifierCombination::new_from(false, false, false, true, false)), // OSM LShift + sk_layer!(1), // OSL Layer 1 k!(A), // Regular key A th!(B, C), // Tap-hold key B, C - osm!(ModifierCombination::new_from(false, false, false, false, true)), // OSM LCtrl + sk_mod!(ModifierCombination::new_from(false, false, false, false, true)), // OSM LCtrl wm!(B, ModifierCombination::new_from(false, true, false, false, false)), // WM B with LGUI ]], [[ // Layer 1 - osm!(ModifierCombination::new_from(false, false, false, true, true)), // OSM LShift + LCtrl + sk_mod!(ModifierCombination::new_from(false, false, false, true, true)), // OSM LShift + LCtrl k!(No), // No action k!(C), // Layer 1 key C k!(D), // Layer 1 key D @@ -50,9 +50,9 @@ mod one_shot_test { Keyboard::new(wrap_keymap(KEYMAP, per_key_config, behavior_config)) } - fn create_test_keyboard_with_one_shot_modifiers_config(config: OneShotModifiersConfig) -> Keyboard<'static> { + fn create_test_keyboard_with_sticky_key_config(config: StickyKeyConfig) -> Keyboard<'static> { let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig { - one_shot_modifiers: config, + sticky_key: config, ..BehaviorConfig::default() })); let per_key_config: &'static PositionalConfig<1, 6> = Box::leak(Box::new(PositionalConfig::default())); @@ -109,9 +109,9 @@ mod one_shot_test { key_sequence_test! { keyboard: create_test_keyboard_with_behavior_config( BehaviorConfig { - one_shot: OneShotConfig { + sticky_key: StickyKeyConfig { timeout: Duration::from_millis(100), - ..OneShotConfig::default() + ..StickyKeyConfig::default() }, ..BehaviorConfig::default() } @@ -328,9 +328,9 @@ mod one_shot_test { #[test] fn test_osm_activate_on_keypress() { key_sequence_test! { - keyboard: create_test_keyboard_with_one_shot_modifiers_config(OneShotModifiersConfig { + keyboard: create_test_keyboard_with_sticky_key_config(StickyKeyConfig { activate_on_keypress: true, - ..OneShotModifiersConfig::default() + ..StickyKeyConfig::default() }), sequence: [ [0, 0, true, 10], // Press OSM LShift @@ -365,9 +365,9 @@ mod one_shot_test { #[test] fn test_osm_combined_modifiers_with_activate_on_keypress() { key_sequence_test! { - keyboard: create_test_keyboard_with_one_shot_modifiers_config(OneShotModifiersConfig { + keyboard: create_test_keyboard_with_sticky_key_config(StickyKeyConfig { activate_on_keypress: true, - ..OneShotModifiersConfig::default() + ..StickyKeyConfig::default() }), sequence: [ // Press and Release OSM LShift @@ -429,12 +429,9 @@ mod one_shot_test { key_sequence_test! { keyboard: create_test_keyboard_with_behavior_config( BehaviorConfig { - one_shot: OneShotConfig { + sticky_key: StickyKeyConfig { timeout: Duration::from_millis(100), - ..OneShotConfig::default() - }, - one_shot_modifiers: OneShotModifiersConfig { - ..OneShotModifiersConfig::default() + ..StickyKeyConfig::default() }, ..BehaviorConfig::default() } @@ -516,12 +513,9 @@ mod one_shot_test { key_sequence_test! { keyboard: create_test_keyboard_with_behavior_config( BehaviorConfig { - one_shot: OneShotConfig { + sticky_key: StickyKeyConfig { timeout: Duration::from_millis(100), - ..OneShotConfig::default() - }, - one_shot_modifiers: OneShotModifiersConfig { - ..OneShotModifiersConfig::default() + ..StickyKeyConfig::default() }, ..BehaviorConfig::default() } @@ -545,9 +539,9 @@ mod one_shot_test { #[test] fn test_osm_chain_mode_basic() { key_sequence_test! { - keyboard: create_test_keyboard_with_one_shot_modifiers_config(OneShotModifiersConfig { + keyboard: create_test_keyboard_with_sticky_key_config(StickyKeyConfig { quick_release: false, - ..OneShotModifiersConfig::default() + ..StickyKeyConfig::default() }), sequence: [ [0, 0, true, 10], // Press OSM LShift @@ -566,9 +560,9 @@ mod one_shot_test { #[test] fn test_osm_chain_mode_multiple_keys() { key_sequence_test! { - keyboard: create_test_keyboard_with_one_shot_modifiers_config(OneShotModifiersConfig { + keyboard: create_test_keyboard_with_sticky_key_config(StickyKeyConfig { quick_release: false, - ..OneShotModifiersConfig::default() + ..StickyKeyConfig::default() }), sequence: [ [0, 0, true, 10], // Press OSM LShift @@ -591,10 +585,10 @@ mod one_shot_test { #[test] fn test_osm_chain_mode_activate_on_keypress() { key_sequence_test! { - keyboard: create_test_keyboard_with_one_shot_modifiers_config(OneShotModifiersConfig { + keyboard: create_test_keyboard_with_sticky_key_config(StickyKeyConfig { activate_on_keypress: true, quick_release: false, - ..OneShotModifiersConfig::default() + ..StickyKeyConfig::default() }), sequence: [ [0, 0, true, 10], // Press OSM LShift @@ -615,9 +609,9 @@ mod one_shot_test { #[test] fn test_osm_quick_release_basic() { key_sequence_test! { - keyboard: create_test_keyboard_with_one_shot_modifiers_config(OneShotModifiersConfig { + keyboard: create_test_keyboard_with_sticky_key_config(StickyKeyConfig { quick_release: true, - ..OneShotModifiersConfig::default() + ..StickyKeyConfig::default() }), sequence: [ [0, 0, true, 10], // Press OSM LShift @@ -636,9 +630,9 @@ mod one_shot_test { #[test] fn test_osm_quick_release_multiple_keys() { key_sequence_test! { - keyboard: create_test_keyboard_with_one_shot_modifiers_config(OneShotModifiersConfig { + keyboard: create_test_keyboard_with_sticky_key_config(StickyKeyConfig { quick_release: true, - ..OneShotModifiersConfig::default() + ..StickyKeyConfig::default() }), sequence: [ [0, 0, true, 10], // Press OSM LShift @@ -664,9 +658,9 @@ mod one_shot_test { #[test] fn test_osm_quick_release_combined_modifiers() { key_sequence_test! { - keyboard: create_test_keyboard_with_one_shot_modifiers_config(OneShotModifiersConfig { + keyboard: create_test_keyboard_with_sticky_key_config(StickyKeyConfig { quick_release: true, - ..OneShotModifiersConfig::default() + ..StickyKeyConfig::default() }), sequence: [ [0, 0, true, 10], // Press OSM LShift @@ -687,9 +681,9 @@ mod one_shot_test { #[test] fn test_osm_quick_release_with_wm() { key_sequence_test! { - keyboard: create_test_keyboard_with_one_shot_modifiers_config(OneShotModifiersConfig { + keyboard: create_test_keyboard_with_sticky_key_config(StickyKeyConfig { quick_release: true, - ..OneShotModifiersConfig::default() + ..StickyKeyConfig::default() }), sequence: [ [0, 0, true, 10], // Press OSM LShift @@ -710,10 +704,10 @@ mod one_shot_test { #[test] fn test_osm_quick_release_activate_on_keypress() { key_sequence_test! { - keyboard: create_test_keyboard_with_one_shot_modifiers_config(OneShotModifiersConfig { + keyboard: create_test_keyboard_with_sticky_key_config(StickyKeyConfig { activate_on_keypress: true, quick_release: true, - ..OneShotModifiersConfig::default() + ..StickyKeyConfig::default() }), sequence: [ [0, 0, true, 10], // Press OSM LShift @@ -733,10 +727,10 @@ mod one_shot_test { #[test] fn test_osm_quick_release_combined_activate_on_keypress() { key_sequence_test! { - keyboard: create_test_keyboard_with_one_shot_modifiers_config(OneShotModifiersConfig { + keyboard: create_test_keyboard_with_sticky_key_config(StickyKeyConfig { activate_on_keypress: true, quick_release: true, - ..OneShotModifiersConfig::default() + ..StickyKeyConfig::default() }), sequence: [ [0, 0, true, 10], // Press OSM LShift diff --git a/rmk/tests/keyboard_sticky_key_test.rs b/rmk/tests/keyboard_sticky_key_test.rs index 857f2ac3a..bda8afe8c 100644 --- a/rmk/tests/keyboard_sticky_key_test.rs +++ b/rmk/tests/keyboard_sticky_key_test.rs @@ -10,9 +10,9 @@ use rusty_fork::rusty_fork_test; use crate::common::{KC_LALT, KC_LCTRL, KC_LSHIFT, wrap_keymap}; -// KEYMAP +// KEYMAP (release_on_layer_change=true is set in the helper config, not per-key) // Layer 0: A B C MO(1) LShift No -// Layer 1: SK(Tab,LAlt,exit=true) SK(Tab,LCtrl,exit=true) SK(Tab,LCtrl|LShift,exit=true) Transparent Transparent No +// Layer 1: SK(Tab,LAlt) SK(Tab,LCtrl) SK(Tab,LCtrl|LShift) Transparent Transparent No const KEYMAP: [[[KeyAction; 6]; 1]; 2] = [ [[ @@ -26,54 +26,38 @@ const KEYMAP: [[[KeyAction; 6]; 1]; 2] = [ ]], [[ // Layer 1 - sk!(Tab, ModifierCombination::LALT, 0, 0, true), // col 0: SK(Tab, LAlt, exit=true) - sk!(Tab, ModifierCombination::LCTRL, 0, 0, true), // col 1: SK(Tab, LCtrl, exit=true) + sk!(Tab, ModifierCombination::LALT), // col 0: SK(Tab, LAlt) + sk!(Tab, ModifierCombination::LCTRL), // col 1: SK(Tab, LCtrl) sk!( Tab, - ModifierCombination::new_from_vals(true, true, false, false, false, false, false, false), - 0, - 0, - true - ), // col 2: SK(Tab, LCtrl|LShift, exit=true) + ModifierCombination::new_from_vals(true, true, false, false, false, false, false, false) + ), // col 2: SK(Tab, LCtrl|LShift) a!(Transparent), // col 3: Transparent a!(Transparent), // col 4: Transparent → LShift a!(No), // col 5: No ]], ]; -// KEYMAP_MAX_REPEAT: SK at col 0 has max_repeat=2 +// KEYMAP_MAX_REPEAT: used with the max_repeat=2 helper config (max_repeat is global, not per-key) const KEYMAP_MAX_REPEAT: [[[KeyAction; 6]; 1]; 2] = [ [[k!(A), k!(B), k!(C), mo!(1), k!(LShift), a!(No)]], [[ - sk!(Tab, ModifierCombination::LALT, 2, 0, false), // col 0: max_repeat=2 - sk!(Tab, ModifierCombination::LCTRL, 0, 0, false), // col 1 - sk!(Tab, ModifierCombination::LCTRL, 0, 0, false), // col 2 + sk!(Tab, ModifierCombination::LALT), // col 0 + sk!(Tab, ModifierCombination::LCTRL), // col 1 + sk!(Tab, ModifierCombination::LCTRL), // col 2 a!(Transparent), a!(Transparent), a!(No), ]], ]; -// KEYMAP_PER_KEY_TIMEOUT: SK at col 0 has 50ms per-key timeout -const KEYMAP_PER_KEY_TIMEOUT: [[[KeyAction; 6]; 1]; 2] = [ - [[k!(A), k!(B), k!(C), mo!(1), k!(LShift), a!(No)]], - [[ - sk!(Tab, ModifierCombination::LALT, 0, 50, false), // col 0: 50ms per-key timeout - sk!(Tab, ModifierCombination::LCTRL, 0, 0, false), // col 1 - sk!(Tab, ModifierCombination::LCTRL, 0, 0, false), // col 2 - a!(Transparent), - a!(Transparent), - a!(No), - ]], -]; - -// KEYMAP_NO_EXIT: SK with exit_on_layer_change=false — SK survives MO release +// KEYMAP_NO_EXIT: used with the default helper config (release_on_layer_change=false → SK survives MO release) const KEYMAP_NO_EXIT: [[[KeyAction; 6]; 1]; 2] = [ [[k!(A), k!(B), k!(C), mo!(1), k!(LShift), a!(No)]], [[ - sk!(Tab, ModifierCombination::LALT, 0, 0, false), // col 0: exit=false - sk!(Tab, ModifierCombination::LCTRL, 0, 0, false), // col 1 - sk!(Tab, ModifierCombination::LCTRL, 0, 0, false), // col 2 + sk!(Tab, ModifierCombination::LALT), // col 0 + sk!(Tab, ModifierCombination::LCTRL), // col 1 + sk!(Tab, ModifierCombination::LCTRL), // col 2 a!(Transparent), a!(Transparent), a!(No), @@ -82,27 +66,28 @@ const KEYMAP_NO_EXIT: [[[KeyAction; 6]; 1]; 2] = [ fn create_test_keyboard() -> Keyboard<'static> { static BEHAVIOR_CONFIG: static_cell::StaticCell = static_cell::StaticCell::new(); - let behavior_config = BEHAVIOR_CONFIG.init(BehaviorConfig::default()); + let behavior_config = BEHAVIOR_CONFIG.init(BehaviorConfig { + sticky_key: StickyKeyConfig { + release_on_layer_change: true, + ..StickyKeyConfig::default() + }, + ..BehaviorConfig::default() + }); static KEY_CONFIG: static_cell::StaticCell> = static_cell::StaticCell::new(); let per_key_config = KEY_CONFIG.init(PositionalConfig::default()); Keyboard::new(wrap_keymap(KEYMAP, per_key_config, behavior_config)) } fn create_test_keyboard_max_repeat() -> Keyboard<'static> { - let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig::default())); - let per_key_config: &'static PositionalConfig<1, 6> = Box::leak(Box::new(PositionalConfig::default())); - Keyboard::new(wrap_keymap(KEYMAP_MAX_REPEAT, per_key_config, behavior_config)) -} - -fn create_test_keyboard_per_key_timeout() -> Keyboard<'static> { let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig { sticky_key: StickyKeyConfig { - timeout: Duration::from_millis(100), + max_repeat: 2, + ..StickyKeyConfig::default() }, ..BehaviorConfig::default() })); let per_key_config: &'static PositionalConfig<1, 6> = Box::leak(Box::new(PositionalConfig::default())); - Keyboard::new(wrap_keymap(KEYMAP_PER_KEY_TIMEOUT, per_key_config, behavior_config)) + Keyboard::new(wrap_keymap(KEYMAP_MAX_REPEAT, per_key_config, behavior_config)) } fn create_test_keyboard_no_exit() -> Keyboard<'static> { @@ -295,6 +280,8 @@ rusty_fork_test! { keyboard: create_test_keyboard_with_behavior_config(BehaviorConfig { sticky_key: StickyKeyConfig { timeout: Duration::from_millis(100), + release_on_layer_change: true, + ..StickyKeyConfig::default() }, ..BehaviorConfig::default() }), @@ -334,6 +321,8 @@ rusty_fork_test! { keyboard: create_test_keyboard_with_behavior_config(BehaviorConfig { sticky_key: StickyKeyConfig { timeout: Duration::from_millis(100), + release_on_layer_change: true, + ..StickyKeyConfig::default() }, ..BehaviorConfig::default() }), @@ -399,39 +388,7 @@ rusty_fork_test! { }; } - /// StickyKey Test 9: Per-key timeout overrides global timeout - /// - /// Config: KEYMAP_PER_KEY_TIMEOUT (SK at col 0 has 50ms per-key timeout), global=100ms - /// - /// Sequence: - /// - Press MO(1), press SK(50ms), release SK → per-key 50ms timer starts - /// - Wait 80ms (per-key 50ms fires, global 100ms has NOT fired) - /// - Release MO(1) (SK already released by per-key timeout) - /// - Press C on layer 0 (no modifier), release C - /// - /// Expected: SK releases at 50ms (per-key), not 100ms (global) - #[test] - fn test_sk_per_key_timeout_overrides_global() { - key_sequence_test! { - keyboard: create_test_keyboard_per_key_timeout(), - sequence: [ - [0, 3, true, 10], // Press MO(1) - [0, 0, true, 10], // Press SK(Tab, LAlt, 50ms timeout) - [0, 0, false, 10], // Release SK → per-key 50ms timer starts - [0, 3, false, 80], // Wait 80ms (50ms per-key fires!), then release MO(1) - [0, 2, true, 10], // Press C on layer 0 (no modifier) - [0, 2, false, 10], // Release C - ], - expected_reports: [ - [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Alt+Tab - [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held, per-key timer starts (50ms) - [0, [0, 0, 0, 0, 0, 0]], // Per-key timeout fires at 50ms: Alt released - // MO(1) release: SK already inactive, no report - [0, [kc_to_u8!(C), 0, 0, 0, 0, 0]], // C press: no modifier - [0, [0, 0, 0, 0, 0, 0]], // C release - ] - }; - } + // per-key timeout removed this round (deferred, spec Section 4); see parity catalogue /// StickyKey Test 10: exit_on_layer_change=true — SK exits on MO release /// From 77029ec6aa5e352b3876fa40a396413264b5bfed Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Fri, 5 Jun 2026 23:33:47 -0500 Subject: [PATCH 046/119] docs(plan): mark Stage 1 complete; record gate status and carry-forward D2 (test compile deferred to Stage 2) --- .../superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md b/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md index 23eb80c01..5975d1d66 100644 --- a/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md +++ b/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md @@ -376,6 +376,12 @@ git commit -m "test: migrate one-shot/sticky tests to SK(...) syntax and unified **Stage 1 Gate:** Config/parse/macro/wire crates build; test crate compiles; `OSM(...)`/`OSL(...)`/5-positional-`SK(...)` now produce build errors. Behavior tests not yet green (engine pending). Run `cargo build` across the workspace to confirm only the engine `keyboard.rs`/`oneshot.rs`/`sticky_key.rs` arms remain to migrate. +**STAGE 1 COMPLETE (2026-06-05).** Commits: cbb75169 (1.1) · 9ae4008c (1.2) · a24b198a (1.3) · 28416fa5 (1.4) · cefc6e1b (1.5) · f19c4734 (plan DPs). Both per-task reviews (spec + code-quality) passed for every task. Gate status: +- ✅ rmk-config, rmk-macro, rmk-types build clean (rmk-types under `--features host`; snapshots regenerated for the wire-format change — base+bulk Action-carrying endpoints only). +- ✅ Exactly 10 remaining `rmk` lib errors, ALL in engine files (`keymap.rs` ×2, `keyboard/oneshot.rs` ×2, `keyboard/sticky_key.rs` ×4, `keyboard.rs` ×2) under the CI feature set `--no-default-features --features=split,vial,storage,async_matrix,_ble`. These are the Stage 2 migration targets. +- ⚠️ **CARRY-FORWARD D2 — "test crate compiles" deferred to the Stage 2 gate.** The plan assumed the engine still compiled through Stage 1, but Tasks 1.1–1.4 removed the symbols the old engine depends on, so the `rmk` lib (and therefore the test targets) cannot compile until Stage 2. The Task 1.5 test migration was verified at the symbol level only (grep-clean of `osm!`/`osl!`/`OneShotConfig`/`OneShotModifiersConfig`/`one_shot_modifiers`/>2-arg `sk!`; API-surface review; semantic per-key→global remap audited). **Stage 2 gate must compile + run both migrated test files** (`keyboard_one_shot_test.rs`, `keyboard_sticky_key_test.rs`) — that is where the migration is actually validated. +- DP-3 applied (Vial `SettingKey::OneShotTimeout = 0x06` kept byte-identical; internal `one_shot_timeout`→`sticky_key_timeout` rename across keymap/context/vial/storage). + --- ## Stage 2 — Engine: shape dispatch + absorb OSM From 1056db2d115010e4c03a333255e42dcfb15f10b4 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Sat, 6 Jun 2026 07:36:02 -0500 Subject: [PATCH 047/119] docs(plan): record DP-2 (fold oneshot.rs into sticky_key.rs, delete oneshot.rs) --- docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md b/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md index 5975d1d66..e2f0c16db 100644 --- a/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md +++ b/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md @@ -33,6 +33,7 @@ These are the spec's four open questions (Section 5 / "Open questions for the im - **DP-2 — Home of the unified latch (resolve in Stage 2, Task 2.1).** Fold `oneshot.rs` into `sticky_key.rs`, or create a new shared module (e.g. `keyboard/latch.rs`). - **Recommendation:** fold into `sticky_key.rs` and delete `oneshot.rs`. The spec's file map predicts `oneshot.rs` "likely shrinks to nothing or merges into `sticky_key.rs`." A new module name would orphan the established `sticky_key` mod path used across `keyboard.rs`. + - **DECISION (2026-06-06, confirmed by user):** **Fold into `sticky_key.rs`; delete `oneshot.rs`.** The unified latch lives in `sticky_key.rs` (Task 2.1 declares the latch state there). `oneshot.rs` is deleted as its logic is ported out — OSM removed in Task 2.4, OSL removed and the file + `mod oneshot;`/`use` deleted in Task 3.1. Grounding: the surviving public surface is already `sticky_key`-named (`Action::StickyKey`, `sticky_key_state` field, `sticky_key_config`, `process_action_sticky_key`, `release_sticky_key_if_active`) and `keyboard.rs` references that path throughout; keeping it and dropping `oneshot` is the lowest-churn path and matches the user-facing `SK(...)` naming. A neutral `keyboard/latch.rs` would force every `sticky_key::` site and the `mod`/`use` lines to churn to `latch::` for zero behavioral gain, and would add a third module name to a feature whose whole point is collapsing to one engine. - **DP-3 — Vial one-shot-timeout runtime path (resolve in Stage 1, Task 1.4).** The unified `timeout` either keeps a Vial runtime-set path (`SettingKey::OneShotTimeout = 0x06`, handlers at `rmk/src/host/via/vial.rs:127-130` and `184-186`, storage `FlashOperationMessage::OneShotTimeout` at `rmk/src/storage/mod.rs:145`) or drops it with the OSM keycodes. - **Recommendation:** **keep** the Vial setting wire-compatible but re-point it at the unified `sticky_key` timeout (rename the internal `one_shot_timeout` storage field / accessor to `sticky_key_timeout`, leave `SettingKey` numeric value `0x06` and the protocol bytes unchanged). Dropping a Vial `SettingKey` is itself a Vial-protocol break; this round we are already breaking the keymap wire (DP-4) and should not stack a second protocol break unless the user wants it. Confirm with user. From f329ca4e27a50191816e18042ca05c93e604ca75 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Sat, 6 Jun 2026 08:01:54 -0500 Subject: [PATCH 048/119] feat(engine): unified SK latch carrying mods/key/layer/phase/repeat/deadline (DP-1, DP-2) --- rmk/src/keyboard/sticky_key.rs | 54 +++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 17 deletions(-) diff --git a/rmk/src/keyboard/sticky_key.rs b/rmk/src/keyboard/sticky_key.rs index 2abec6201..902b6edb1 100644 --- a/rmk/src/keyboard/sticky_key.rs +++ b/rmk/src/keyboard/sticky_key.rs @@ -1,36 +1,44 @@ //! StickyKey action implementation. //! -//! StickyKey holds a modifier combination across key presses for Alt+Tab-like cycling. -//! Features: -//! - `max_repeat`: limit fires before auto-release (0 = infinite) -//! - `timeout_ms`: per-key timeout override (0 = use global config) -//! - `exit_on_layer_change`: whether layer changes release the SK -//! -//! ## `max_repeat` semantics -//! count starts at 1 on first press. On each subsequent press, count is incremented. -//! Deactivation fires when count > max_repeat (strictly greater), so max_repeat=N fires -//! the key exactly N times and deactivates silently on press N+1. +//! A unified one-shot action engine covering pure-mod (OSM), tap-key, and layer (OSL) shapes. +//! The shape is determined by the `StickyKeyAction` payload at compile time. +//! Runtime state is tracked in `StickyKeyState`; the latch phase is tracked in `SkPhase`. use embassy_time::{Duration, Instant}; use rmk_types::action::StickyKeyAction; -use rmk_types::keycode::KeyCode; +use rmk_types::keycode::{HidKeyCode, KeyCode}; use rmk_types::modifier::ModifierCombination; use crate::event::KeyboardEvent; use crate::keyboard::Keyboard; +/// Latch phase of a sticky key. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub(crate) enum SkPhase { + /// SK pressed, not yet consumed. + #[default] + Pressed, + /// Armed — waiting for the next (foreign) key. + Latched, + /// Promoted to held (key released after another key was used). + Held, +} + /// State for the StickyKey action. -#[derive(Default, Debug)] +#[derive(Clone, Copy, Default, Debug)] pub(crate) enum StickyKeyState { /// StickyKey is inactive. #[default] None, - /// StickyKey is active — modifiers held, optional deadline for auto-release. + /// StickyKey is active — carries all latch state the engine needs. Active { mods: ModifierCombination, + /// `KeyCode::Hid(HidKeyCode::No)` = pure-mod or layer shape; any other key = tap-key shape. + key: KeyCode, + /// `Some(n)` = OSL shape; `None` = pure-mod or tap-key shape. + layer: Option, + phase: SkPhase, repeat_count: u16, - max_repeat: u16, - exit_on_layer_change: bool, deadline: Option, }, } @@ -54,15 +62,27 @@ impl StickyKeyState { } } - pub fn exit_on_layer_change(&self) -> bool { + /// True when this is a pure-mod shape: active with no tap key and no layer. + pub fn is_pure_mod(&self) -> bool { matches!( self, StickyKeyState::Active { - exit_on_layer_change: true, + key: KeyCode::Hid(HidKeyCode::No), + layer: None, .. } ) } + + /// True when this is a tap-key shape: active with a non-No key code. + pub fn is_tap_key(&self) -> bool { + self.is_active() && !self.is_pure_mod() && !self.is_layer() + } + + /// True when this is a layer (OSL) shape: active with a `Some` layer. + pub fn is_layer(&self) -> bool { + matches!(self, StickyKeyState::Active { layer: Some(_), .. }) + } } impl Keyboard<'_> { From d2a17499f2a539d6f8d8272672dd425cd49eb451 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Sat, 6 Jun 2026 08:41:34 -0500 Subject: [PATCH 049/119] fix(macro): sk_mod!/sk_layer! emit KeyCode::Hid(HidKeyCode::No) (Stage 1 sentinel defect) --- rmk-types/src/action/mod.rs | 6 +++--- rmk/src/layout_macro.rs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/rmk-types/src/action/mod.rs b/rmk-types/src/action/mod.rs index 29fb22d3c..1b46bec2e 100644 --- a/rmk-types/src/action/mod.rs +++ b/rmk-types/src/action/mod.rs @@ -36,14 +36,14 @@ use crate::steno::StenoKey; #[cfg_attr(feature = "defmt", derive(defmt::Format))] #[cfg_attr(feature = "rmk_protocol", derive(Schema))] pub struct StickyKeyAction { - /// Key sent on each SK press. `KeyCode::No` selects the pure-mod (OSM) shape + /// Key sent on each SK press. `KeyCode::Hid(HidKeyCode::No)` selects the pure-mod (OSM) shape /// when `layer` is `None`; otherwise it's the tap-key (alt-tab) shape. pub key: KeyCode, /// Modifiers held between presses (0 = none). Unused for the layer (OSL) shape. pub keep: ModifierCombination, /// `Some(n)` = one-shot-layer (OSL) shape activating layer `n`. - /// `None` + `key == KeyCode::No` = pure-mod (OSM) shape. - /// `None` + `key != KeyCode::No` = tap-key (alt-tab) shape. + /// `None` + `key == KeyCode::Hid(HidKeyCode::No)` = pure-mod (OSM) shape. + /// `None` + `key != KeyCode::Hid(HidKeyCode::No)` = tap-key (alt-tab) shape. pub layer: Option, } diff --git a/rmk/src/layout_macro.rs b/rmk/src/layout_macro.rs index d947c693f..e6a7ea94f 100644 --- a/rmk/src/layout_macro.rs +++ b/rmk/src/layout_macro.rs @@ -350,7 +350,7 @@ macro_rules! sk_mod { ($m:expr) => { $crate::types::action::KeyAction::Single($crate::types::action::Action::StickyKey( $crate::types::action::StickyKeyAction { - key: $crate::types::keycode::KeyCode::No, + key: $crate::types::keycode::KeyCode::Hid($crate::types::keycode::HidKeyCode::No), keep: $m, layer: None, }, @@ -372,7 +372,7 @@ macro_rules! sk_layer { ($n:literal) => { $crate::types::action::KeyAction::Single($crate::types::action::Action::StickyKey( $crate::types::action::StickyKeyAction { - key: $crate::types::keycode::KeyCode::No, + key: $crate::types::keycode::KeyCode::Hid($crate::types::keycode::HidKeyCode::No), keep: $crate::types::modifier::ModifierCombination::new(), layer: Some($n), }, From 6b1175b7c990c4ffe5b2d2669739eb7c72a29758 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Sat, 6 Jun 2026 09:08:14 -0500 Subject: [PATCH 050/119] docs(plan): mark Task 2.1 complete; record sentinel fix + declined phase() accessor --- .../plans/2026-06-03-sk-absorbs-oneshot-plan.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md b/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md index e2f0c16db..ffa9fbb9a 100644 --- a/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md +++ b/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md @@ -397,9 +397,9 @@ git commit -m "test: migrate one-shot/sticky tests to SK(...) syntax and unified - Modify: `rmk/src/keyboard/sticky_key.rs:24-66` (latch state + helpers) - Decision: DP-1 (payload encoding — must already be recorded from Task 1.4), DP-2 (latch home) -- [ ] **Step 1: STOP — record DP-2.** Write the decision (recommended: fold `oneshot.rs` into `sticky_key.rs`, delete `oneshot.rs`) into the Decision Points section above. +- [x] **Step 1: STOP — record DP-2.** Write the decision (recommended: fold `oneshot.rs` into `sticky_key.rs`, delete `oneshot.rs`) into the Decision Points section above. -- [ ] **Step 2: Replace `StickyKeyState`** (enum `None | Active{...}` at 24-36) with the unified latch carrying everything the spec lists (Section 3e): `mods`, optional `key`, optional `layer`, `phase` (Pressed/Latched/Held), `repeat_count`, `deadline: Option`. Suggested shape: +- [x] **Step 2: Replace `StickyKeyState`** (enum `None | Active{...}` at 24-36) with the unified latch carrying everything the spec lists (Section 3e): `mods`, optional `key`, optional `layer`, `phase` (Pressed/Latched/Held), `repeat_count`, `deadline: Option`. Suggested shape: ```rust #[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] pub(crate) enum SkPhase { @@ -423,14 +423,21 @@ pub(crate) enum StickyKeyState { }, } ``` -- [ ] **Step 3: Re-implement the helper methods** (`value`/`is_active`/`deadline` at 38-66, plus new shape predicates `is_pure_mod()` = `key == No && layer.is_none()`, `is_tap_key()` = `key != No`, `is_layer()` = `layer.is_some()`). `value()` returns the held mods for `resolve_explicit_modifiers`. Replace `exit_on_layer_change()` (it was per-action; now read `release_on_layer_change` from config — the helper becomes a config read in `keyboard.rs`, see Task 2.4). +- [x] **Step 3: Re-implement the helper methods** (`value`/`is_active`/`deadline` at 38-66, plus new shape predicates `is_pure_mod()` = `key == No && layer.is_none()`, `is_tap_key()` = `key != No`, `is_layer()` = `layer.is_some()`). `value()` returns the held mods for `resolve_explicit_modifiers`. Replace `exit_on_layer_change()` (it was per-action; now read `release_on_layer_change` from config — the helper becomes a config read in `keyboard.rs`, see Task 2.4). -- [ ] **Step 4: Build.** `cargo build -p rmk ...` — expect failures only in the `process_*`/dispatch sites (next tasks). Commit the state shape alone: +- [x] **Step 4: Build.** `cargo build -p rmk ...` — expect failures only in the `process_*`/dispatch sites (next tasks). Commit the state shape alone: ```bash git add rmk/src/keyboard/sticky_key.rs git commit -m "feat(engine): unified SK latch carrying mods/key/layer/phase/repeat/deadline (DP-1, DP-2)" ``` +**TASK 2.1 COMPLETE (2026-06-06).** Commits: `1056db2d` (DP-2 plan record) · `f329ca4e` (unified SK latch — sticky_key.rs only) · `d2a17499` (sentinel fix, see below). Both reviews passed (spec ✅; code-quality ✅ after fixes). +- Latch implemented exactly as specced; `is_pure_mod` requires BOTH `key == Hid(No)` AND `layer.is_none()`. The `"No"` sentinel is `KeyCode::Hid(HidKeyCode::No)` — `KeyCode` has **no bare `No` variant**. +- Code-quality review surfaced a **Stage-1 sentinel defect**: `sk_mod!`/`sk_layer!` in `layout_macro.rs` (and doc prose in `rmk-types/src/action/mod.rs`) emitted the non-existent `KeyCode::No`. Fixed now (commit `d2a17499`) since Task 2.1's predicates define that sentinel contract. This removes one class of the test-crate-compile errors that Task 2.2 must clear (carry-forward D2). +- `is_tap_key` defined as `is_active() && !is_pure_mod() && !is_layer()` (sentinel-independent, per reviewer). +- A `phase()` accessor was **declined** as speculative (YAGNI); Task 2.2 adds accessors when it writes the engine body (same file, zero extra churn). +- Build blast radius after 2.1: exactly the expected consumer-site errors (`process_action_sticky_key` body, `keyboard.rs` `.exit_on_layer_change()`/`Action::OneShot*` arms, `keymap.rs` `one_shot_modifiers`, `oneshot.rs` `one_shot_timeout()`) — all Task 2.2/2.4/3.1 targets. No errors in the new latch/helpers. + ### Task 2.2: Pure-mod path — accumulation, activate_on_keypress, quick_release, terminating-key application **Files:** From 565a804f349e7f0f55fee58e02f89709407fc610 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Sat, 6 Jun 2026 10:00:24 -0500 Subject: [PATCH 051/119] =?UTF-8?q?docs(plan):=20record=20Stage=202=20exec?= =?UTF-8?q?ution=20decision=20=E2=80=94=20combine=20engine=20Tasks=202.2/2?= =?UTF-8?q?.3/2.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md b/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md index ffa9fbb9a..98b4a512d 100644 --- a/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md +++ b/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md @@ -438,6 +438,8 @@ git commit -m "feat(engine): unified SK latch carrying mods/key/layer/phase/repe - A `phase()` accessor was **declined** as speculative (YAGNI); Task 2.2 adds accessors when it writes the engine body (same file, zero extra churn). - Build blast radius after 2.1: exactly the expected consumer-site errors (`process_action_sticky_key` body, `keyboard.rs` `.exit_on_layer_change()`/`Action::OneShot*` arms, `keymap.rs` `one_shot_modifiers`, `oneshot.rs` `one_shot_timeout()`) — all Task 2.2/2.4/3.1 targets. No errors in the new latch/helpers. +**STAGE 2 EXECUTION DECISION (2026-06-06, confirmed by user): combine Tasks 2.2 + 2.3 + 2.4 into one engine-migration work unit.** Rationale: the `rmk` lib does not compile after Stage 1 (carry-forward D2), and the blocking errors are split across 2.2 (`process_action_sticky_key` body), 2.4 (`keyboard.rs` `Action::OneShot*` arms, `.exit_on_layer_change()` calls; `keymap.rs` `one_shot_modifiers`) and 2.4/3.1 (`oneshot.rs` `one_shot_timeout()`). The three tasks all edit the same function and the same `keyboard.rs` sites, and no test can run until all three land — so the per-task TDD gates in 2.2/2.3 are not individually satisfiable (the plan's line-160 assumption that Stage 1 kept the lib compiling failed). They are executed by one implementer to a compiling, fully test-green state (OSM behavior + non-deferred SK + new 3b/3c regressions), followed by a single two-stage review over the combined diff. OSL behavior tests remain failing until Stage 3 (expected). Task sub-sections 2.2/2.3/2.4 below retain their full specs as the combined work unit's checklist. + ### Task 2.2: Pure-mod path — accumulation, activate_on_keypress, quick_release, terminating-key application **Files:** From e0a67bfb755421552a3aae27edcd633b2284ad36 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Sat, 6 Jun 2026 13:01:54 -0500 Subject: [PATCH 052/119] =?UTF-8?q?feat(engine):=20pure-mod=20SK=20shape?= =?UTF-8?q?=20=E2=80=94=20accumulation,=20activate=5Fon=5Fkeypress/quick?= =?UTF-8?q?=5Frelease,=20terminating-key=20application=20(3a/3b/3c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- rmk/src/keyboard.rs | 80 ++++++------ rmk/src/keyboard/sticky_key.rs | 170 +++++++++++++++++++++++--- rmk/src/keymap.rs | 6 +- rmk/tests/keyboard_sticky_key_test.rs | 68 ++++++++++- 4 files changed, 260 insertions(+), 64 deletions(-) diff --git a/rmk/src/keyboard.rs b/rmk/src/keyboard.rs index 411850ed8..974eabd9d 100644 --- a/rmk/src/keyboard.rs +++ b/rmk/src/keyboard.rs @@ -29,7 +29,7 @@ use crate::keyboard::fork::ActiveFork; use crate::keyboard::held_buffer::{HeldBuffer, HeldKey, KeyState}; use crate::keyboard::mouse::{MouseAction, MouseState}; use crate::keyboard::oneshot::OneShotState; -use crate::keyboard::sticky_key::StickyKeyState; +use crate::keyboard::sticky_key::{SkPhase, StickyKeyState}; use crate::keyboard_macros::MacroOperation; use crate::keymap::KeyMap; #[cfg(all(feature = "split", feature = "_ble"))] @@ -143,8 +143,10 @@ impl Runnable for Keyboard<'_> { /// The report is sent using `send_report`. async fn run(&mut self) -> ! { loop { - // TODO: Now the unprocessed_events is only used in one-shot keys and clear peer key. - // Maybe it can be removed in the future? + // `unprocessed_events` is still required: the Clear Peer BLE path + // (`#[cfg(feature = "split")]`, see below) and the OSL inline-select path push + // events here for re-processing. Do NOT delete the queue or this consumer. + // (The OSM producers were removed in Stage 2 once OSM moved to the SK engine.) if !self.unprocessed_events.is_empty() { // Process unprocessed events let e = self.unprocessed_events.remove(0); @@ -216,9 +218,6 @@ pub struct Keyboard<'a> { /// Oneshot Layer state osl_state: OneShotState, - /// Oneshot Modifier state - osm_state: OneShotState, - /// StickyKey state — holds a modifier+key combination across key presses sticky_key_state: StickyKeyState, @@ -274,7 +273,6 @@ impl<'a> Keyboard<'a> { keyboard_event_subscriber: KeyboardEvent::subscriber(), last_press_time: Instant::now(), osl_state: OneShotState::default(), - osm_state: OneShotState::default(), sticky_key_state: StickyKeyState::default(), caps_word: CapsWordState::default(), with_modifiers: ModifierCombination::default(), @@ -1217,8 +1215,13 @@ impl<'a> Keyboard<'a> { }) .await; - // Release StickyKey when any non-SK, non-modifier key is pressed. - if event.pressed && self.sticky_key_state.is_active() { + // Release the tap-key StickyKey when any non-SK, non-modifier key is pressed. + // + // Pure-mod SKs are deliberately NOT released here: the modifier must remain applied + // THROUGH the terminating key's report (and is then consumed by `update_sticky_key` + // in `process_action_key`, per `quick_release`). Only the tap-key shape releases its + // held modifier cleanly before the foreign key registers. + if event.pressed && self.sticky_key_state.is_tap_key() { let is_sk_or_modifier = match action { Action::StickyKey(_) | Action::Modifier(_) => true, Action::Key(KeyCode::Hid(hid_key)) if hid_key.is_modifier() => true, @@ -1238,7 +1241,7 @@ impl<'a> Keyboard<'a> { // Reactivate the layer after the key is released if event.pressed { self.keymap.deactivate_layer(layer_num); - if self.sticky_key_state.exit_on_layer_change() { + if self.keymap.sticky_key_config().release_on_layer_change { self.release_sticky_key_if_active().await; } } @@ -1247,7 +1250,7 @@ impl<'a> Keyboard<'a> { // Toggle a layer when the key is released if !event.pressed { self.keymap.toggle_layer(layer_num); - if self.sticky_key_state.exit_on_layer_change() { + if self.keymap.sticky_key_config().release_on_layer_change { self.release_sticky_key_if_active().await; } } @@ -1265,7 +1268,7 @@ impl<'a> Keyboard<'a> { } // Activate the target layer self.keymap.activate_layer(layer_num); - if self.sticky_key_state.exit_on_layer_change() { + if self.keymap.sticky_key_config().release_on_layer_change { self.release_sticky_key_if_active().await; } } @@ -1273,7 +1276,7 @@ impl<'a> Keyboard<'a> { Action::DefaultLayer(layer_num) => { // Set the default layer self.keymap.set_default_layer(layer_num); - if self.sticky_key_state.exit_on_layer_change() { + if self.keymap.sticky_key_config().release_on_layer_change { self.release_sticky_key_if_active().await; } } @@ -1313,16 +1316,6 @@ impl<'a> Keyboard<'a> { self.process_action_layer_switch(layer_num, event).await; self.send_keyboard_report_with_resolved_modifiers(event.pressed).await } - Action::OneShotLayer(l) => { - self.process_action_osl(l, event).await; - // Process OSM to avoid the OSL state stuck when an OSL is followed by an OSM - self.update_osm(event); - } - Action::OneShotModifier(m) => { - self.process_action_osm(m, event).await; - // Process OSL to avoid the OSM state stuck when an OSM is followed by an OSL - self.update_osl(event); - } Action::StickyKey(params) => { self.process_action_sticky_key(params, event).await; } @@ -1378,25 +1371,26 @@ impl<'a> Keyboard<'a> { /// - registered modifiers /// - one-shot modifiers pub fn resolve_explicit_modifiers(&self, pressed: bool) -> ModifierCombination { - // if a one-shot modifier is active, decorate the hid report of keypress with those modifiers + // if a sticky key is active, decorate the hid report of keypress with its modifiers let mut result = self.held_modifiers; - // OneShotState::Held keeps the temporary modifiers active until the key is released - if pressed { - if let Some(osm) = self.osm_state.value() { - result |= *osm; + // Add StickyKey modifiers. + // + // - Tap-key shape (alt-tab): the modifier is held continuously between presses, so + // it is included on both press and release reports (its own HID key is what gets + // registered/unregistered). + // - Pure-mod shape (OSM): the modifier usually applies only on the terminating key's + // press report and is "released" together with the key release — except in held + // mode (key pressed while SK still physically held), where the modifier behaves + // like a normal held modifier and stays applied until the SK itself is released. + if let StickyKeyState::Active { mods, phase, .. } = self.sticky_key_state { + if self.sticky_key_state.is_pure_mod() { + if pressed || phase == SkPhase::Held { + result |= mods; + } + } else { + result |= mods; } - } else if let OneShotState::Held(osm) = self.osm_state { - // One shot modifiers usually "released" together with the key release, - // except when oneshot is in "held mode" (to allow Alt+Tab like use cases) - // In this later case Held -> None state change will report - // the "modifier released" change in a separate hid report - result |= osm; - }; - - // Add StickyKey modifiers if active - if let Some(sk_mods) = self.sticky_key_state.value() { - result |= *sk_mods; } result @@ -1582,9 +1576,9 @@ impl<'a> Keyboard<'a> { _ => warn!("KeyCode variant not supported: {:?}", key), } - let quick_release = self.keymap.one_shot_modifiers_config().quick_release; - let osm_consumed = self.update_osm(event); - if quick_release && osm_consumed && key.is_basic_keyboard_key() && event.pressed { + let quick_release = self.keymap.sticky_key_config().quick_release; + let sk_consumed = self.update_sticky_key(event); + if quick_release && sk_consumed && key.is_basic_keyboard_key() && event.pressed { self.send_keyboard_report_with_resolved_modifiers(true).await; } self.update_osl(event); @@ -1597,7 +1591,7 @@ impl<'a> Keyboard<'a> { self.keymap.activate_layer(layer_num); } else { self.keymap.deactivate_layer(layer_num); - if self.sticky_key_state.exit_on_layer_change() { + if self.keymap.sticky_key_config().release_on_layer_change { self.release_sticky_key_if_active().await; } } diff --git a/rmk/src/keyboard/sticky_key.rs b/rmk/src/keyboard/sticky_key.rs index 902b6edb1..69c1bb484 100644 --- a/rmk/src/keyboard/sticky_key.rs +++ b/rmk/src/keyboard/sticky_key.rs @@ -3,6 +3,10 @@ //! A unified one-shot action engine covering pure-mod (OSM), tap-key, and layer (OSL) shapes. //! The shape is determined by the `StickyKeyAction` payload at compile time. //! Runtime state is tracked in `StickyKeyState`; the latch phase is tracked in `SkPhase`. +//! +//! Timeout is driven solely by the run-loop deadline race (see `Keyboard::run`); there is +//! no inline `select` in this module. On expiry the run loop calls +//! [`Keyboard::release_sticky_key_if_active`]. use embassy_time::{Duration, Instant}; use rmk_types::action::StickyKeyAction; @@ -13,14 +17,18 @@ use crate::event::KeyboardEvent; use crate::keyboard::Keyboard; /// Latch phase of a sticky key. +/// +/// Mirrors the former OSM state machine: `Pressed` == Initial, `Latched` == Single, +/// `Held` == Held. #[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] pub(crate) enum SkPhase { - /// SK pressed, not yet consumed. + /// SK pressed, not yet consumed (still physically held). OSM `Initial`. #[default] Pressed, - /// Armed — waiting for the next (foreign) key. + /// Armed — SK released before any other key, waiting for the next (foreign) key. OSM `Single`. Latched, - /// Promoted to held (key released after another key was used). + /// Another key was pressed while the SK was still held; behaves like a normal held + /// modifier until the SK is released. OSM `Held`. Held, } @@ -87,34 +95,102 @@ impl StickyKeyState { impl Keyboard<'_> { pub(crate) async fn process_action_sticky_key(&mut self, params: StickyKeyAction, event: KeyboardEvent) { + // Layer (OSL) shape — Stage 3. Minimal stub: must compile and not panic. + // TODO(Stage 3): port OSL behavior into this branch (see oneshot.rs `process_action_osl`). + if params.layer.is_some() { + return; + } + + if params.key == KeyCode::Hid(HidKeyCode::No) { + self.process_sticky_pure_mod(params, event).await; + } else { + self.process_sticky_tap_key(params, event).await; + } + } + + /// Pure-mod (OSM) shape: accumulate the modifier across taps, apply it through the + /// terminating key, honor `activate_on_keypress`/`quick_release`. + async fn process_sticky_pure_mod(&mut self, params: StickyKeyAction, event: KeyboardEvent) { + let config = self.keymap.sticky_key_config(); + let deadline = (config.timeout != Duration::MAX).then(|| Instant::now() + config.timeout); + if event.pressed { - let timeout = if params.timeout_ms > 0 { - Duration::from_millis(params.timeout_ms as u64) - } else { - self.keymap.sticky_key_timeout() - }; - let deadline = (timeout != Duration::MAX).then(|| Instant::now() + timeout); + match &mut self.sticky_key_state { + StickyKeyState::None => { + self.sticky_key_state = StickyKeyState::Active { + mods: params.keep, + key: params.key, + layer: None, + phase: SkPhase::Pressed, + repeat_count: 1, + deadline, + }; + } + StickyKeyState::Active { + mods, deadline: d, .. + } => { + // Accumulate (3c) and refresh the timeout deadline. + *mods |= params.keep; + *d = deadline; + } + } + + if config.activate_on_keypress { + self.send_keyboard_report_with_resolved_modifiers(true).await; + } + } else { + // SK released. + match self.sticky_key_state { + StickyKeyState::Active { + phase: SkPhase::Pressed, + .. + } => { + // Released before any other key → arm it for the next key. + if let StickyKeyState::Active { phase, .. } = &mut self.sticky_key_state { + *phase = SkPhase::Latched; + } + } + StickyKeyState::Active { + phase: SkPhase::Held, .. + } => { + // Held-mode: the modifier was applied as a normal held modifier; releasing + // the SK releases it now in its own report. + self.sticky_key_state = StickyKeyState::None; + self.send_keyboard_report_with_resolved_modifiers(false).await; + } + _ => {} + } + } + } + /// Tap-key (alt-tab) shape: send `keep` mods + `key` on every press, hold the mods + /// between presses, cycle on each press (`max_repeat`). Ignores + /// `activate_on_keypress`/`quick_release`. + async fn process_sticky_tap_key(&mut self, params: StickyKeyAction, event: KeyboardEvent) { + let config = self.keymap.sticky_key_config(); + let deadline = (config.timeout != Duration::MAX).then(|| Instant::now() + config.timeout); + + if event.pressed { let mut should_deactivate = false; match &mut self.sticky_key_state { StickyKeyState::None => { self.sticky_key_state = StickyKeyState::Active { mods: params.keep, + key: params.key, + layer: None, + phase: SkPhase::Latched, repeat_count: 1, - max_repeat: params.max_repeat, - exit_on_layer_change: params.exit_on_layer_change, deadline, }; } StickyKeyState::Active { repeat_count, - max_repeat: mr, deadline: d, .. } => { *repeat_count += 1; - if *mr > 0 && *repeat_count > *mr { + if config.max_repeat > 0 && *repeat_count > config.max_repeat { should_deactivate = true; } else { *d = deadline; @@ -144,10 +220,72 @@ impl Keyboard<'_> { } } + /// Foreign-key hook for the pure-mod shape, mirroring the former `update_osm`. + /// Called from `process_action_key` for every basic key. Drives the OSM-style + /// phase transitions on the terminating key and returns `true` when the latch was + /// consumed (so the caller can emit a quick-release report). + /// + /// Tap-key and layer shapes are untouched here — they are consumed elsewhere. + pub(crate) fn update_sticky_key(&mut self, event: KeyboardEvent) -> bool { + if !self.sticky_key_state.is_pure_mod() { + return false; + } + let quick_release = self.keymap.sticky_key_config().quick_release; + match &mut self.sticky_key_state { + StickyKeyState::Active { + phase: phase @ SkPhase::Pressed, + .. + } => { + // A key was pressed while the SK is still physically held → promote to Held. + *phase = SkPhase::Held; + false + } + StickyKeyState::Active { + phase: SkPhase::Latched, + .. + } if quick_release && event.pressed => { + self.sticky_key_state = StickyKeyState::None; + true + } + StickyKeyState::Active { + phase: SkPhase::Latched, + .. + } if !quick_release && !event.pressed => { + self.sticky_key_state = StickyKeyState::None; + true + } + _ => false, + } + } + pub(crate) async fn release_sticky_key_if_active(&mut self) { - if self.sticky_key_state.is_active() { - debug!("Releasing StickyKey"); - self.sticky_key_state = StickyKeyState::None; + if !self.sticky_key_state.is_active() { + return; + } + debug!("Releasing StickyKey"); + + // Decide whether the release needs its own HID report. A report is only meaningful + // when the sticky modifier was actually visible in the last report: + // - tap-key shape: the modifier is always live between presses → always report. + // - pure-mod shape: only when promoted to Held, or when `activate_on_keypress` + // emitted the modifier early. A bare Latched pure-mod that times out before any + // key (and without early activation) never emitted the modifier, so releasing it + // must NOT produce a spurious empty report. Mirrors the former OSM timeout path. + let needs_report = if self.sticky_key_state.is_pure_mod() { + let activate_on_keypress = self.keymap.sticky_key_config().activate_on_keypress; + matches!( + self.sticky_key_state, + StickyKeyState::Active { + phase: SkPhase::Held, + .. + } + ) || activate_on_keypress + } else { + true + }; + + self.sticky_key_state = StickyKeyState::None; + if needs_report { self.send_keyboard_report_with_resolved_modifiers(false).await; } } diff --git a/rmk/src/keymap.rs b/rmk/src/keymap.rs index 275187f4d..11caf837d 100644 --- a/rmk/src/keymap.rs +++ b/rmk/src/keymap.rs @@ -11,7 +11,7 @@ use { }; use crate::MACRO_SPACE_SIZE; -use crate::config::{BehaviorConfig, Hand, MouseKeyConfig, OneShotModifiersConfig, PositionalConfig}; +use crate::config::{BehaviorConfig, Hand, MouseKeyConfig, PositionalConfig, StickyKeyConfig}; use crate::event::{KeyboardEvent, KeyboardEventPos, LayerChangeEvent, publish_event}; use crate::input_device::rotary_encoder::Direction; use crate::keyboard::combo::Combo; @@ -512,8 +512,8 @@ impl<'a> KeyMap<'a> { self.inner.borrow().behavior.sticky_key.timeout } - pub(crate) fn one_shot_modifiers_config(&self) -> OneShotModifiersConfig { - self.inner.borrow().behavior.one_shot_modifiers + pub(crate) fn sticky_key_config(&self) -> StickyKeyConfig { + self.inner.borrow().behavior.sticky_key } pub(crate) fn tap_interval(&self) -> u16 { diff --git a/rmk/tests/keyboard_sticky_key_test.rs b/rmk/tests/keyboard_sticky_key_test.rs index bda8afe8c..2153345e8 100644 --- a/rmk/tests/keyboard_sticky_key_test.rs +++ b/rmk/tests/keyboard_sticky_key_test.rs @@ -5,10 +5,10 @@ use rmk::config::{BehaviorConfig, PositionalConfig, StickyKeyConfig}; use rmk::keyboard::Keyboard; use rmk::types::action::KeyAction; use rmk::types::modifier::ModifierCombination; -use rmk::{a, k, mo, sk}; +use rmk::{a, k, mo, sk, sk_mod}; use rusty_fork::rusty_fork_test; -use crate::common::{KC_LALT, KC_LCTRL, KC_LSHIFT, wrap_keymap}; +use crate::common::{KC_LALT, KC_LCTRL, KC_LGUI, KC_LSHIFT, wrap_keymap}; // KEYMAP (release_on_layer_change=true is set in the helper config, not per-key) // Layer 0: A B C MO(1) LShift No @@ -64,6 +64,23 @@ const KEYMAP_NO_EXIT: [[[KeyAction; 6]; 1]; 2] = [ ]], ]; +// KEYMAP_PUREMOD: pure-mod SKs (OSM shape) on the base layer for the absorbed-OSM regressions. +// Layer 0: SK(LGui) SK(LCtrl) SK(LShift) P No No +const KEYMAP_PUREMOD: [[[KeyAction; 6]; 1]; 1] = [[[ + sk_mod!(ModifierCombination::LGUI), // col 0: pure-mod SK(LGui) + sk_mod!(ModifierCombination::LCTRL), // col 1: pure-mod SK(LCtrl) + sk_mod!(ModifierCombination::LSHIFT), // col 2: pure-mod SK(LShift) + k!(P), // col 3: P + a!(No), // col 4 + a!(No), // col 5 +]]]; + +fn create_test_keyboard_puremod() -> Keyboard<'static> { + let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig::default())); + let per_key_config: &'static PositionalConfig<1, 6> = Box::leak(Box::new(PositionalConfig::default())); + Keyboard::new(wrap_keymap(KEYMAP_PUREMOD, per_key_config, behavior_config)) +} + fn create_test_keyboard() -> Keyboard<'static> { static BEHAVIOR_CONFIG: static_cell::StaticCell = static_cell::StaticCell::new(); let behavior_config = BEHAVIOR_CONFIG.init(BehaviorConfig { @@ -452,4 +469,51 @@ rusty_fork_test! { ] }; } + + /// StickyKey Test 3b (regression): pure-mod SK applies its modifier THROUGH the + /// terminating key, then clears. Mirrors `test_osm_basic_single_behavior` via the + /// unified SK engine. Pins the absorbed OSM terminating-key behavior. + /// + /// Sequence: tap SK(LGui) (col 0), tap P (col 3) + /// Expected: P with LGui, then all released. + #[test] + fn test_sk_puremod_terminating_key() { + key_sequence_test! { + keyboard: create_test_keyboard_puremod(), + sequence: [ + [0, 0, true, 10], // Press SK(LGui) + [0, 0, false, 10], // Release SK(LGui) + [0, 3, true, 10], // Press P + [0, 3, false, 10], // Release P + ], + expected_reports: [ + [KC_LGUI, [kc_to_u8!(P), 0, 0, 0, 0, 0]], // P with LGui + [0, [0, 0, 0, 0, 0, 0]], // All released + ] + }; + } + + /// StickyKey Test 3c (regression): two pure-mod SK taps accumulate onto one + /// terminating key. Mirrors `test_osm_combined_modifiers` via the SK engine. + /// + /// Sequence: tap SK(LCtrl) (col 1), tap SK(LShift) (col 2), tap P (col 3) + /// Expected: P with LCtrl|LShift, then all released. + #[test] + fn test_sk_puremod_cross_tap_accumulation() { + key_sequence_test! { + keyboard: create_test_keyboard_puremod(), + sequence: [ + [0, 1, true, 10], // Press SK(LCtrl) + [0, 1, false, 10], // Release SK(LCtrl) + [0, 2, true, 10], // Press SK(LShift) + [0, 2, false, 10], // Release SK(LShift) + [0, 3, true, 10], // Press P + [0, 3, false, 10], // Release P + ], + expected_reports: [ + [KC_LCTRL | KC_LSHIFT, [kc_to_u8!(P), 0, 0, 0, 0, 0]], // P with LCtrl|LShift + [0, [0, 0, 0, 0, 0, 0]], // All released + ] + }; + } } From e9a0c092c8317015f183ce021aee58e5cc50d8d1 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Sat, 6 Jun 2026 13:10:02 -0500 Subject: [PATCH 053/119] refactor(engine): retire OSM path + inline select; remove OSM/OSL unprocessed_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. --- rmk/src/keyboard/oneshot.rs | 119 ++++--------------------------- rmk/tests/keyboard_combo_test.rs | 15 ++-- 2 files changed, 18 insertions(+), 116 deletions(-) diff --git a/rmk/src/keyboard/oneshot.rs b/rmk/src/keyboard/oneshot.rs index aa8b1b75f..b7a35af12 100644 --- a/rmk/src/keyboard/oneshot.rs +++ b/rmk/src/keyboard/oneshot.rs @@ -1,6 +1,4 @@ use embassy_futures::select::{Either, select}; -use embassy_time::Timer; -use rmk_types::modifier::ModifierCombination; use crate::event::KeyboardEvent; use crate::keyboard::Keyboard; @@ -30,89 +28,17 @@ impl OneShotState { } impl<'a> Keyboard<'a> { - pub(crate) async fn process_action_osm(&mut self, new_modifiers: ModifierCombination, event: KeyboardEvent) { - let activate_on_keypress = self.keymap.one_shot_modifiers_config().activate_on_keypress; - - // Update one shot state - if event.pressed { - let mut was_active = false; - // Add new modifier combination to existing one shot or init if none - self.osm_state = match self.osm_state { - OneShotState::None => OneShotState::Initial(new_modifiers), - OneShotState::Initial(cur_modifiers) => OneShotState::Initial(cur_modifiers | new_modifiers), - OneShotState::Single(cur_modifiers) => { - was_active = cur_modifiers & new_modifiers == new_modifiers; - - if was_active { - let result = cur_modifiers & !new_modifiers; - // Remove the matching event from unprocessed_events queue - self.unprocessed_events.retain(|e| e.pos != event.pos); - // Send report for current osm_state modifiers - self.send_keyboard_report_with_resolved_modifiers(true).await; - - if result.into_bits() == 0 { - OneShotState::None - } else { - OneShotState::Single(result) - } - } else { - OneShotState::Single(cur_modifiers | new_modifiers) - } - } - OneShotState::Held(cur_modifiers) => OneShotState::Held(cur_modifiers | new_modifiers), - }; - - self.update_osl(event); - - // Send report for updated osm_state modifiers - if was_active || activate_on_keypress { - self.send_keyboard_report_with_resolved_modifiers(true).await; - } - } else { - match self.osm_state { - OneShotState::Initial(cur_modifiers) | OneShotState::Single(cur_modifiers) => { - self.osm_state = OneShotState::Single(cur_modifiers); - let timeout = Timer::after(self.keymap.one_shot_timeout()); - match select(timeout, self.keyboard_event_subscriber.next_message_pure()).await { - Either::First(_) => { - // Timeout, release modifiers - self.update_osl(event); - self.osm_state = OneShotState::None; - - // Send release report because modifiers were held - if activate_on_keypress { - self.send_keyboard_report_with_resolved_modifiers(false).await; - } - } - Either::Second(e) => { - // New event, send it to queue - if self.unprocessed_events.push(e).is_err() { - warn!("Unprocessed event queue is full, dropping event"); - } - } - } - } - OneShotState::Held(cur_modifiers) => { - let was_active = cur_modifiers & new_modifiers == new_modifiers; - - if !was_active { - return; - } - - // Release modifier - self.update_osl(event); - self.osm_state = OneShotState::None; - - // This sends a separate hid report with the - // currently registered modifiers except the - // one shot modifiers -> this way "releasing" them. - self.send_keyboard_report_with_resolved_modifiers(false).await; - } - _ => (), - }; - } - } - + // OSM (one-shot modifier) is now handled by the unified StickyKey engine + // (see `keyboard/sticky_key.rs`, pure-mod shape). The former `process_action_osm` + // and `update_osm` were removed in Stage 2. + // + // OSL (one-shot layer) is still handled here until Stage 3 ports it into the SK + // engine's layer branch. These functions are temporarily uncalled from the OSM + // dispatch (which was deleted) but stay live: `update_osl` is invoked from the key + // and modifier paths in `keyboard.rs`. + + // TODO(Stage 3): port into process_action_sticky_key layer branch. + #[allow(dead_code)] pub(crate) async fn process_action_osl(&mut self, layer_num: u8, event: KeyboardEvent) { // Update one shot state if event.pressed { @@ -136,7 +62,7 @@ impl<'a> Keyboard<'a> { OneShotState::Initial(l) | OneShotState::Single(l) => { self.osl_state = OneShotState::Single(l); - let timeout = embassy_time::Timer::after(self.keymap.one_shot_timeout()); + let timeout = embassy_time::Timer::after(self.keymap.sticky_key_timeout()); match select(timeout, self.keyboard_event_subscriber.next_message_pure()).await { Either::First(_) => { // Timeout, deactivate layer @@ -160,27 +86,6 @@ impl<'a> Keyboard<'a> { } } - /// Update OSM state based on the keyboard event. - /// Returns `true` if the OSM was consumed (transitioned from Single to None). - pub(crate) fn update_osm(&mut self, event: KeyboardEvent) -> bool { - let quick_release = self.keymap.one_shot_modifiers_config().quick_release; - match self.osm_state { - OneShotState::Initial(m) => { - self.osm_state = OneShotState::Held(m); - false - } - OneShotState::Single(_) if quick_release && event.pressed => { - self.osm_state = OneShotState::None; - true - } - OneShotState::Single(_) if !quick_release && !event.pressed => { - self.osm_state = OneShotState::None; - true - } - _ => false, - } - } - pub(crate) fn update_osl(&mut self, event: KeyboardEvent) { match self.osl_state { OneShotState::Initial(l) => self.osl_state = OneShotState::Held(l), diff --git a/rmk/tests/keyboard_combo_test.rs b/rmk/tests/keyboard_combo_test.rs index 183c8a66d..f16693a57 100644 --- a/rmk/tests/keyboard_combo_test.rs +++ b/rmk/tests/keyboard_combo_test.rs @@ -1,11 +1,11 @@ pub mod common; use embassy_time::Duration; -use rmk::config::{BehaviorConfig, CombosConfig, MorsesConfig, OneShotConfig, OneShotModifiersConfig}; +use rmk::config::{BehaviorConfig, CombosConfig, MorsesConfig, StickyKeyConfig}; use rmk::keyboard::combo::{Combo, ComboConfig}; use rmk::types::keycode::HidKeyCode; use rmk::types::modifier::ModifierCombination; -use rmk::{k, osm, th}; +use rmk::{k, sk_mod, th}; use rmk_types::morse::{MorseMode, MorseProfile}; use crate::common::{KC_LSHIFT, create_test_keyboard_with_config}; @@ -39,7 +39,7 @@ pub fn get_combos_config() -> CombosConfig { k!(T), //1,5 ] .to_vec(), - osm!(ModifierCombination::new_from(false, false, false, true, false)), // one-shot LShift + sk_mod!(ModifierCombination::new_from(false, false, false, true, false)), // one-shot LShift Some(0), ))), Some(Combo::new(ComboConfig::new( @@ -150,7 +150,7 @@ fn test_combo_with_one_shot_modifier() { key_sequence_test! { keyboard: create_test_keyboard_with_config(BehaviorConfig { combo: get_combos_config(), - one_shot: OneShotConfig { + sticky_key: StickyKeyConfig { timeout: Duration::from_millis(300), ..Default::default() }, @@ -455,11 +455,8 @@ fn test_combo_with_one_shot_modifier_quick_release() { key_sequence_test! { keyboard: create_test_keyboard_with_config(BehaviorConfig { combo: get_combos_config(), - one_shot: OneShotConfig { + sticky_key: StickyKeyConfig { timeout: Duration::from_millis(300), - ..Default::default() - }, - one_shot_modifiers: OneShotModifiersConfig { quick_release: true, ..Default::default() }, @@ -486,7 +483,7 @@ fn test_overlapped_combo_quick_release() { key_sequence_test! { keyboard: create_test_keyboard_with_config(BehaviorConfig { combo: get_combos_config(), - one_shot_modifiers: OneShotModifiersConfig { + sticky_key: StickyKeyConfig { quick_release: true, ..Default::default() }, From 35e62c774ad993e5a264137b034fb429f712f30c Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Sat, 6 Jun 2026 15:36:56 -0500 Subject: [PATCH 054/119] fix(engine): clear deadline on pure-mod Held promotion (OSM no-timeout parity); document single-latch shape assumption --- rmk/src/keyboard/sticky_key.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/rmk/src/keyboard/sticky_key.rs b/rmk/src/keyboard/sticky_key.rs index 69c1bb484..51bfa65ba 100644 --- a/rmk/src/keyboard/sticky_key.rs +++ b/rmk/src/keyboard/sticky_key.rs @@ -129,7 +129,10 @@ impl Keyboard<'_> { StickyKeyState::Active { mods, deadline: d, .. } => { - // Accumulate (3c) and refresh the timeout deadline. + // Accumulate (3c) and refresh the timeout deadline. The unified latch holds + // at most one SK at a time; pressing a different-shaped SK while one is active + // accumulates onto the existing latch rather than replacing it (no test or + // spec covers concurrent mixed shapes — single-latch assumption). *mods |= params.keep; *d = deadline; } @@ -234,10 +237,15 @@ impl Keyboard<'_> { match &mut self.sticky_key_state { StickyKeyState::Active { phase: phase @ SkPhase::Pressed, + deadline, .. } => { // A key was pressed while the SK is still physically held → promote to Held. + // OSM `Held` has no timeout: the modifier stays live until the SK is physically + // released (held-alt-tab use case). Clear the run-loop deadline so it does not + // spuriously time-out while held. *phase = SkPhase::Held; + *deadline = None; false } StickyKeyState::Active { From f532398fd45e4d93e5aace4b04e21ced8337a19f Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Sat, 6 Jun 2026 15:37:49 -0500 Subject: [PATCH 055/119] docs(plan): mark Stage 2 complete; record reviews, test gate, and review-fix adjudication --- .../plans/2026-06-03-sk-absorbs-oneshot-plan.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md b/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md index 98b4a512d..38f5017d2 100644 --- a/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md +++ b/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md @@ -535,6 +535,14 @@ git commit -m "refactor(engine): retire OSM path + inline select; remove OSM/OSL **Stage 2 Gate:** All OSM-behavior tests + all (non-deferred) SK tests + the 3b/3c regressions green. Inline `select` gone. `unprocessed_events` queue retained (Clear Peer), OSM/OSL producers removed. Clippy clean in touched files. +**STAGE 2 COMPLETE (2026-06-06).** Executed as one combined work unit (see execution decision above). Commits: `e0a67bfb` (pure-mod 3a/3b/3c) · `e9a0c092` (retire OSM path + inline select; remove OSM/OSL `unprocessed_events` producers, keep queue for Clear Peer) · `35e62c77` (code-quality review fix). Both review gates passed: **spec ✅** (independently verified by code read + test run) and **code-quality ✅ APPROVED**. +- **nextest: 466 run, 461 passed, 5 failed.** The 5 failures are EXACTLY the deferred OSL behavior tests — `test_osl_basic_single_behavior`, `test_osl_held_behavior`, `test_osl_multiple_keys`, `test_osl_then_osm`, `test_osm_then_osl` (Stage 3). All 25 OSM tests, all non-deferred SK tests, and both new regressions (`test_sk_puremod_terminating_key` 3b, `test_sk_puremod_cross_tap_accumulation` 3c) are green. Note: `test_osl_timeout`/`test_osm_and_osl_timeout` pass only because their expected base-layer/no-mod output coincides with the no-OSL-layer result. +- Engine: `process_action_sticky_key` dispatches by shape → `process_sticky_pure_mod` (OSM port: accumulation, `activate_on_keypress`/`quick_release`, terminating-key application via `update_sticky_key` foreign-key hook + `resolve_explicit_modifiers`) and `process_sticky_tap_key` (alt-tab cycling, `max_repeat`). Single timeout = run-loop deadline race. +- OSM fully retired: `process_action_osm`/`update_osm` deleted from `oneshot.rs`; `Action::OneShotModifier`/`OneShotLayer` dispatch arms + `osm_state` field + its `resolve_explicit_modifiers` branch removed; `one_shot_modifiers_config()` → `sticky_key_config()` in `keymap.rs`; five `exit_on_layer_change()` sites read `config.release_on_layer_change`. +- OSL kept compiling for Stage 3 (`process_action_osl`/`update_osl`/`OneShotState` retained; `#[allow(dead_code)]`+TODO on the now-uncalled `process_action_osl`; layer branch in `process_action_sticky_key` is an early-return stub). +- **Scope note:** `keyboard_combo_test.rs` was also migrated off the removed OSM API (Stage 1 left it broken; mechanical `osm!`→`sk_mod!` + config rename, no assertion changes) — needed for the suite to compile. +- **Code-quality review adjudication (controller):** #1 *Held pure-mod spurious timeout* — ACCEPTED & FIXED in `35e62c77` (clear deadline on Held promotion; restores OSM's no-timeout-while-held parity, a real divergence not in the accepted-changes list). #2 *cross-shape latch contamination* — behavior DEFERRED (unspecified concurrent-mixed-shape case; no test/spec); documented with a single-latch-assumption comment. #3 *`repeat_count` u16 overflow* — pre-existing, carried over unchanged; left per surgical-changes rule (noted only). + --- ## Stage 3 — Engine: absorb OSL From 0171d6d7833a597cac089603595d3c6117c479c1 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Sat, 6 Jun 2026 15:47:05 -0500 Subject: [PATCH 056/119] =?UTF-8?q?docs(plan):=20record=20Stage=203=20D1/D?= =?UTF-8?q?2=20design=20decision=20=E2=80=94=20preserve=20OSM+OSL=20combin?= =?UTF-8?q?ation=20behavior=20on=20single=20latch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md b/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md index 38f5017d2..6a1f2c984 100644 --- a/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md +++ b/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md @@ -551,6 +551,8 @@ git commit -m "refactor(engine): retire OSM path + inline select; remove OSM/OSL **Gate:** all OSL-behavior tests green; full suite + `cargo clippy` clean. +**STAGE 3 DESIGN DECISION (2026-06-06, confirmed by user): D1/D2 — PRESERVE existing OSM+OSL combination behavior exactly.** The OSL layer shape lives on the SAME single mutually-exclusive `sticky_key_state` latch (DP-1), not a separate field and not a combined mod+layer latch. Rule: a newly-pressed SK shape that lands on an active latch of a *different* shape REPLACES it — dropping the latched mod (→ D1: `test_osm_then_osl` emits `[0, C]`, no LShift) and/or deactivating the latched layer before applying the new shape (→ D2: `test_osl_then_osm` emits `[LShift|LCtrl, A]` because col0 resolves on the still-active layer 1 to `OSM(LShift|LCtrl)`, then that OSM replaces the OSL latch and deactivates layer 1). Same-shape mod+mod still accumulates (Stage 2 cross-tap behavior, unchanged). This is a pure behavior-preserving refactor: D1/D2 are NOT accepted behavior changes — the 5 OSL tests stay as-written and are the grading contract. Rationale: matches the parity catalogue's "the SK engine must reproduce this outcome" note (D1), keeps the one-engine/one-latch model from DP-1, and is the lowest-risk path to green. A combined-latch "fix" was rejected as scope creep with no anchoring test. + ### Task 3.1: Layer shape on the unified latch **Files:** From fe1e4ec6fb9753c60def345e5d5752b235bdbb31 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Sat, 6 Jun 2026 22:11:14 -0500 Subject: [PATCH 057/119] =?UTF-8?q?feat(engine):=20absorb=20OSL=20?= =?UTF-8?q?=E2=80=94=20SK(MO(n))=20layer=20shape=20on=20unified=20latch;?= =?UTF-8?q?=20delete=20oneshot.rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rmk/src/keyboard.rs | 8 --- rmk/src/keyboard/oneshot.rs | 99 ------------------------------ rmk/src/keyboard/sticky_key.rs | 108 ++++++++++++++++++++++++++++++--- 3 files changed, 99 insertions(+), 116 deletions(-) delete mode 100644 rmk/src/keyboard/oneshot.rs diff --git a/rmk/src/keyboard.rs b/rmk/src/keyboard.rs index 974eabd9d..f9ee36e82 100644 --- a/rmk/src/keyboard.rs +++ b/rmk/src/keyboard.rs @@ -28,7 +28,6 @@ use crate::keyboard::combo::Combo; use crate::keyboard::fork::ActiveFork; use crate::keyboard::held_buffer::{HeldBuffer, HeldKey, KeyState}; use crate::keyboard::mouse::{MouseAction, MouseState}; -use crate::keyboard::oneshot::OneShotState; use crate::keyboard::sticky_key::{SkPhase, StickyKeyState}; use crate::keyboard_macros::MacroOperation; use crate::keymap::KeyMap; @@ -41,7 +40,6 @@ pub(crate) mod fork; pub(crate) mod held_buffer; pub(crate) mod morse; pub(crate) mod mouse; -pub(crate) mod oneshot; #[cfg(feature = "steno")] pub(crate) mod steno; pub(crate) mod sticky_key; @@ -215,9 +213,6 @@ pub struct Keyboard<'a> { /// Used in repeat-key last_key_code: KeyCode, - /// Oneshot Layer state - osl_state: OneShotState, - /// StickyKey state — holds a modifier+key combination across key presses sticky_key_state: StickyKeyState, @@ -272,7 +267,6 @@ impl<'a> Keyboard<'a> { keymap, keyboard_event_subscriber: KeyboardEvent::subscriber(), last_press_time: Instant::now(), - osl_state: OneShotState::default(), sticky_key_state: StickyKeyState::default(), caps_word: CapsWordState::default(), with_modifiers: ModifierCombination::default(), @@ -1288,7 +1282,6 @@ impl<'a> Keyboard<'a> { } //report the modifier press/release in its own hid report self.send_keyboard_report_with_resolved_modifiers(event.pressed).await; - self.update_osl(event); } Action::TriggerMacro(macro_idx) => self.execute_macro(macro_idx, event).await, Action::KeyWithModifier(key_code, modifiers) => { @@ -1581,7 +1574,6 @@ impl<'a> Keyboard<'a> { if quick_release && sk_consumed && key.is_basic_keyboard_key() && event.pressed { self.send_keyboard_report_with_resolved_modifiers(true).await; } - self.update_osl(event); } /// Process layer switch action. diff --git a/rmk/src/keyboard/oneshot.rs b/rmk/src/keyboard/oneshot.rs deleted file mode 100644 index b7a35af12..000000000 --- a/rmk/src/keyboard/oneshot.rs +++ /dev/null @@ -1,99 +0,0 @@ -use embassy_futures::select::{Either, select}; - -use crate::event::KeyboardEvent; -use crate::keyboard::Keyboard; - -/// State machine for one shot keys -#[derive(Default)] -pub enum OneShotState { - /// First one shot key press - Initial(T), - /// One shot key was released before any other key, normal one shot behavior - Single(T), - /// Another key was pressed before one shot key was released, treat as a normal modifier/layer - Held(T), - /// One shot inactive - #[default] - None, -} - -impl OneShotState { - /// Get the current one shot value if any - pub fn value(&self) -> Option<&T> { - match self { - OneShotState::Initial(v) | OneShotState::Single(v) | OneShotState::Held(v) => Some(v), - OneShotState::None => None, - } - } -} - -impl<'a> Keyboard<'a> { - // OSM (one-shot modifier) is now handled by the unified StickyKey engine - // (see `keyboard/sticky_key.rs`, pure-mod shape). The former `process_action_osm` - // and `update_osm` were removed in Stage 2. - // - // OSL (one-shot layer) is still handled here until Stage 3 ports it into the SK - // engine's layer branch. These functions are temporarily uncalled from the OSM - // dispatch (which was deleted) but stay live: `update_osl` is invoked from the key - // and modifier paths in `keyboard.rs`. - - // TODO(Stage 3): port into process_action_sticky_key layer branch. - #[allow(dead_code)] - pub(crate) async fn process_action_osl(&mut self, layer_num: u8, event: KeyboardEvent) { - // Update one shot state - if event.pressed { - // Deactivate old layer if any - if let Some(&l) = self.osl_state.value() { - self.keymap.deactivate_layer(l); - } - - // Update layer of one shot - self.osl_state = match self.osl_state { - OneShotState::None => OneShotState::Initial(layer_num), - OneShotState::Initial(_) => OneShotState::Initial(layer_num), - OneShotState::Single(_) => OneShotState::Single(layer_num), - OneShotState::Held(_) => OneShotState::Held(layer_num), - }; - - // Activate new layer - self.keymap.activate_layer(layer_num); - } else { - match self.osl_state { - OneShotState::Initial(l) | OneShotState::Single(l) => { - self.osl_state = OneShotState::Single(l); - - let timeout = embassy_time::Timer::after(self.keymap.sticky_key_timeout()); - match select(timeout, self.keyboard_event_subscriber.next_message_pure()).await { - Either::First(_) => { - // Timeout, deactivate layer - self.keymap.deactivate_layer(layer_num); - self.osl_state = OneShotState::None; - } - Either::Second(e) => { - // New event, send it to queue - if self.unprocessed_events.push(e).is_err() { - warn!("Unprocessed event queue is full, dropping event"); - } - } - } - } - OneShotState::Held(layer_num) => { - self.osl_state = OneShotState::None; - self.keymap.deactivate_layer(layer_num); - } - _ => (), - }; - } - } - - pub(crate) fn update_osl(&mut self, event: KeyboardEvent) { - match self.osl_state { - OneShotState::Initial(l) => self.osl_state = OneShotState::Held(l), - OneShotState::Single(layer_num) if !event.pressed => { - self.keymap.deactivate_layer(layer_num); - self.osl_state = OneShotState::None; - } - _ => (), - } - } -} diff --git a/rmk/src/keyboard/sticky_key.rs b/rmk/src/keyboard/sticky_key.rs index 51bfa65ba..8a22fee95 100644 --- a/rmk/src/keyboard/sticky_key.rs +++ b/rmk/src/keyboard/sticky_key.rs @@ -95,13 +95,9 @@ impl StickyKeyState { impl Keyboard<'_> { pub(crate) async fn process_action_sticky_key(&mut self, params: StickyKeyAction, event: KeyboardEvent) { - // Layer (OSL) shape — Stage 3. Minimal stub: must compile and not panic. - // TODO(Stage 3): port OSL behavior into this branch (see oneshot.rs `process_action_osl`). if params.layer.is_some() { - return; - } - - if params.key == KeyCode::Hid(HidKeyCode::No) { + self.process_sticky_layer(params, event).await; + } else if params.key == KeyCode::Hid(HidKeyCode::No) { self.process_sticky_pure_mod(params, event).await; } else { self.process_sticky_tap_key(params, event).await; @@ -115,6 +111,13 @@ impl Keyboard<'_> { let deadline = (config.timeout != Duration::MAX).then(|| Instant::now() + config.timeout); if event.pressed { + // Latch-replacement rule: a pure-mod press accumulates onto an existing pure-mod + // latch, but REPLACES a latched layer (deactivate it and drop the layer; the + // single mutually-exclusive latch holds at most one SK). + if let StickyKeyState::Active { layer: Some(layer_num), .. } = self.sticky_key_state { + self.keymap.deactivate_layer(layer_num); + self.sticky_key_state = StickyKeyState::None; + } match &mut self.sticky_key_state { StickyKeyState::None => { self.sticky_key_state = StickyKeyState::Active { @@ -166,6 +169,70 @@ impl Keyboard<'_> { } } + /// Layer (OSL) shape: activate the layer for the next foreign key. Mirrors the former + /// `process_action_osl`. The layer carries no modifier, so consuming it emits no HID + /// report — the foreign key resolves on the active layer in `process_action_key` before + /// the latch is consumed. + async fn process_sticky_layer(&mut self, params: StickyKeyAction, event: KeyboardEvent) { + let layer_num = params.layer.expect("layer shape requires a layer"); + let config = self.keymap.sticky_key_config(); + let deadline = (config.timeout != Duration::MAX).then(|| Instant::now() + config.timeout); + + if event.pressed { + // Latch-replacement rule on a single mutually-exclusive latch: a layer SK press + // takes over the latch. Deactivate any previously-latched OSL layer first, then + // drop any latched mods/tap-key. A layer-on-layer press keeps the existing phase + // (mirrors old `process_action_osl` lines 51-56); any other shape becomes a fresh + // Pressed latch. + let prev_phase = match self.sticky_key_state { + StickyKeyState::Active { + layer: Some(prev_layer), + phase, + .. + } => { + self.keymap.deactivate_layer(prev_layer); + phase + } + _ => SkPhase::Pressed, + }; + + self.keymap.activate_layer(layer_num); + self.sticky_key_state = StickyKeyState::Active { + mods: params.keep, + key: params.key, + layer: Some(layer_num), + phase: prev_phase, + repeat_count: 1, + deadline, + }; + } else { + // SK released. + match self.sticky_key_state { + StickyKeyState::Active { + phase: SkPhase::Pressed | SkPhase::Latched, + .. + } => { + // Released before any other key → arm it for the next key and (re)arm the + // deadline so the run-loop race covers expiry. + if let StickyKeyState::Active { phase, deadline: d, .. } = &mut self.sticky_key_state { + *phase = SkPhase::Latched; + *d = deadline; + } + } + StickyKeyState::Active { + phase: SkPhase::Held, + .. + } => { + // Held-mode: the layer stayed active while the SK was physically held. + // Releasing the SK deactivates the layer now (no HID report). + self.keymap.deactivate_layer(layer_num); + self.sticky_key_state = StickyKeyState::None; + } + StickyKeyState::None => {} + } + } + } + /// Tap-key (alt-tab) shape: send `keep` mods + `key` on every press, hold the mods /// between presses, cycle on each press (`max_repeat`). Ignores /// `activate_on_keypress`/`quick_release`. @@ -228,9 +295,25 @@ impl Keyboard<'_> { /// phase transitions on the terminating key and returns `true` when the latch was /// consumed (so the caller can emit a quick-release report). /// - /// Tap-key and layer shapes are untouched here — they are consumed elsewhere. + /// Tap-key shape is untouched here — it is consumed elsewhere. pub(crate) fn update_sticky_key(&mut self, event: KeyboardEvent) -> bool { - if !self.sticky_key_state.is_pure_mod() { + if !self.sticky_key_state.is_pure_mod() && !self.sticky_key_state.is_layer() { + return false; + } + // Layer (OSL) shape: mirror the former `update_osl`. Pressed→Held on a foreign key + // (handled by the shared Pressed arm below, which also clears the deadline). A Latched + // layer is consumed on the foreign key's RELEASE: deactivate the layer and clear the + // latch. No HID report — deactivating a layer emits nothing. + if let StickyKeyState::Active { + phase: SkPhase::Latched, + layer: Some(layer_num), + .. + } = self.sticky_key_state + { + if !event.pressed { + self.keymap.deactivate_layer(layer_num); + self.sticky_key_state = StickyKeyState::None; + } return false; } let quick_release = self.keymap.sticky_key_config().quick_release; @@ -279,6 +362,7 @@ impl Keyboard<'_> { // emitted the modifier early. A bare Latched pure-mod that times out before any // key (and without early activation) never emitted the modifier, so releasing it // must NOT produce a spurious empty report. Mirrors the former OSM timeout path. + // - layer shape: deactivating a layer emits nothing → never report. let needs_report = if self.sticky_key_state.is_pure_mod() { let activate_on_keypress = self.keymap.sticky_key_config().activate_on_keypress; matches!( @@ -289,9 +373,15 @@ impl Keyboard<'_> { } ) || activate_on_keypress } else { - true + // tap-key shape always reports; layer shape never does (deactivating emits nothing). + !self.sticky_key_state.is_layer() }; + // For the layer shape, deactivate the active layer before clearing the latch. + if let StickyKeyState::Active { layer: Some(layer_num), .. } = self.sticky_key_state { + self.keymap.deactivate_layer(layer_num); + } + self.sticky_key_state = StickyKeyState::None; if needs_report { self.send_keyboard_report_with_resolved_modifiers(false).await; From 9f03aac5d69a94a6829f32d58604cd2d7a96afd6 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Sat, 6 Jun 2026 23:04:37 -0500 Subject: [PATCH 058/119] docs(engine): fix stale unprocessed_events comment + document bare-modifier OSL non-consume (Stage 3 review fixes) --- rmk/src/keyboard.rs | 7 ++++--- rmk/src/keyboard/sticky_key.rs | 6 ++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/rmk/src/keyboard.rs b/rmk/src/keyboard.rs index f9ee36e82..7a1b6e3a7 100644 --- a/rmk/src/keyboard.rs +++ b/rmk/src/keyboard.rs @@ -142,9 +142,10 @@ impl Runnable for Keyboard<'_> { async fn run(&mut self) -> ! { loop { // `unprocessed_events` is still required: the Clear Peer BLE path - // (`#[cfg(feature = "split")]`, see below) and the OSL inline-select path push - // events here for re-processing. Do NOT delete the queue or this consumer. - // (The OSM producers were removed in Stage 2 once OSM moved to the SK engine.) + // (`#[cfg(feature = "split")]`, see below) pushes events here for re-processing. + // Do NOT delete the queue or this consumer. + // (The OSM/OSL producers were removed once those behaviors moved to the SK engine, + // whose timeout is now driven by the deadline race below rather than an inline select.) if !self.unprocessed_events.is_empty() { // Process unprocessed events let e = self.unprocessed_events.remove(0); diff --git a/rmk/src/keyboard/sticky_key.rs b/rmk/src/keyboard/sticky_key.rs index 8a22fee95..14c0508bf 100644 --- a/rmk/src/keyboard/sticky_key.rs +++ b/rmk/src/keyboard/sticky_key.rs @@ -296,6 +296,12 @@ impl Keyboard<'_> { /// consumed (so the caller can emit a quick-release report). /// /// Tap-key shape is untouched here — it is consumed elsewhere. + /// + /// Called only from `process_action_key` (basic keys), so a bare `Action::Modifier` + /// no longer consumes a latched OSL the way the former `update_osl` did from the + /// modifier path — only a non-modifier key, a layer change, or timeout consumes it. + /// This narrowing is intentional (a held modifier is not a "terminating key") and + /// matches how tap-key SKs already ignore bare modifiers. pub(crate) fn update_sticky_key(&mut self, event: KeyboardEvent) -> bool { if !self.sticky_key_state.is_pure_mod() && !self.sticky_key_state.is_layer() { return false; From fe44f3b3a85f534a2325eed207f2842ce7783270 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Sat, 6 Jun 2026 23:05:10 -0500 Subject: [PATCH 059/119] docs(plan): mark Stage 3 complete; record reviews, test gate, and review-fix adjudication --- .../plans/2026-06-03-sk-absorbs-oneshot-plan.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md b/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md index 6a1f2c984..bcb470324 100644 --- a/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md +++ b/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md @@ -598,6 +598,16 @@ git commit -m "chore: clippy clean + full-suite green after OSM/OSL absorption ( --- +**STAGE 3 COMPLETE (2026-06-06).** Commits: `fe1e4ec6` (feat: absorb OSL — SK(MO(n)) layer shape on unified latch; delete oneshot.rs) · `9f03aac5` (docs(engine): Stage 3 review fixes). Plan-decision commit `0171d6d7` (D1/D2 design). + +- **Engine:** layer shape implemented on the single mutually-exclusive `sticky_key_state` latch — `process_sticky_layer` (activate/arm/deadline on press; Latched-arm or Held-deactivate on release), `update_sticky_key` extended to drive the layer shape (Pressed→Held promotion via the shared arm; Latched consumed + layer deactivated on the foreign key's RELEASE), `release_sticky_key_if_active` deactivates the active layer and suppresses the HID report for the layer shape. Latch-replacement rule per the Stage 3 design decision (pure-mod↔layer replace; layer-on-layer deactivate-old/activate-new keeping phase; pure-mod+pure-mod still accumulates). No inline select/Timer — timeout is the run-loop deadline race only. +- **Cleanup:** `oneshot.rs` DELETED (DP-2); removed `osl_state` field+init, `OneShotState` import, `mod oneshot`, and both `update_osl` call sites from `keyboard.rs`. `grep oneshot/OneShotState/update_osl/process_action_osl/osl_state rmk/src` → no live references. +- **Tests:** `cargo nextest ... --features=split,vial,storage,async_matrix,_ble` → **466 run, 466 passed, 0 skipped.** All 7 layer tests green (`test_osl_basic_single_behavior`, `test_osl_held_behavior`, `test_osl_multiple_keys`, `test_osl_timeout`, `test_osm_then_osl` [D1 `[0,C]`], `test_osl_then_osm` [D2 `[LShift|LCtrl,A]`], `test_osm_and_osl_timeout`). No assertions changed. Clippy clean. +- **Reviews:** spec ✅ (independently verified, 466/466) and code-quality ✅ "Ready to merge: Yes" (only Minor items). Adjudication: fixed the stale `unprocessed_events` comment (its OSL inline-select producer was deleted this stage) + added a doc note that a bare `Action::Modifier` intentionally no longer consumes a latched OSL (untested divergence beyond accepted D1/D2; the implementer flagged it too) → commit `9f03aac5`. Left as-is (defensible/pre-existing per surgical rule): `process_sticky_layer` async-without-await (dispatch symmetry), the defensive `Latched`-source release arm (actually reachable via layer-on-layer re-press), and the pre-existing unused `StickyKeyState::value()`. +- **Deferred to Stage 3.2 / later:** the repo-root `sh scripts/test_all.sh` full feature-matrix run and `OneShotKey` (OSK) untouched-confirmation are the remaining Stage 3.2 gate steps; can fold into Stage 5 local verification. + +--- + ## Stage 4 — Docs (Section 6 requirement) **Goal:** Document the pure-mod vs tap-key shape distinction — specifically that `activate_on_keypress` and `quick_release` are honored **only for pure-mod SKs and silently ignored for tap-key SKs** — prominently in the keymap config reference and the `[behavior.sticky_key]` section. Include the rationale (a tap-key has nothing to defer) and the three-shape table from the spec Overview. From 242d59458c728483f1b754169bfa28260d21f328 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Sun, 7 Jun 2026 11:37:37 -0500 Subject: [PATCH 060/119] docs: SK shapes + unified [behavior.sticky_key]; pure-mod vs tap-key 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 --- docs/docs/main/docs/configuration/appendix.md | 12 +-- docs/docs/main/docs/configuration/behavior.md | 93 ++++++++++++------- docs/docs/main/docs/configuration/layout.md | 15 +-- 3 files changed, 74 insertions(+), 46 deletions(-) diff --git a/docs/docs/main/docs/configuration/appendix.md b/docs/docs/main/docs/configuration/appendix.md index a35c32cc4..72e26e7d3 100644 --- a/docs/docs/main/docs/configuration/appendix.md +++ b/docs/docs/main/docs/configuration/appendix.md @@ -115,15 +115,13 @@ tri_layer = { adjust = 3, } -# OneShot configuration -one_shot = { - timeout = "1s" -} - -# One Shot Modifiers configuration -one_shot_modifiers = { +# Sticky Key configuration (replaces the former one_shot / one_shot_modifiers tables) +sticky_key = { + timeout = "1s", activate_on_keypress = false, quick_release = false, + max_repeat = 0, + release_on_layer_change = false, } [behavior.morse] diff --git a/docs/docs/main/docs/configuration/behavior.md b/docs/docs/main/docs/configuration/behavior.md index 1df233282..dfc7245ca 100644 --- a/docs/docs/main/docs/configuration/behavior.md +++ b/docs/docs/main/docs/configuration/behavior.md @@ -9,12 +9,9 @@ tri_layer = { lower = 2, adjust = 3, } -one_shot = { +sticky_key = { timeout = "1s", } -one_shot_modifiers = { - activate_on_keypress = false, -} ``` ## Tri Layer @@ -34,65 +31,95 @@ In this example, when both layers 1 (`upper`) and 2 (`lower`) are active, layer Note that `"#layer_name"` could also be used in place of layer numbers. -## One-Shot +## Sticky Key -The `one_shot` sub-table contains common one-shot configuration (for both OSM and OSL) +The `[behavior.sticky_key]` table configures the unified **Sticky Key** (`SK`) feature. `SK` replaces the former `OSM` (one-shot modifier) and `OSL` (one-shot layer) actions, which have been removed and are now a build error. -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`). +### SK shapes -## One-Shot Modifiers +`SK` selects its behavior based on the shape of its argument: -The `one_shot_modifiers` sub-table configures one-shot modifiers (OSM). +| Shape | Syntax | Behavior | +|-------|--------|----------| +| Pure-mod | `SK(LGui)` (modifiers chain like `WM`, e.g. `SK(LCtrl\|LShift)`) | One-shot modifier — the modifier is held for the next key press, then released automatically. | +| Layer | `SK(MO(n))` | One-shot layer — layer `n` is active for the next key press, then released. | +| Tap-key | `SK(Tab, [LAlt])` (the modifier list is in `[ ]`; modifiers chain, e.g. `SK(Tab, [LCtrl\|LShift])`) | The modifier stays held across **repeated presses of the same key** (Alt+Tab-style window/tab cycling): the first press sends `modifier + key`, each subsequent press keeps the modifier held. Releases automatically when any non-SK, non-modifier key is pressed. | -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). +### Config fields -If you press One-Shot Modifier again, it will be sent as a normal modifier key press and, therefore, released. +| Field | Default | Meaning | +|-------|---------|---------| +| `timeout` | `"1s"` | Auto-release an unused sticky key after this idle time. String suffixed `s` or `ms`. | +| `activate_on_keypress` | `false` | **Pure-mod SKs only.** When `true`, send the modifier immediately as the SK key itself is pressed, instead of waiting and applying it to the next key. (Also known as One-Shot Sticky Modifiers / OSSM.) | +| `quick_release` | `false` | **Pure-mod SKs only.** Release the modifier as soon as the next key is *pressed* (`true`) rather than when it is *released* (`false`, chain mode). | +| `max_repeat` | `0` | Max number of keys the sticky modifier applies to; `0` = unlimited. | +| `release_on_layer_change` | `false` | Whether a layer change releases the sticky key. `false` = it survives layer changes. | -The `quick_release` option controls when the one-shot modifier is released: +The `quick_release` option in detail: - `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. +:::warning + +`activate_on_keypress` and `quick_release` are honored **only for pure-mod SKs** (`SK(LGui)`). They are **silently ignored** for tap-key SKs (`SK(Tab, [LAlt])`) and layer SKs (`SK(MO(n))`). Both fields tune *when a one-shot modifier is sent and released*: a tap-key SK sends its modifier eagerly and deliberately holds it across repeats, and a layer SK sends no modifier at all — so neither has anything for these fields to tune. + +::: + Default values: + ```toml -[behavior.one_shot_modifiers] +[behavior.sticky_key] +timeout = "1s" activate_on_keypress = false quick_release = false +max_repeat = 0 +release_on_layer_change = false ``` -OSSM example: +OSSM example (pure-mod SK activates on key press): + ```toml -[behavior.one_shot_modifiers] +[behavior.sticky_key] activate_on_keypress = true ``` -Quick-release example: +Quick-release example (modifier released when next key is pressed): + ```toml -[behavior.one_shot_modifiers] +[behavior.sticky_key] quick_release = true ``` -## Sticky Key +Longer timeout example: + +```toml +[behavior.sticky_key] +timeout = "5s" +``` -The `sticky_key` sub-table configures the StickyKey (`SK`) feature. +For keymap usage, see `SK(...)` in the [keymap configuration](./layout#keyboard-layout-configuration). -`SK(key, [modifier])` holds a modifier across repeated presses of the same key. This is useful for Alt+Tab-style window/tab cycling: the first press sends `modifier + key` (e.g. Alt+Tab), then on each subsequent press the modifier stays held so only `key` is sent again. The modifier releases automatically when any non-SK, non-modifier key is pressed. +### Migration from OSM / OSL -This differs from `OSM` (one-shot modifier), which sends the modifier only once and always releases after the next keypress. +The former `OSM`, `OSL`, the 5-positional `SK` form, and the `[behavior.one_shot]` / `[behavior.one_shot_modifiers]` tables are **removed** — using them is a build error. -Default behavior: no timeout, infinite repeats, modifier survives layer changes. +| Old | New | +|-----|-----| +| `OSM(LGui)` | `SK(LGui)` | +| `OSL(1)` | `SK(MO(1))` | +| `SK(Tab, [LAlt], 0, 0, false)` (5-positional) | `SK(Tab, [LAlt])` + `[behavior.sticky_key]` | +| `[behavior.one_shot]` `timeout` | `[behavior.sticky_key]` `timeout` | +| `[behavior.one_shot_modifiers]` `activate_on_keypress` / `quick_release` | `[behavior.sticky_key]` `activate_on_keypress` / `quick_release` | +| `exit_on_layer_change` | `release_on_layer_change` | -Optional global timeout example: -```toml -[behavior.sticky_key] -timeout = "5s" # auto-release the modifier after 5 seconds of inactivity -``` +Accepted breaking changes: -To use StickyKey in your keymap, see `SK(key, [modifier])` in the [keymap configuration](./layout#keymap-config). +- `OSM(...)` and `OSL(...)` keymap actions are **removed** → build error. Use `SK(mod)` and `SK(MO(n))`. +- The old 5-positional `SK(key, [mod], max_repeat, timeout_ms, exit_on_layer_change)` form is **removed** → build error. The trailing knobs now live in `[behavior.sticky_key]`. +- The `[behavior.one_shot]` and `[behavior.one_shot_modifiers]` config tables are **removed** → use `[behavior.sticky_key]`. +- The old per-key `exit_on_layer_change` is renamed to the global `release_on_layer_change` (default `false`). +- Tap-key (alt-tab) SKs now have a **1s default timeout** (previously they had no timeout). Set `timeout` higher or rely on the default. ## Combo @@ -449,7 +476,7 @@ keymap = [ ["A", "B", "C"], ["TD(0)", "TD(1)", "TD(2)"], # Use morse dances 0, 1, and 2 ["LCtrl", "MO(1)", "LShift"], - ["OSL(1)", "LT(2, Kc9, PN)", "LM(1, LShift | LGui)"] # PN is a morse profile name here + ["SK(MO(1))", "LT(2, Kc9, PN)", "LM(1, LShift | LGui)"] # PN is a morse profile name here [ ["_", "TT(1)", "TG(2)"], ["_", "_", "_"], diff --git a/docs/docs/main/docs/configuration/layout.md b/docs/docs/main/docs/configuration/layout.md index b9f3e8045..061029a0b 100644 --- a/docs/docs/main/docs/configuration/layout.md +++ b/docs/docs/main/docs/configuration/layout.md @@ -122,12 +122,15 @@ The `layer.keys` string should follow several rules: 2. Use `MO(n)` to create a layer activate action, `n` is the layer number 3. Use `LM(n, modifier)` to create layer activate with modifier action. The modifier can be chained in the same way as `WM` 4. Use `LT(n, key, )` to create a layer activate action or tap key(tap/hold). The `key` here is the RMK [`KeyCode`](https://docs.rs/rmk/latest/rmk/keycode/enum.KeyCode.html), The `profile_name` is optional, which defines the key's [profile](./behavior#per-key-profiles-for-morse-tapdance-tap-hold-fine-tuning) - 5. Use `OSL(n)` to create a one-shot layer action, `n` is the layer number - 6. Use `OSM(modifier)` to create a one-shot modifier action. The modifier can be chained in the same way as `WM` - 7. Use `SK(key, [modifier])` to create a sticky key action. The modifier stays held across repeated presses of `key` until any non-SK key is pressed — useful for Alt+Tab-style cycling. The modifier can be chained in the same way as `WM` (e.g. `SK(Tab, [LCtrl|LShift])`). Optional positional args: `SK(key, [mod], max_repeat, timeout_ms, exit_on_layer_change)`. See [Sticky Key](./behavior#sticky-key) for global timeout configuration. - 8. Use `TT(n)` to create a layer activate or tap toggle action, `n` is the layer number - 9. Use `TG(n)` to create a layer toggle action, `n` is the layer number - 10. Use `TO(n)` to create a layer toggle only action (activate layer `n` and deactivate all other layers), `n` is the layer number + 5. Use `SK(...)` to create a sticky key action — behavior is selected by argument shape: + - `SK(modifier)` — one-shot modifier (replaces the removed `OSM`): the modifier is held for the next key press, then released automatically. Modifiers chain like `WM`, e.g. `SK(LCtrl|LShift)`. + - `SK(MO(n))` — one-shot layer (replaces the removed `OSL`): layer `n` is active for the next key press, then released. + - `SK(key, [modifier])` — tap-key (Alt+Tab-style cycling): the modifier stays held across repeated presses of `key` until any non-SK, non-modifier key is pressed. The modifier list is in `[ ]` and can be chained, e.g. `SK(Tab, [LCtrl|LShift])`. + + See [Sticky Key](./behavior#sticky-key) for global config (`timeout`, `activate_on_keypress`, `quick_release`, etc.). + 6. Use `TT(n)` to create a layer activate or tap toggle action, `n` is the layer number + 7. Use `TG(n)` to create a layer toggle action, `n` is the layer number + 8. Use `TO(n)` to create a layer toggle only action (activate layer `n` and deactivate all other layers), `n` is the layer number The definitions of these operations are the same as QMK's; you can find them [here](https://docs.qmk.fm/#/feature_layers). If you want other actions, please [file an issue](https://github.com/HaoboGu/rmk/issues/new). From d34538f1fa0578860e953726c43b8dc20e3eeca9 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:44:14 -0500 Subject: [PATCH 061/119] docs(plan): mark Stage 4 complete; record doc rewrite, reviews, and fix adjudication --- .../plans/2026-06-03-sk-absorbs-oneshot-plan.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md b/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md index bcb470324..11763f5f0 100644 --- a/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md +++ b/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md @@ -637,6 +637,14 @@ git commit -m "docs: SK shapes + unified [behavior.sticky_key]; pure-mod vs tap- --- +**STAGE 4 COMPLETE (2026-06-08).** Commit: `242d5945` (docs: SK shapes + unified [behavior.sticky_key]; pure-mod vs tap-key magic-field rule). 3 files, +74/−46. + +- **Files:** `docs/docs/main/docs/configuration/behavior.md` (merged the three old sections — `## One-Shot`, `## One-Shot Modifiers`, `## Sticky Key` — into one `## Sticky Key` section: three-shape table, 5-field config table with defaults, chain-mode vs quick-release detail, `:::warning` magic-field callout, default-values block, OSSM/quick-release/longer-timeout examples, and a `### Migration from OSM / OSL` subsection with table + accepted-breaks bullets; fixed the morse example `OSL(1)`→`SK(MO(1))`), `layout.md` (deleted the `OSL(n)`/`OSM(modifier)` list items, merged into one `SK(...)` item documenting all three shapes, dropped the removed 5-positional sentence, renumbered the list 1–8), `appendix.md` (replaced the `one_shot`/`one_shot_modifiers` example blocks with one `sticky_key` block listing all 5 fields). +- **Reviews:** spec ✅ (independently verified) and code-quality ✅ "Ready to merge: Yes" after fixes. Adjudication — all findings ACCEPTED & FIXED: (spec) appendix block was missing `max_repeat`/`release_on_layer_change` → added all 5; magic-field rationale imprecise for the layer case → rewrote to "a tap-key SK sends its modifier eagerly and holds it across repeats; a layer SK sends no modifier at all." (code-quality) `activate_on_keypress` table cell described `false` behavior under the field (verified against `sticky_key.rs:144-146` — `true` fires on the SK's OWN press) → corrected; pre-existing dead anchor `./layout#keymap-config` on the rewritten cross-link → fixed to `#keyboard-layout-configuration` (folded in only because I was already authoring that line). Reviewer's "both files link to it" was imprecise — only `behavior.md:101` did. +- **No source/test/toml touched** — docs-only. A stray untracked `docs/superpowers/plans/2026-05-22-sticky-key.md` was accidentally swept into an interim amend by `git add docs/`; removed via `git reset --soft` + targeted re-stage, left on disk untracked. Final commit is exactly the 3 config docs. + +--- + ## Stage 5 — Local verification, hardware testing, and wire/Vial migration evaluation (DP-4) **Goal:** Run the complete local suite and capture the **DP-4** wire/Vial/storage migration finding before any move toward PR #859. **This stage does not push to the PR.** The user runs hardware testing personally. From e32c4d974b80776ea1934f15ae7d515fbd522ea8 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:49:15 -0500 Subject: [PATCH 062/119] test: update SK grammar tests for 3-shape design; fmt SK files 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. --- rmk-config/src/layout.rs | 22 +++++++++++++--------- rmk/src/keyboard/sticky_key.rs | 17 ++++++++++------- rmk/tests/keyboard_one_shot_test.rs | 18 +++++++++--------- rmk/tests/keyboard_sticky_key_test.rs | 8 ++++---- 4 files changed, 36 insertions(+), 29 deletions(-) diff --git a/rmk-config/src/layout.rs b/rmk-config/src/layout.rs index 198f963e6..5c6a36d9d 100644 --- a/rmk-config/src/layout.rs +++ b/rmk-config/src/layout.rs @@ -730,31 +730,35 @@ mod tests { let aliases = HashMap::new(); let layer_names = HashMap::new(); - let keymap = "SK(Tab, [LAlt]) SK(Tab, [LCtrl]) SK(Tab, [LCtrl | LShift], 3, 2000, true)"; + // Exercise all three SK shapes: tap-key SK(key, [mods]), pure-mod + // SK() (one-shot modifier), and layer SK(MO(n)) (one-shot layer). + let keymap = "SK(Tab, [LAlt]) SK(Tab, [LCtrl | LShift]) SK(LGui) SK(MO(1))"; let result = KeyboardTomlConfig::keymap_parser(keymap, &aliases, &layer_names); assert!(result.is_ok()); assert_eq!( result.unwrap(), - vec![ - "SK(Tab, [LAlt])", - "SK(Tab, [LCtrl])", - "SK(Tab, [LCtrl | LShift], 3, 2000, true)" - ] + vec!["SK(Tab, [LAlt])", "SK(Tab, [LCtrl | LShift])", "SK(LGui)", "SK(MO(1))"] ); } #[test] fn test_sk_action_grammar() { let test_cases = vec![ + // Tap-key shape: SK(key, [mods]) "SK(Tab, [LAlt])", "SK(Tab, [LCtrl])", "SK(Tab, [LCtrl | LShift])", - "SK(Tab, [LAlt], 5)", - "SK(Tab, [LAlt], 5, 3000)", - "SK(Tab, [LAlt], 5, 3000, true)", "SK(Tab, [])", "sk(Tab, [LAlt])", + // Pure-mod shape: SK() — one-shot modifier + "SK(LGui)", + "SK(LCtrl | LShift)", + "sk(lalt)", + // Layer shape: SK(MO(n)) — one-shot layer + "SK(MO(1))", + "SK(MO(3))", + "sk(mo(2))", ]; for input in test_cases { diff --git a/rmk/src/keyboard/sticky_key.rs b/rmk/src/keyboard/sticky_key.rs index 14c0508bf..ac016a586 100644 --- a/rmk/src/keyboard/sticky_key.rs +++ b/rmk/src/keyboard/sticky_key.rs @@ -114,7 +114,10 @@ impl Keyboard<'_> { // Latch-replacement rule: a pure-mod press accumulates onto an existing pure-mod // latch, but REPLACES a latched layer (deactivate it and drop the layer; the // single mutually-exclusive latch holds at most one SK). - if let StickyKeyState::Active { layer: Some(layer_num), .. } = self.sticky_key_state { + if let StickyKeyState::Active { + layer: Some(layer_num), .. + } = self.sticky_key_state + { self.keymap.deactivate_layer(layer_num); self.sticky_key_state = StickyKeyState::None; } @@ -129,9 +132,7 @@ impl Keyboard<'_> { deadline, }; } - StickyKeyState::Active { - mods, deadline: d, .. - } => { + StickyKeyState::Active { mods, deadline: d, .. } => { // Accumulate (3c) and refresh the timeout deadline. The unified latch holds // at most one SK at a time; pressing a different-shaped SK while one is active // accumulates onto the existing latch rather than replacing it (no test or @@ -220,8 +221,7 @@ impl Keyboard<'_> { } } StickyKeyState::Active { - phase: SkPhase::Held, - .. + phase: SkPhase::Held, .. } => { // Held-mode: the layer stayed active while the SK was physically held. // Releasing the SK deactivates the layer now (no HID report). @@ -384,7 +384,10 @@ impl Keyboard<'_> { }; // For the layer shape, deactivate the active layer before clearing the latch. - if let StickyKeyState::Active { layer: Some(layer_num), .. } = self.sticky_key_state { + if let StickyKeyState::Active { + layer: Some(layer_num), .. + } = self.sticky_key_state + { self.keymap.deactivate_layer(layer_num); } diff --git a/rmk/tests/keyboard_one_shot_test.rs b/rmk/tests/keyboard_one_shot_test.rs index 3db911379..55f6f49d0 100644 --- a/rmk/tests/keyboard_one_shot_test.rs +++ b/rmk/tests/keyboard_one_shot_test.rs @@ -21,20 +21,20 @@ mod one_shot_test { [[ // Layer 0 sk_mod!(ModifierCombination::new_from(false, false, false, true, false)), // OSM LShift - sk_layer!(1), // OSL Layer 1 - k!(A), // Regular key A - th!(B, C), // Tap-hold key B, C + sk_layer!(1), // OSL Layer 1 + k!(A), // Regular key A + th!(B, C), // Tap-hold key B, C sk_mod!(ModifierCombination::new_from(false, false, false, false, true)), // OSM LCtrl - wm!(B, ModifierCombination::new_from(false, true, false, false, false)), // WM B with LGUI + wm!(B, ModifierCombination::new_from(false, true, false, false, false)), // WM B with LGUI ]], [[ // Layer 1 sk_mod!(ModifierCombination::new_from(false, false, false, true, true)), // OSM LShift + LCtrl - k!(No), // No action - k!(C), // Layer 1 key C - k!(D), // Layer 1 key D - k!(E), // Layer 1 key E - k!(F), // Layer 1 key F + k!(No), // No action + k!(C), // Layer 1 key C + k!(D), // Layer 1 key D + k!(E), // Layer 1 key E + k!(F), // Layer 1 key F ]], ]; diff --git a/rmk/tests/keyboard_sticky_key_test.rs b/rmk/tests/keyboard_sticky_key_test.rs index 2153345e8..25e9b80ae 100644 --- a/rmk/tests/keyboard_sticky_key_test.rs +++ b/rmk/tests/keyboard_sticky_key_test.rs @@ -26,15 +26,15 @@ const KEYMAP: [[[KeyAction; 6]; 1]; 2] = [ ]], [[ // Layer 1 - sk!(Tab, ModifierCombination::LALT), // col 0: SK(Tab, LAlt) + sk!(Tab, ModifierCombination::LALT), // col 0: SK(Tab, LAlt) sk!(Tab, ModifierCombination::LCTRL), // col 1: SK(Tab, LCtrl) sk!( Tab, ModifierCombination::new_from_vals(true, true, false, false, false, false, false, false) ), // col 2: SK(Tab, LCtrl|LShift) - a!(Transparent), // col 3: Transparent - a!(Transparent), // col 4: Transparent → LShift - a!(No), // col 5: No + a!(Transparent), // col 3: Transparent + a!(Transparent), // col 4: Transparent → LShift + a!(No), // col 5: No ]], ]; From e0f92066c02666a033e80a61b92fef7ad0a47931 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:26:38 -0500 Subject: [PATCH 063/119] fix(sticky-key): replace foreign-shape latch on press; harden repeat/codegen Mixed-shape SK-on-SK latch (item #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 #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. --- rmk-macro/src/codegen/action_parser.rs | 14 ++- rmk/src/keyboard/sticky_key.rs | 31 ++++--- rmk/tests/keyboard_sticky_key_test.rs | 119 ++++++++++++++++++++++++- 3 files changed, 148 insertions(+), 16 deletions(-) diff --git a/rmk-macro/src/codegen/action_parser.rs b/rmk-macro/src/codegen/action_parser.rs index 475c98776..71618be98 100644 --- a/rmk-macro/src/codegen/action_parser.rs +++ b/rmk-macro/src/codegen/action_parser.rs @@ -242,7 +242,19 @@ pub(crate) fn parse_key( ::rmk::sk!(#ident, #keep_modifiers) } } else { - // Pure-mod shape: SK(LGui) — OSM replacement + // Pure-mod shape: SK(LGui) — OSM replacement. + // + // A nested action other than MO(n) (e.g. SK(TG(1)), SK(TO(2))) parses as a + // valid `sk_action` in the pest grammar (it accepts the broad `layer_action`) + // but is NOT a supported SK layer shape. Catch it here with a targeted message + // instead of falling through to the generic "not a modifier" panic below. + if inner.contains('(') { + panic!( + "\n\u{274c} keyboard.toml: SK only supports MO(n) as its layer shape (got `{inner}`). \ + Usage: SK(LGui) | SK(Tab, [LAlt]) | SK(MO(n))" + ); + } + let modifiers = parse_modifiers(inner); if modifiers.is_empty() { diff --git a/rmk/src/keyboard/sticky_key.rs b/rmk/src/keyboard/sticky_key.rs index ac016a586..8148858a6 100644 --- a/rmk/src/keyboard/sticky_key.rs +++ b/rmk/src/keyboard/sticky_key.rs @@ -111,15 +111,12 @@ impl Keyboard<'_> { let deadline = (config.timeout != Duration::MAX).then(|| Instant::now() + config.timeout); if event.pressed { - // Latch-replacement rule: a pure-mod press accumulates onto an existing pure-mod - // latch, but REPLACES a latched layer (deactivate it and drop the layer; the - // single mutually-exclusive latch holds at most one SK). - if let StickyKeyState::Active { - layer: Some(layer_num), .. - } = self.sticky_key_state - { - self.keymap.deactivate_layer(layer_num); - self.sticky_key_state = StickyKeyState::None; + // Single mutually-exclusive latch: a pure-mod press accumulates (3c) onto an + // existing pure-mod latch, but REPLACES any other shape (layer or tap-key). + // Releasing the foreign latch first deactivates a held layer and drops its mods + // cleanly, so only a same-shape (pure-mod) latch can reach the accumulate arm below. + if self.sticky_key_state.is_active() && !self.sticky_key_state.is_pure_mod() { + self.release_sticky_key_if_active().await; } match &mut self.sticky_key_state { StickyKeyState::None => { @@ -133,10 +130,7 @@ impl Keyboard<'_> { }; } StickyKeyState::Active { mods, deadline: d, .. } => { - // Accumulate (3c) and refresh the timeout deadline. The unified latch holds - // at most one SK at a time; pressing a different-shaped SK while one is active - // accumulates onto the existing latch rather than replacing it (no test or - // spec covers concurrent mixed shapes — single-latch assumption). + // Same-shape pure-mod re-press: accumulate (3c) and refresh the deadline. *mods |= params.keep; *d = deadline; } @@ -241,6 +235,13 @@ impl Keyboard<'_> { let deadline = (config.timeout != Duration::MAX).then(|| Instant::now() + config.timeout); if event.pressed { + // Single mutually-exclusive latch: a tap-key press cycles (repeat_count) an existing + // tap-key latch, but REPLACES any other shape. Release the foreign latch first so only + // a same-shape tap-key latch can reach the cycle arm below. + if self.sticky_key_state.is_active() && !self.sticky_key_state.is_tap_key() { + self.release_sticky_key_if_active().await; + } + let mut should_deactivate = false; match &mut self.sticky_key_state { @@ -259,7 +260,9 @@ impl Keyboard<'_> { deadline: d, .. } => { - *repeat_count += 1; + // Saturating so an unbounded (`max_repeat == 0`) cycle can never overflow + // the counter and panic on a debug build after 65535 presses. + *repeat_count = repeat_count.saturating_add(1); if config.max_repeat > 0 && *repeat_count > config.max_repeat { should_deactivate = true; } else { diff --git a/rmk/tests/keyboard_sticky_key_test.rs b/rmk/tests/keyboard_sticky_key_test.rs index 25e9b80ae..fae2ae295 100644 --- a/rmk/tests/keyboard_sticky_key_test.rs +++ b/rmk/tests/keyboard_sticky_key_test.rs @@ -5,7 +5,7 @@ use rmk::config::{BehaviorConfig, PositionalConfig, StickyKeyConfig}; use rmk::keyboard::Keyboard; use rmk::types::action::KeyAction; use rmk::types::modifier::ModifierCombination; -use rmk::{a, k, mo, sk, sk_mod}; +use rmk::{a, k, mo, sk, sk_layer, sk_mod}; use rusty_fork::rusty_fork_test; use crate::common::{KC_LALT, KC_LCTRL, KC_LGUI, KC_LSHIFT, wrap_keymap}; @@ -81,6 +81,37 @@ fn create_test_keyboard_puremod() -> Keyboard<'static> { Keyboard::new(wrap_keymap(KEYMAP_PUREMOD, per_key_config, behavior_config)) } +// KEYMAP_MIXED: all three SK shapes on layer 0, used to exercise the mutually-exclusive +// latch (pressing a different-shape SK while one is latched REPLACES it, never merges). +// Layer 0: SK(LGui) SK(Tab,LAlt) SK(MO(1)) P No No +// Layer 1: Trns Trns Trns Z No No +// (cols 0-2 fall through to layer 0 so the SKs stay pressable while layer 1 is +// latched; col 3 = Z is a detector — it only resolves when layer 1 leaked.) +const KEYMAP_MIXED: [[[KeyAction; 6]; 1]; 2] = [ + [[ + sk_mod!(ModifierCombination::LGUI), // col 0: pure-mod SK(LGui) + sk!(Tab, ModifierCombination::LALT), // col 1: tap-key SK(Tab, LAlt) + sk_layer!(1), // col 2: layer SK(MO(1)) + k!(P), // col 3: P (layer-0 terminating key) + a!(No), // col 4 + a!(No), // col 5 + ]], + [[ + a!(Transparent), // col 0 → layer-0 SK(LGui) + a!(Transparent), // col 1 → layer-0 SK(Tab, LAlt) + a!(Transparent), // col 2 → layer-0 SK(MO(1)) + k!(Z), // col 3: Z — detector for a leaked layer 1 + a!(No), // col 4 + a!(No), // col 5 + ]], +]; + +fn create_test_keyboard_mixed() -> Keyboard<'static> { + let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig::default())); + let per_key_config: &'static PositionalConfig<1, 6> = Box::leak(Box::new(PositionalConfig::default())); + Keyboard::new(wrap_keymap(KEYMAP_MIXED, per_key_config, behavior_config)) +} + fn create_test_keyboard() -> Keyboard<'static> { static BEHAVIOR_CONFIG: static_cell::StaticCell = static_cell::StaticCell::new(); let behavior_config = BEHAVIOR_CONFIG.init(BehaviorConfig { @@ -516,4 +547,90 @@ rusty_fork_test! { ] }; } + + /// StickyKey Test 12 (regression): a tap-key SK pressed while a PURE-MOD SK is latched + /// REPLACES it — the latch is mutually exclusive, so the old modifier is dropped, not + /// merged. Without the replacement guard the tap-key press would OR the pure-mod's LGui + /// onto the report, yielding LGui+LAlt+Tab instead of just LAlt+Tab. + /// + /// Sequence: tap SK(LGui) (col 0), press/release SK(Tab,LAlt) (col 1) + /// Expected: LAlt+Tab (LGui dropped), then LAlt held. + #[test] + fn test_sk_tap_key_replaces_pure_mod() { + key_sequence_test! { + keyboard: create_test_keyboard_mixed(), + sequence: [ + [0, 0, true, 10], // Press SK(LGui) + [0, 0, false, 10], // Release SK(LGui) → pure-mod latched (no report) + [0, 1, true, 10], // Press SK(Tab, LAlt) → replaces pure-mod + [0, 1, false, 10], // Release SK + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // tap-key press: LAlt+Tab (LGui dropped) + [KC_LALT, [0, 0, 0, 0, 0, 0]], // tap-key release: LAlt held + ] + }; + } + + /// StickyKey Test 13 (regression): a pure-mod SK pressed while a TAP-KEY SK is latched + /// REPLACES it. The tap-key's held LAlt is released (its own report) and the next basic + /// key gets the new pure-mod's LGui applied through it — OSM terminating-key behavior — + /// not the stale LAlt. Without the guard the pure-mod's LGui would merge onto the tap-key + /// latch, leaving the shape as tap-key and applying LAlt+LGui. + /// + /// Sequence: press/release SK(Tab,LAlt) (col 1), tap SK(LGui) (col 0), tap P (col 3) + /// Expected: LAlt+Tab, LAlt held, LAlt released, LGui+P, all released. + #[test] + fn test_sk_pure_mod_replaces_tap_key() { + key_sequence_test! { + keyboard: create_test_keyboard_mixed(), + sequence: [ + [0, 1, true, 10], // Press SK(Tab, LAlt) + [0, 1, false, 10], // Release SK → tap-key latched (LAlt held) + [0, 0, true, 10], // Press SK(LGui) → replaces tap-key (drops LAlt) + [0, 0, false, 10], // Release SK(LGui) → pure-mod latched + [0, 3, true, 10], // Press P → LGui applied through it + [0, 3, false, 10], // Release P + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // tap-key press: LAlt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // tap-key release: LAlt held + [0, [0, 0, 0, 0, 0, 0]], // pure-mod press: tap-key released (LAlt dropped) + [KC_LGUI, [kc_to_u8!(P), 0, 0, 0, 0, 0]], // P with LGui (terminating key) + [0, [0, 0, 0, 0, 0, 0]], // P release: all clear + ] + }; + } + + /// StickyKey Test 14 (regression): a tap-key SK pressed while a LAYER SK is latched + /// REPLACES it — the orphaned-layer bug. The latched layer must be deactivated, so the + /// later basic key resolves on layer 0 (P), not the leaked layer 1 (Z). Without the guard + /// the tap-key press would bump the layer latch's repeat_count, leaving layer 1 active + /// forever and sending the key with no modifier. + /// + /// Sequence: press/release SK(MO(1)) (col 2), press/release SK(Tab,LAlt) (col 1), tap P (col 3) + /// Expected: LAlt+Tab, LAlt held, then P resolves on LAYER 0 (the tap-key early-releases + /// its LAlt before the foreign key, per the tap-key terminating-key rule, so P is sent + /// clean) — crucially P, not the leaked layer-1 Z. + #[test] + fn test_sk_tap_key_replaces_layer() { + key_sequence_test! { + keyboard: create_test_keyboard_mixed(), + sequence: [ + [0, 2, true, 10], // Press SK(MO(1)) → layer 1 active + [0, 2, false, 10], // Release SK → layer latched + [0, 1, true, 10], // Press SK(Tab, LAlt) (col 1 Trns → layer-0 tap-key) → replaces layer + [0, 1, false, 10], // Release SK → tap-key latched (LAlt held) + [0, 3, true, 10], // Press col 3 → resolves to P on layer 0 (layer 1 deactivated) + [0, 3, false, 10], // Release + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // tap-key press: LAlt+Tab (layer dropped, no report) + [KC_LALT, [0, 0, 0, 0, 0, 0]], // tap-key release: LAlt held + [0, [0, 0, 0, 0, 0, 0]], // P press: tap-key early-releases LAlt + [0, [kc_to_u8!(P), 0, 0, 0, 0, 0]], // P sent clean on layer 0 (NOT Z) — layer 1 gone + [0, [0, 0, 0, 0, 0, 0]], // P release + ] + }; + } } From 4a9c1b37d41c43b72320aefa065299bd9b16acab Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Wed, 10 Jun 2026 18:24:37 -0500 Subject: [PATCH 064/119] docs(sticky-key): record DP-4 wire/Vial/storage migration findings 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. --- ...026-06-10-sk-oneshot-migration-findings.md | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-10-sk-oneshot-migration-findings.md diff --git a/docs/superpowers/plans/2026-06-10-sk-oneshot-migration-findings.md b/docs/superpowers/plans/2026-06-10-sk-oneshot-migration-findings.md new file mode 100644 index 000000000..76f69780d --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-sk-oneshot-migration-findings.md @@ -0,0 +1,111 @@ +# DP-4 — SK-absorbs-OneShot: wire / Vial / storage migration findings + +**Date:** 2026-06-10 +**Branch:** `feat/osm-sticky-key-merge` +**Scope:** Impact of removing `Action::OneShotModifier` / `Action::OneShotLayer` (postcard +discriminant shift) and the `0x5280–0x52BF` Vial keycodes, plus the +`one_shot_timeout → sticky_key_timeout` field rename. + +## TL;DR verdict + +| Question | Answer | +| --- | --- | +| Manual reflash-with-erase needed? | **No** — firmware self-erases on upgrade. | +| Vial re-sync / migration code needed? | **No** — stale keycodes degrade to `KeyAction::No`, no panic. | +| Storage schema version bump needed? | **No** — `BUILD_HASH` already serves this role and changes every build. | +| Can `from_via_keycode` panic on a stale keycode? | **No** — catch-all arm warns + returns `KeyAction::No`. | +| User-visible consequence | Vial dynamic keymap edits are wiped on the upgrade flash (one-time). | + +**Net: this is a safe, zero-code migration in practice.** No defensive code, no schema +field, no documented reflash step is strictly required. The single caveat is the Vial +dynamic-keymap wipe, which is inherent to any RMK firmware update (not specific to this change). + +## How the keymap is stored (the load-bearing fact) + +The keymap **is** persisted to flash, per-key, and `KeyAction`/`Action` is serialized via +**postcard (discriminant/positional wire format)** — NOT as a Vial u16 keycode. + +- Write: `FlashOperationMessage::KeymapKey { action }` → `StorageData::KeyAction(action)` + → postcard store. `rmk/src/storage/mod.rs:734`, per-key keys at `:556-567`. +- Read at boot: `StorageData::KeyAction(action)` deserialized straight back into the keymap + array. `rmk/src/host/storage.rs:73-111` (`:96`). +- `from_via_keycode` / `to_via_keycode` (`rmk/src/host/via/keycode_convert.rs:131`, `:5`) + are **protocol-boundary adapters only** — they are NOT the storage encoder. + +Consequence: removing `Action` variants **does** shift postcard discriminants, so old stored +bytes would deserialize to the wrong variant — *if they were ever read against the new layout.* +They are not, because of `BUILD_HASH` (below). + +## Why the discriminant shift is harmless: BUILD_HASH + +`rmk/build.rs:25-51` computes `BUILD_HASH = crc32(format!("{git_short_commit}_{now_nanos}"))`, +where `now_nanos` is the wall-clock build time. It is written into `constants.rs` and consumed +as `BUILD_HASH` (`rmk/src/storage/mod.rs:28`). + +Boot gate (`check_enable`, `rmk/src/storage/mod.rs:634-642`): + +```rust +if let Some(StorageData::StorageConfig(config)) = self.fetch_data(StorageKey::StorageConfig).await + && config.enable + && config.build_hash == BUILD_HASH { return true; } +false +``` + +On mismatch (`:458-485`): `flash.erase_all()` then `initialize_storage_with_config(...)` from +the **compiled-in** keymap + behavior defaults. No panic; on init error it stores +`enable: false` to avoid partial init. A regression test already exists: +`build_hash_mismatch_reinitializes_storage` (`:1009`). + +Because `BUILD_HASH` embeds both the commit id **and** the build timestamp, the old firmware +(built on `main`) and the new firmware (built on the feat branch) will always have different +hashes. Flashing the new `.uf2` therefore always triggers erase + reinit, so the new firmware +**never reads old-layout postcard bytes**. The discriminant shift is masked by design. + +> Edge note: within a single source tree, Cargo caches the build-script output (only +> `rerun-if-changed=build.rs` is declared), so two consecutive rebuilds *without* a change can +> reuse a `BUILD_HASH`. This does not affect the upgrade path — the two firmwares differ in +> source, and any `Action`-layout change forces recompilation. A `cargo clean` (already part of +> the layout cargo-make tasks) regenerates the hash regardless. + +## Vial keycode removal (0x5280–0x52BF): no panic + +Removed in commit `28416fa57`. The old mappings were +`0x5280..=0x529F → OneShotLayer`, `0x52A0..=0x52BF → OneShotModifier`. The range is now +unhandled and hits the catch-all in `from_via_keycode` +(`rmk/src/host/via/keycode_convert.rs:227-230`): + +```rust +_ => { + warn!("Via keycode {:#X} is not processed", via_keycode); + KeyAction::No +} +``` + +- A stale `0x5280` from a live Vial message → `KeyAction::No` (inert) + warn. **No panic.** +- Stale keycodes are **never decoded at boot**: storage reads typed `KeyAction` via postcard, + not via keycodes, so `from_via_keycode` is only on the live-protocol path + (`rmk/src/host/via/mod.rs:105`, `vial.rs`). +- The new `Action::StickyKey` is **not** Vial-keycode-representable: `to_via_keycode` returns `0` + + warn for it (`keycode_convert.rs:88-92`). So SK actions cannot round-trip through the Vial + desktop app — but this fails safe (no panic; SK keys simply aren't editable/visible in Vial). + StickyKey is authored via the `sk!`/`sk_mod!`/`sk_layer!` macros (compile-time TOML), not Vial. + +## The one_shot_timeout → sticky_key_timeout rename: safe + +The `BehaviorConfig` field was renamed to `sticky_key_timeout` (`storage/mod.rs:310`), but the +**wire/setting variant name was deliberately preserved**: `FlashOperationMessage::OneShotTimeout(u16)` +(`:144-145`, comment: *"variant name kept for storage-format stability"*). Its handler updates +the renamed field (`:803-804`). So the persisted setting-key encoding is unchanged — no stored +setting is orphaned by the rename. + +## Recommendation + +Ship as-is. Optionally: + +1. Add one line to the release/upgrade notes: *"Upgrading wipes Vial dynamic keymap + customizations (flash is re-initialized from firmware defaults)."* — true of all RMK updates, + worth restating here since OneShot users are the affected cohort. +2. (Optional, low value) A more specific deprecation `warn!` for the `0x5280–0x52BF` range on the + live path, for user awareness. Not required for correctness. + +No code changes are required for a safe migration. From 57071fb13981ee5573bf2df22ea8afd12c941b35 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:08:02 -0500 Subject: [PATCH 065/119] chore: drop internal SK design/plan docs (not for upstream PR) 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). --- .../2026-06-03-sk-absorbs-oneshot-plan.md | 704 ------------------ ...026-06-10-sk-oneshot-migration-findings.md | 111 --- .../plans/sk-oneshot-parity-catalogue.md | 160 ---- .../2026-06-02-unify-osm-sticky-key-design.md | 463 ------------ .../2026-06-03-sk-absorbs-oneshot-design.md | 425 ----------- 5 files changed, 1863 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md delete mode 100644 docs/superpowers/plans/2026-06-10-sk-oneshot-migration-findings.md delete mode 100644 docs/superpowers/plans/sk-oneshot-parity-catalogue.md delete mode 100644 docs/superpowers/specs/2026-06-02-unify-osm-sticky-key-design.md delete mode 100644 docs/superpowers/specs/2026-06-03-sk-absorbs-oneshot-design.md diff --git a/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md b/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md deleted file mode 100644 index 11763f5f0..000000000 --- a/docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md +++ /dev/null @@ -1,704 +0,0 @@ -# Sticky Key Absorbs One-Shot (OSM + OSL) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make a single Sticky Key (SK) engine and a single `Action::StickyKey` path fully absorb one-shot modifier (OSM) and one-shot layer (OSL), replacing the `OSM(...)`/`OSL(...)` keymap syntax and the three one-shot/sticky config tables with one `SK(...)` surface and one `[behavior.sticky_key]` table. Behavior is selected by the **shape** of the SK action (pure-mod / tap-key / layer), not by which legacy syntax was used. - -**Architecture:** One latch state replaces `OneShotState` (OSM), `OneShotState` (OSL), and the `StickyKeyState` enum. The engine reads the action's shape — `key == No` → pure-mod (OSM behavior, applies mod *through* the terminating key, accumulates), `key != No` → tap-key (alt-tab, releases clean), layer payload → one-shot layer (OSL behavior). Timeout moves entirely onto the existing non-blocking run-loop deadline race (`keyboard.rs:144-185`); the blocking inline `select(timeout, …)` blocks in `oneshot.rs` are deleted. This is a **deliberate, non-backward-compatible** replacement: user-facing syntax, config table names, defaults, and the wire/struct shape all change. - -**Tech Stack:** Rust `#![no_std]` firmware (RMK fork). Config: `rmk-config` (serde + pest grammar) → `rmk-config/src/resolved` → `rmk-macro` codegen → `rmk` runtime structs. Wire: `rmk-types` (postcard via `MaxSize`/`Serialize`/`Deserialize`). Tests: `cargo nextest` with `embassy-time` MockDriver (virtual time, per-test process isolation required). - -**Test command (memorize — every gate uses it):** -```sh -# from rmk-fork/rmk/ -cargo nextest run --no-default-features --features=split,vial,storage,async_matrix,_ble -# full feature matrix (run before declaring the whole feature done): -sh scripts/test_all.sh # from repo root -``` -`cargo test` will **abort at startup** by design (`rmk/tests/common/mod.rs:30-43` `require_nextest`). Always use nextest. - -**Repo / branch:** All work happens in `/mnt/c/RandomProjects/GitHubRepoProjects/rmk-fork` on branch `feat/osm-sticky-key-merge`. This is consumed by RMKSofleV2 via `[patch.crates-io]`. **Do not push toward PR #859.** The branch merges into the sticky-mod PR *only after* the full local suite passes **and** the user confirms it works on real hardware. - ---- - -## Decision Points (resolve these explicitly — do not silently pick) - -These are the spec's four open questions (Section 5 / "Open questions for the implementation plan"). Each is wired into a specific stage below. Where a stage reaches one, **STOP, state the trade-off, record the choice in this plan file (check the box + write the decision inline), and only then proceed.** Recommendations are given but are not final until confirmed. - -- **DP-1 — Action payload encoding (resolve in Stage 2, Task 2.1).** `StickyKeyAction` must carry an optional layer (OSL) which today's all-concrete struct cannot. Two encodings: - - *(a) Tagged enum:* `StickyKeyAction` becomes `enum { Mods { keep, key, max_repeat }, Layer { layer } }`. - - *(b) Added optional field:* keep the struct, add `layer: Option`; `Some` = layer shape, `None` = mod/tap-key shape with `key == No` distinguishing pure-mod from tap-key. - - **Recommendation:** (b) added `layer: Option` — smaller diff to the existing struct, derives (`MaxSize`/`Serialize`/`Deserialize`/`Schema`) carry over unchanged, and the engine already needs the `key == No` branch for the terminating-key rule, so the three-way match is `(layer, key)` → cheap. Revisit if the engine dispatch reads cleaner as an enum once the latch is merged. - - **DECISION (2026-06-04, confirmed by user):** **(b) — add `layer: Option`.** Grounding: every action-parameter type in rmk-types is a plain struct (`Combo`, `Morse`, `Fork`, `EncoderAction`, `StickyKeyAction`); none is a tagged enum — only the top-level `Action` is. Keeping `StickyKeyAction` a struct matches that convention and `MorseProfile`'s `Option<...>` sub-setting pattern. The engine currently reads `params.{key,keep,...}` as plain field accesses; adding one optional field keeps those and adds a single `(layer, key)` dispatch, whereas an enum would force every access site to a `match` (larger, riskier diff). Both encodings break the postcard wire identically (DP-4), so the struct option is strictly the lower-churn / lower-bug-risk path. Field set: `{ key: KeyCode, keep: ModifierCombination, layer: Option }` — `Some(n)` = layer shape; `None` + `key == No` = pure-mod; `None` + `key != No` = tap-key. - -- **DP-2 — Home of the unified latch (resolve in Stage 2, Task 2.1).** Fold `oneshot.rs` into `sticky_key.rs`, or create a new shared module (e.g. `keyboard/latch.rs`). - - **Recommendation:** fold into `sticky_key.rs` and delete `oneshot.rs`. The spec's file map predicts `oneshot.rs` "likely shrinks to nothing or merges into `sticky_key.rs`." A new module name would orphan the established `sticky_key` mod path used across `keyboard.rs`. - - **DECISION (2026-06-06, confirmed by user):** **Fold into `sticky_key.rs`; delete `oneshot.rs`.** The unified latch lives in `sticky_key.rs` (Task 2.1 declares the latch state there). `oneshot.rs` is deleted as its logic is ported out — OSM removed in Task 2.4, OSL removed and the file + `mod oneshot;`/`use` deleted in Task 3.1. Grounding: the surviving public surface is already `sticky_key`-named (`Action::StickyKey`, `sticky_key_state` field, `sticky_key_config`, `process_action_sticky_key`, `release_sticky_key_if_active`) and `keyboard.rs` references that path throughout; keeping it and dropping `oneshot` is the lowest-churn path and matches the user-facing `SK(...)` naming. A neutral `keyboard/latch.rs` would force every `sticky_key::` site and the `mod`/`use` lines to churn to `latch::` for zero behavioral gain, and would add a third module name to a feature whose whole point is collapsing to one engine. - -- **DP-3 — Vial one-shot-timeout runtime path (resolve in Stage 1, Task 1.4).** The unified `timeout` either keeps a Vial runtime-set path (`SettingKey::OneShotTimeout = 0x06`, handlers at `rmk/src/host/via/vial.rs:127-130` and `184-186`, storage `FlashOperationMessage::OneShotTimeout` at `rmk/src/storage/mod.rs:145`) or drops it with the OSM keycodes. - - **Recommendation:** **keep** the Vial setting wire-compatible but re-point it at the unified `sticky_key` timeout (rename the internal `one_shot_timeout` storage field / accessor to `sticky_key_timeout`, leave `SettingKey` numeric value `0x06` and the protocol bytes unchanged). Dropping a Vial `SettingKey` is itself a Vial-protocol break; this round we are already breaking the keymap wire (DP-4) and should not stack a second protocol break unless the user wants it. Confirm with user. - - **DECISION (2026-06-04, confirmed by user):** **KEEP, re-pointed.** Leave `SettingKey::OneShotTimeout = 0x06` and its protocol bytes unchanged; rename the internal storage field/accessor from `one_shot_timeout` → `sticky_key_timeout` so Vial still live-sets the unified timeout. Zero Vial-protocol break (the keymap-wire break in DP-4 stays the only one this round). Chosen explicitly for minimum breakage. - -- **DP-4 — Wire/Vial/storage migration impact (resolve in Stage 5, post-engine).** The `StickyKeyAction` struct/postcard change plus removal of `Action::OneShotModifier`/`OneShotLayer` variants is a wire-order break. Whether it invalidates keymaps stored in flash and Vial state — and what migration is needed (reflash? Vial re-sync? storage schema bump?) — is **TBD after the engine works**, likely only visible during hardware testing. **Do not assume harmless.** Stage 5 has an explicit evaluation task; the finding must be recorded before any move toward PR #859. - ---- - -## File Map (re-verified against `feat/osm-sticky-key-merge`, 2026-06-03) - -Line numbers below are current as of this plan. Re-confirm with a `grep`/Read immediately before editing each file — surrounding edits in earlier stages will shift them. - -**Config — TOML structs & resolve & codegen:** -- `rmk-config/src/lib.rs` — `BehaviorConfig` (570-580), `OneShotConfig` (614-616, `timeout`), `OneShotModifiersConfig` (621-624, `activate_on_keypress`, `quick_release`), `StickyKeyConfig` (629-632, `timeout`). TOML tables `[behavior.one_shot]`, `[behavior.one_shot_modifiers]`, `[behavior.sticky_key]`. -- `rmk-config/src/resolved/behavior.rs` — `one_shot_timeout_ms`, `one_shot_modifiers`, `sticky_key_timeout_ms` (4-17); extraction at 105-110 and 205. -- `rmk-config/src/keymap.pest` — `osm_action` (58), `osl_action` (73), `sk_action` (112-120), `key_action` integration (127). -- `rmk-config/src/layout.rs` — pest AST match arms: `osm_action` (391-397), `sk_action` (399-402), `osl_action` (423-428). -- `rmk-macro/src/codegen/behavior.rs` — `expand_one_shot` (25-39), `expand_one_shot_modifiers` (41-65), `expand_sticky_key` (67-79). -- `rmk-macro/src/codegen/action_parser.rs` — `parse_key` (152); `osl(` arm (201-206), `osm(` arm (207-225), `sk(` arm (226-289); `parse_modifiers` helper (51-85). - -**Runtime config:** -- `rmk/src/config/behavior.rs` — `BehaviorConfig` (11-22), `OneShotConfig` (62-75, default 1s), `OneShotModifiersConfig` (77-83), `StickyKeyConfig` (85-97, default `Duration::MAX`). -- `rmk/src/keymap.rs` — `one_shot_timeout()` (511), `sticky_key_timeout()` (515), `set_one_shot_timeout()` (561). - -**Macros (declarative):** -- `rmk/src/layout_macro.rs` — `osl!` (328-332), `osm!` (352-356), `sk!` (367-379). - -**Engine:** -- `rmk/src/keyboard/sticky_key.rs` — `StickyKeyState` enum (24-36: `None | Active { mods, repeat_count, max_repeat, exit_on_layer_change, deadline }`); helpers `value`/`is_active`/`deadline`/`exit_on_layer_change` (38-66); `process_action_sticky_key` (69-125, repeat-count increment 90-102); `release_sticky_key_if_active` (127-133). -- `rmk/src/keyboard/oneshot.rs` — `OneShotState` enum (10-20: `Initial/Single/Held/None`); `process_action_osm` (33-114, accumulation `cur | new` at 42/59/62, inline `select` 75-93, `unprocessed_events.retain` 49); `process_action_osl` (116-161, activate 119-133, inline `select` 139-152, `unprocessed_events.push` 148, deactivate via `update_osl` 184-193); `update_osm` (165-182). -- `rmk/src/keyboard.rs` — mod decls `oneshot` (44) / `sticky_key` (47), `use` imports (31-32); state fields `osl_state` (217), `osm_state` (220), `sticky_key_state` (223); `run()` loop + deadline race (144-185); action dispatch `OneShotLayer`/`OneShotModifier`/`StickyKey` (1316-1328); foreign-key release (1220-1230); layer-change release calls (`exit_on_layer_change()` at 1241, 1250, 1268, 1276, 1600); `resolve_explicit_modifiers` (1380-1403); `unprocessed_events` consumer (148-150) and **non-OSM producer** Clear Peer BLE (1683, `#[cfg(feature = "split")]`). - -**Wire / Via / storage:** -- `rmk-types/src/action/mod.rs` — `StickyKeyAction` struct (34-50: `key`, `keep`, `max_repeat`, `timeout_ms`, `exit_on_layer_change`); `Action` variants `OneShotLayer(u8)` (83), `OneShotModifier(ModifierCombination)` (85), `OneShotKey(KeyCode)` (87), `StickyKey(StickyKeyAction)` (98). Derives `Serialize, Deserialize, MaxSize, defmt::Format, Schema`. -- `rmk/src/host/via/keycode_convert.rs` — `to_via_keycode` OSL (61-64) / OSM (65-69); `from_via_keycode` OSL (188-192) / OSM (193-197); unit tests (280, 287). -- `rmk/src/storage/mod.rs` — `BehaviorConfig` persisted struct (301-316, `one_shot_timeout: u16` at 310); serialize (336); deserialize (514); `FlashOperationMessage::OneShotTimeout(u16)` (145); handler (803-804). -- `rmk-types/src/protocol/vial.rs` — `SettingKey::OneShotTimeout = 0x06` (101). -- `rmk/src/host/via/vial.rs` — `GetBehaviorSetting` OneShotTimeout (127-130); `SetBehaviorSetting` (184-186). - -**Tests / docs:** -- `rmk/tests/keyboard_one_shot_test.rs` — 25 tests (catalogued in Stage 0). -- `rmk/tests/keyboard_sticky_key_test.rs` — 11 tests (catalogued in Stage 0). -- `rmk/tests/common/mod.rs` — `require_nextest` (30-43), `run_key_sequence_test`; `rmk/tests/common/test_macro.rs` — `key_sequence_test!` (10). -- User docs — keymap config reference + `[behavior.sticky_key]` section (Stage 4 finds exact path). - ---- - -## Stage 0 — Characterize (the capability oracle / parity contract) - -**Goal:** Produce a written behavior catalogue — one row per OSM/OSL/SK axis the 36 existing tests pin. This list is the parity checklist every later stage is graded against. No code changes. - -### Task 0.1: Write the parity catalogue document - -**Files:** -- Create: `docs/superpowers/plans/sk-oneshot-parity-catalogue.md` - -- [ ] **Step 1: Catalogue OSM/OSL tests.** Open `rmk/tests/keyboard_one_shot_test.rs` and for each of the 25 tests write a row: `test name | behavior axis | syntax+config used | which new SK shape/setting it maps to`. The 25 (verified) are: - - - `test_osm_basic_single_behavior` (76) — OSM applies mod to next key then releases → pure-mod SK terminating-key. - - `test_osm_timeout` (108) — OSM expires after timeout; next key clean → shared `timeout`. - - `test_osm_held_behavior` (151) — held past key press, mod stays until OSM release → pure-mod held-promotion. - - `test_osm_multiple_keys` (185) — applies only to next key → pure-mod single-consume. - - `test_osm_rolling_with_tap_hold` (224) — OSM release before key release still applies → pure-mod ordering. - - `test_osm_combined_modifiers` (255) — two OSM presses accumulate (LShift+LCtrl) → pure-mod accumulation (3c). - - `test_osm_multiple_osm_with_wm` (291) — multiple OSM + `wm!` accumulate → accumulation + WM interaction. - - `test_osm_activate_on_keypress` (329) — mod sent immediately on press when enabled → `activate_on_keypress` (pure-mod only). - - `test_osm_combined_modifiers_with_activate_on_keypress` (366) — accumulate + early activation. - - `test_osl_basic_single_behavior` (394) — OSL activates layer for next key only → layer shape (3d). - - `test_osl_held_behavior` (411) — held across key press, layer stays until release → layer held-promotion. - - `test_osl_timeout` (428) — OSL expires; next key on base layer → shared `timeout` on layer shape. - - `test_osl_multiple_keys` (456) — applies only to next key → layer single-consume. - - `test_osm_then_osl` (477) — OSM+OSL combine, mod applies to layer-switched key. - - `test_osl_then_osm` (496) — OSL+OSM combine. - - `test_osm_and_osl_timeout` (515) — both time out independently. - - `test_osm_chain_mode_basic` (546) — `quick_release=false`: mod held until key release. - - `test_osm_chain_mode_multiple_keys` (567) — chain mode, only first key modified. - - `test_osm_chain_mode_activate_on_keypress` (592) — chain + early activation. - - `test_osm_quick_release_basic` (616) — `quick_release=true`: mod released mid key-press. - - `test_osm_quick_release_multiple_keys` (637). - - `test_osm_quick_release_combined_modifiers` (665). - - `test_osm_quick_release_with_wm` (688) — OSM mod released, WM mod persists. - - `test_osm_quick_release_activate_on_keypress` (711). - - `test_osm_quick_release_combined_activate_on_keypress` (734). - -- [ ] **Step 2: Catalogue SK tests.** Open `rmk/tests/keyboard_sticky_key_test.rs` and add the 11 (verified) rows. Note for each whether the axis is preserved, and which axes prove the **tap-key** shape (so they must keep `key != No` semantics): - - - `test_sk_basic_flow_press_twice` (131) — press sends key+mod; release holds; re-press repeats; layer exit cleans up → tap-key core. - - `test_sk_layer_change_cleanup` (162) — `exit_on_layer_change=true` cleanup on MO release → `release_on_layer_change`. - - `test_sk_shift_does_not_release_sk` (196) — a real modifier press does NOT release SK; they stack → foreign-key rule excludes modifiers. - - `test_sk_rapid_three_presses` (228) — three rapid presses each send key+mod. - - `test_sk_combined_modifiers` (263) — SK with `LCtrl|LShift` sends both. - - `test_sk_timeout` (293) — auto-release after global timeout; next key clean. - - `test_sk_timeout_resets_on_press` (332) — timeout resets each press. - - `test_sk_max_repeat` (375) — deactivates silently after `max_repeat=2` (3rd press deactivates) → `max_repeat` cycling. - - `test_sk_per_key_timeout_overrides_global` (414) — per-key `timeout_ms` overrides global. **NOTE:** per-key timeout override is *removed* this round (Section 4 deferred). This test must be **re-expressed or retired** — flag it in the catalogue as "capability deferred; convert to global-timeout assertion or delete with justification." - - `test_sk_exits_on_layer_change` (444) — `exit_on_layer_change=true`. - - `test_sk_survives_layer_change` (478) — `exit_on_layer_change=false` survives; released only by key press → new default `release_on_layer_change=false`. - -- [ ] **Step 3: Mark the two known new tests required by the spec (Section 7).** Add rows for tests that do **not** exist yet and must be authored in Stage 2: - - *pure-mod terminating-key regression* — `SK(LGui)` then `P` emits `Gui+P` (today's SK engine gets this wrong; today's OSM gets it right). This is the core 3b proof. - - *cross-tap accumulation on pure-mod* — `SK(LCtrl)` then `SK(LShift)` then `P` emits `Ctrl+Shift+P` (3c proof). - -- [ ] **Step 4: Mark capability deltas (accepted breaks) explicitly.** Add a short "Accepted behavior changes" section so reviewers don't mistake them for regressions: - - alt-tab SKs gain a default 1s timeout (previously none / `Duration::MAX`). - - default `release_on_layer_change=false` (was effectively `exit_on_layer_change=true` in several SK tests via the keymap). - - per-key `timeout_ms` and the 5-positional `SK(...)` tail are removed. - -- [ ] **Step 5: Commit.** -```bash -cd /mnt/c/RandomProjects/GitHubRepoProjects/rmk-fork -git add docs/superpowers/plans/sk-oneshot-parity-catalogue.md -git commit -m "docs: characterize OSM/OSL/SK behavior parity catalogue (Stage 0)" -``` - ---- - -## Stage 1 — Config + parser (collapse three tables → one; add `SK(LGui)`/`SK(MO(n))`; remove `OSM`/`OSL` and the 5-positional tail) - -**Goal:** The build accepts the new `[behavior.sticky_key]` table (with `activate_on_keypress`, `quick_release`, `max_repeat`, `release_on_layer_change`, `timeout`) and the new `SK(...)` parse forms; it **rejects** `OSM(...)`/`OSL(...)` and the legacy 5-positional `SK(...)` with a clear build error. Keymaps/tests are rewritten to the new syntax. - -**Gate:** Rewritten config/parse tests green. (Engine still references old state — it will be migrated in Stage 2; keep it compiling by leaving the runtime structs in place but feeding them from the new resolved values where needed, or stub as noted per task.) - -> **Ordering note:** Stage 1 changes the wire shape (`StickyKeyAction` gains a layer payload, `OneShotModifier`/`OneShotLayer` variants are removed). That touches the engine's `match` arms in `keyboard.rs:1316-1328`. To keep the crate compiling between Stage 1 and Stage 2, this stage **adds** the new payload shape and parse paths and makes the old `OneShotModifier`/`OneShotLayer` dispatch arms forward to the existing OSM/OSL engine functions *temporarily* (the producers are gone, so they're dead, but they keep types resolved). Stage 2 deletes them. If you prefer, do DP-1 here and thread it forward — but **resolve DP-1 before writing the wire struct (Task 2.1 references it; pull it earlier if needed).** - -### Task 1.1: Unified runtime `StickyKeyConfig` - -**Files:** -- Modify: `rmk/src/config/behavior.rs:85-97` (and `BehaviorConfig` 11-22) - -- [x] **Step 1: Re-read the file** to confirm current line numbers for `OneShotConfig`, `OneShotModifiersConfig`, `StickyKeyConfig`, and `BehaviorConfig`. - -- [x] **Step 2: Replace the three config structs with one.** New `StickyKeyConfig`: -```rust -/// Unified sticky-key configuration. Absorbs the former one_shot, one_shot_modifiers, -/// and sticky_key tables. `activate_on_keypress`/`quick_release` are honored only for -/// the pure-modifier SK shape (key == No); see docs. -#[derive(Clone, Copy, Debug)] -pub struct StickyKeyConfig { - /// Applies to every SK shape. Default 1s. - pub timeout: Duration, - /// Honored only by pure-mod SK. Default false. - pub activate_on_keypress: bool, - /// Honored only by pure-mod SK. Default false. - pub quick_release: bool, - /// 0 = infinite; governs tap-key cycling. Default 0. - pub max_repeat: u16, - /// true = a layer change releases the SK. Default false (survives). - pub release_on_layer_change: bool, -} - -impl Default for StickyKeyConfig { - fn default() -> Self { - Self { - timeout: Duration::from_secs(1), - activate_on_keypress: false, - quick_release: false, - max_repeat: 0, - release_on_layer_change: false, - } - } -} -``` - -- [x] **Step 3: Update `BehaviorConfig`.** Remove the `one_shot: OneShotConfig` and `one_shot_modifiers: OneShotModifiersConfig` fields; keep only `sticky_key: StickyKeyConfig`. Delete `OneShotConfig` and `OneShotModifiersConfig` struct defs. Fix the `Default` impl of `BehaviorConfig` accordingly. - -- [x] **Step 4: Build the config crate.** Run: `cargo build -p rmk --no-default-features --features=split,vial,storage,async_matrix,_ble` and fix any references that read `behavior.one_shot*` (you'll find them in `keymap.rs`, `storage/mod.rs`, the engine — expect failures; resolve only the config-crate-local ones now, defer engine ones to Stage 2 by leaving TODO and a temporary shim if needed). Expected: incremental compile errors that map the blast radius. - -- [x] **Step 5: Commit.** -```bash -git add rmk/src/config/behavior.rs -git commit -m "feat(config): collapse one_shot/one_shot_modifiers/sticky_key into unified StickyKeyConfig" -``` - -### Task 1.2: Unified TOML table + resolve + codegen - -**Files:** -- Modify: `rmk-config/src/lib.rs:614-632` (TOML structs), `rmk-config/src/resolved/behavior.rs:4-17,105-110,205`, `rmk-macro/src/codegen/behavior.rs:25-79` - -- [ ] **Step 1: TOML struct (`rmk-config/src/lib.rs`).** Delete `OneShotConfig` (614-616) and `OneShotModifiersConfig` (621-624). Replace `StickyKeyConfig` (629-632) with the full surface: -```rust -#[derive(Clone, Debug, Default, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct StickyKeyConfig { - pub timeout: Option, - pub activate_on_keypress: Option, - pub quick_release: Option, - pub max_repeat: Option, - pub release_on_layer_change: Option, -} -``` -Remove the `one_shot` and `one_shot_modifiers` fields from the parent `BehaviorConfig` (570-580); keep `sticky_key`. - -- [ ] **Step 2: Resolved struct (`rmk-config/src/resolved/behavior.rs`).** Replace `one_shot_timeout_ms` / `one_shot_modifiers` / `sticky_key_timeout_ms` (4-17) with a single resolved shape: -```rust -pub sticky_key_timeout_ms: Option, -pub sticky_key_activate_on_keypress: Option, -pub sticky_key_quick_release: Option, -pub sticky_key_max_repeat: Option, -pub sticky_key_release_on_layer_change: Option, -``` -Update extraction (was 105-110 one_shot, 205 sticky_key) to read all five fields off `[behavior.sticky_key]`. - -- [ ] **Step 3: Codegen (`rmk-macro/src/codegen/behavior.rs`).** Delete `expand_one_shot` (25-39) and `expand_one_shot_modifiers` (41-65). Replace `expand_sticky_key` (67-79) so it emits the full `StickyKeyConfig { timeout, activate_on_keypress, quick_release, max_repeat, release_on_layer_change }` using the Stage-1.1 defaults (1s / false / false / 0 / false) for any `None`. Update the `BehaviorConfig` assembly site that called the three deleted expanders. - -- [ ] **Step 4: Build both crates.** Run: `cargo build -p rmk-config && cargo build -p rmk-macro`. Expected: PASS. - -- [ ] **Step 5: Commit.** -```bash -git add rmk-config/src/lib.rs rmk-config/src/resolved/behavior.rs rmk-macro/src/codegen/behavior.rs -git commit -m "feat(config): single [behavior.sticky_key] TOML table, resolve, and codegen" -``` - -### Task 1.3: Parser — add `SK(LGui)`/`SK(MO(n))`, remove `OSM`/`OSL` and 5-positional tail - -**Files:** -- Modify: `rmk-config/src/keymap.pest:58,73,112-120,127`, `rmk-config/src/layout.rs:391-428`, `rmk-macro/src/codegen/action_parser.rs:152,201-289`, `rmk/src/layout_macro.rs:328-379` - -- [ ] **Step 1: Grammar (`keymap.pest`).** Delete `osm_action` (58) and `osl_action` (73). Rewrite `sk_action` (112-120) to accept the three bare shapes: -```pest -// SK(key, [mods]) | SK(modifier) | SK(MO(n)) -sk_action = { - ^"SK" ~ "(" ~ ( - layer_action // SK(MO(n)) — layer shape - | (keycode_name ~ "," ~ modifier_keep_list) // SK(key, [mods]) — tap-key shape - | modifier_combination // SK(LGui) — pure-mod shape - ) ~ ")" -} -``` -Remove `osm_action`/`osl_action` from the `key_action` rule (127). - -- [ ] **Step 2: pest AST (`layout.rs`).** Delete the `Rule::osm_action` (391-397) and `Rule::osl_action` (423-428) match arms. Keep the `Rule::sk_action` arm (399-402) — it forwards the raw string to codegen — but ensure it no longer assumes the 5-positional shape downstream. - -- [ ] **Step 3: codegen parse (`action_parser.rs`).** Delete the `osl(` arm (201-206) and `osm(` arm (207-225). Rewrite the `sk(` arm (226-289) to dispatch on the inner text: - - inner starts with `MO(` → emit `::rmk::sk_layer!(n)`. - - inner contains `[` → tap-key: parse `key` + `[mods]` (reuse existing bracket parse and `parse_modifiers`); emit `::rmk::sk!(key, mods)`. - - else → pure-mod: `parse_modifiers(inner)`; emit `::rmk::sk_mod!(mods)`. - - If the inner text still contains extra positional args after `]` (the legacy tail), `panic!` with a clear migration message: `"❌ keyboard.toml: the 5-positional SK(...) form is removed; use SK(key, [mods]). max_repeat/timeout/release_on_layer_change now live in [behavior.sticky_key]."` - -- [ ] **Step 4: declarative macros (`layout_macro.rs`).** Delete `osl!` (328-332) and `osm!` (352-356). Replace `sk!` (367-379) with three macros matching the new payload (uses DP-1 — pull DP-1 decision here if encoding the layer payload). Example assuming DP-1 recommendation (b), `layer: Option`: -```rust -#[macro_export] -macro_rules! sk { // tap-key shape - ($key:ident, $keep:expr) => { - $crate::types::action::KeyAction::Single($crate::types::action::Action::StickyKey( - $crate::types::action::StickyKeyAction { - key: $crate::types::keycode::KeyCode::Hid($crate::types::keycode::HidKeyCode::$key), - keep: $keep, - layer: None, - }, - )) - }; -} -#[macro_export] -macro_rules! sk_mod { // pure-mod shape (key == No) - ($m:expr) => { - $crate::types::action::KeyAction::Single($crate::types::action::Action::StickyKey( - $crate::types::action::StickyKeyAction { - key: $crate::types::keycode::KeyCode::No, - keep: $m, - layer: None, - }, - )) - }; -} -#[macro_export] -macro_rules! sk_layer { // layer shape (OSL) - ($n:literal) => { - $crate::types::action::KeyAction::Single($crate::types::action::Action::StickyKey( - $crate::types::action::StickyKeyAction { - key: $crate::types::keycode::KeyCode::No, - keep: $crate::types::modifier::ModifierCombination::new(), - layer: Some($n), - }, - )) - }; -} -``` -> The exact `StickyKeyAction` field set depends on **DP-1**. If DP-1 picks the tagged-enum encoding, these three macros emit the three enum variants instead. **Do not write this task until DP-1 is recorded** (it forces the field names used in Stage 2 too). - -- [ ] **Step 5: Build.** `cargo build -p rmk-config -p rmk-macro -p rmk`. Expect engine-side errors only (handled Stage 2) — config/parse/macro crates must build clean. - -- [ ] **Step 6: Commit.** -```bash -git add rmk-config/src/keymap.pest rmk-config/src/layout.rs rmk-macro/src/codegen/action_parser.rs rmk/src/layout_macro.rs -git commit -m "feat(parser): SK(LGui)/SK(MO(n)) shapes; drop OSM/OSL keywords and 5-positional SK tail" -``` - -### Task 1.4: Wire `StickyKeyAction` + remove OSM/OSL variants + Vial decision (DP-3) - -**Files:** -- Modify: `rmk-types/src/action/mod.rs:34-50,83-98`, `rmk/src/host/via/keycode_convert.rs:61-69,188-197,280-293`, `rmk-types/src/protocol/vial.rs:101`, `rmk/src/host/via/vial.rs:127-130,184-186`, `rmk/src/storage/mod.rs:145,310,336,514,803-804` - -- [ ] **Step 1: `StickyKeyAction` (rmk-types).** Apply the DP-1 encoding. For recommendation (b): replace `max_repeat`/`timeout_ms`/`exit_on_layer_change` fields (45-49) with `layer: Option`, keeping `key` and `keep`: -```rust -pub struct StickyKeyAction { - pub key: KeyCode, // No = pure-mod or layer shape - pub keep: ModifierCombination, // unused for layer shape - pub layer: Option, // Some = one-shot layer (OSL) shape -} -``` -Keep all derives (`Serialize, Deserialize, MaxSize, defmt::Format, Schema`). Delete the `Action::OneShotLayer` (83) and `Action::OneShotModifier` (85) variants. **Leave `Action::OneShotKey` (87) untouched** (OSK is an explicit non-goal this round — stays a no-op warning). - -- [ ] **Step 2: Via keycode_convert.** Delete the OSL/OSM arms in `to_via_keycode` (61-69) and `from_via_keycode` (188-197), and delete/replace the OSL(3)/OSM tests (280, 287). (These map OSM/OSL ranges `0x5280-0x52BF`, which no longer have producers.) - -- [ ] **Step 3: DP-3 — Vial one-shot-timeout path.** **STOP. Record the DP-3 decision.** Then: - - *If keeping (recommended):* rename the storage field `one_shot_timeout` → `sticky_key_timeout` and the keymap accessor `set_one_shot_timeout`/`one_shot_timeout` (`keymap.rs:511,561`) to `sticky_key_timeout`/`set_sticky_key_timeout`, but **leave `SettingKey::OneShotTimeout = 0x06` numeric value and the Vial byte layout unchanged** (rename the enum variant label only if desired; the wire value must not move). Re-point the `vial.rs` Get/Set handlers (127-130, 184-186) at the unified timeout. - - *If dropping:* delete `SettingKey::OneShotTimeout`, the two `vial.rs` handlers, `FlashOperationMessage::OneShotTimeout` (storage 145, handler 803-804), and the persisted field (310, 336, 514). **This is a second Vial-protocol break — flag it loudly in DP-4's evaluation.** - -- [ ] **Step 4: Storage (`storage/mod.rs`).** Per the DP-3 choice, update the persisted `BehaviorConfig` field (310), serialize (336), deserialize (514) to read the unified `sticky_key.timeout`. (Today's deserialize at 514 writes `behavior_config.one_shot.timeout`; re-point to `behavior_config.sticky_key.timeout`.) - -- [ ] **Step 5: Build + snapshot check.** `cargo build -p rmk-types -p rmk`. The wire-format change will likely break a postcard/Schema **snapshot test** (the branch has regenerated snapshots before — see commits `3de61454`, `3d8d5723`). If a snapshot test fails, regenerate it deliberately (do not hand-edit) and **note in the commit that the wire format changed** — this is the DP-4 break surfacing early. Run the snapshot regen exactly as the existing CI/scripts do (look for `insta` or a `*_snapshot` test + `cargo insta review` / `INSTA_UPDATE`). - -- [ ] **Step 6: Commit.** -```bash -git add rmk-types/src/action/mod.rs rmk/src/host/via/keycode_convert.rs rmk-types/src/protocol/vial.rs rmk/src/host/via/vial.rs rmk/src/storage/mod.rs -git commit -m "feat(wire): StickyKeyAction carries layer payload; remove OneShotModifier/OneShotLayer variants" -``` - -### Task 1.5: Rewrite config/parse-facing tests to the new syntax - -**Files:** -- Modify: `rmk/tests/keyboard_one_shot_test.rs`, `rmk/tests/keyboard_sticky_key_test.rs` (syntax/config only this stage — behavior assertions stay; they'll be the Stage 2/3 gates) - -- [ ] **Step 1: Mechanical syntax migration.** In both test files, rewrite keymap macros and configs: - - `osm!(mods)` → `sk_mod!(mods)` - - `osl!(n)` → `sk_layer!(n)` - - `sk!(key, mods, max_repeat, timeout_ms, exit)` → `sk!(key, mods)` (drop the tail; move `max_repeat`/`release_on_layer_change` intent into the `StickyKeyConfig` the test builds) - - `OneShotConfig { timeout }` / `OneShotModifiersConfig { activate_on_keypress, quick_release }` / `StickyKeyConfig { timeout }` → one `StickyKeyConfig { timeout, activate_on_keypress, quick_release, max_repeat, release_on_layer_change }`. - - `test_sk_per_key_timeout_overrides_global` (414): per the Stage 0 flag, **delete** it (capability deferred) and add a one-line comment in the file `// per-key timeout removed this round (deferred, spec Section 4); see parity catalogue`. - -- [ ] **Step 2: Adjust accepted-break expectations.** Tests that relied on alt-tab having *no* timeout, or SK defaulting to `exit_on_layer_change=true`, must set the config explicitly (`release_on_layer_change: true` where the old test assumed exit-on-change). Use the Stage 0 "Accepted behavior changes" list as the checklist. - -- [ ] **Step 3: Compile the test crate only (do not expect green yet).** `cargo nextest run --no-default-features --features=split,vial,storage,async_matrix,_ble --no-run`. Expected: compiles (engine still old → behavior tests may fail at runtime, that's Stage 2/3). If it does not compile, the parser/macro/wire work from 1.1-1.4 has a gap — fix before proceeding. - -- [ ] **Step 4: Commit.** -```bash -git add rmk/tests/keyboard_one_shot_test.rs rmk/tests/keyboard_sticky_key_test.rs -git commit -m "test: migrate one-shot/sticky tests to SK(...) syntax and unified config (Stage 1)" -``` - -**Stage 1 Gate:** Config/parse/macro/wire crates build; test crate compiles; `OSM(...)`/`OSL(...)`/5-positional-`SK(...)` now produce build errors. Behavior tests not yet green (engine pending). Run `cargo build` across the workspace to confirm only the engine `keyboard.rs`/`oneshot.rs`/`sticky_key.rs` arms remain to migrate. - -**STAGE 1 COMPLETE (2026-06-05).** Commits: cbb75169 (1.1) · 9ae4008c (1.2) · a24b198a (1.3) · 28416fa5 (1.4) · cefc6e1b (1.5) · f19c4734 (plan DPs). Both per-task reviews (spec + code-quality) passed for every task. Gate status: -- ✅ rmk-config, rmk-macro, rmk-types build clean (rmk-types under `--features host`; snapshots regenerated for the wire-format change — base+bulk Action-carrying endpoints only). -- ✅ Exactly 10 remaining `rmk` lib errors, ALL in engine files (`keymap.rs` ×2, `keyboard/oneshot.rs` ×2, `keyboard/sticky_key.rs` ×4, `keyboard.rs` ×2) under the CI feature set `--no-default-features --features=split,vial,storage,async_matrix,_ble`. These are the Stage 2 migration targets. -- ⚠️ **CARRY-FORWARD D2 — "test crate compiles" deferred to the Stage 2 gate.** The plan assumed the engine still compiled through Stage 1, but Tasks 1.1–1.4 removed the symbols the old engine depends on, so the `rmk` lib (and therefore the test targets) cannot compile until Stage 2. The Task 1.5 test migration was verified at the symbol level only (grep-clean of `osm!`/`osl!`/`OneShotConfig`/`OneShotModifiersConfig`/`one_shot_modifiers`/>2-arg `sk!`; API-surface review; semantic per-key→global remap audited). **Stage 2 gate must compile + run both migrated test files** (`keyboard_one_shot_test.rs`, `keyboard_sticky_key_test.rs`) — that is where the migration is actually validated. -- DP-3 applied (Vial `SettingKey::OneShotTimeout = 0x06` kept byte-identical; internal `one_shot_timeout`→`sticky_key_timeout` rename across keymap/context/vial/storage). - ---- - -## Stage 2 — Engine: shape dispatch + absorb OSM - -**Goal:** One latch state; pure-mod path with terminating-key application (3b), accumulation (3c), and shape-gated `activate_on_keypress`/`quick_release` (3a). Delete the inline `select` timeout blocks. Remove the **OSM/OSL producers** of `unprocessed_events` (but **keep** the queue + consumer — the Clear Peer BLE producer at `keyboard.rs:1683` remains; spec Risk #4 audit = NOT sole producers). - -**Gate:** all OSM-behavior tests green against the new syntax; all (non-deferred) SK tests green. New 3b + 3c regression tests green. - -### Task 2.1: Define the unified latch (resolves DP-1 + DP-2) - -**Files:** -- Modify: `rmk/src/keyboard/sticky_key.rs:24-66` (latch state + helpers) -- Decision: DP-1 (payload encoding — must already be recorded from Task 1.4), DP-2 (latch home) - -- [x] **Step 1: STOP — record DP-2.** Write the decision (recommended: fold `oneshot.rs` into `sticky_key.rs`, delete `oneshot.rs`) into the Decision Points section above. - -- [x] **Step 2: Replace `StickyKeyState`** (enum `None | Active{...}` at 24-36) with the unified latch carrying everything the spec lists (Section 3e): `mods`, optional `key`, optional `layer`, `phase` (Pressed/Latched/Held), `repeat_count`, `deadline: Option`. Suggested shape: -```rust -#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] -pub(crate) enum SkPhase { - #[default] - Pressed, // SK pressed, not yet consumed - Latched, // armed, waiting for the next (foreign) key - Held, // promoted to held (key released after another key was used) -} - -#[derive(Clone, Copy, Default)] -pub(crate) enum StickyKeyState { - #[default] - None, - Active { - mods: ModifierCombination, - key: KeyCode, // No = pure-mod / layer shape - layer: Option, // Some = OSL shape - phase: SkPhase, - repeat_count: u16, - deadline: Option, - }, -} -``` -- [x] **Step 3: Re-implement the helper methods** (`value`/`is_active`/`deadline` at 38-66, plus new shape predicates `is_pure_mod()` = `key == No && layer.is_none()`, `is_tap_key()` = `key != No`, `is_layer()` = `layer.is_some()`). `value()` returns the held mods for `resolve_explicit_modifiers`. Replace `exit_on_layer_change()` (it was per-action; now read `release_on_layer_change` from config — the helper becomes a config read in `keyboard.rs`, see Task 2.4). - -- [x] **Step 4: Build.** `cargo build -p rmk ...` — expect failures only in the `process_*`/dispatch sites (next tasks). Commit the state shape alone: -```bash -git add rmk/src/keyboard/sticky_key.rs -git commit -m "feat(engine): unified SK latch carrying mods/key/layer/phase/repeat/deadline (DP-1, DP-2)" -``` - -**TASK 2.1 COMPLETE (2026-06-06).** Commits: `1056db2d` (DP-2 plan record) · `f329ca4e` (unified SK latch — sticky_key.rs only) · `d2a17499` (sentinel fix, see below). Both reviews passed (spec ✅; code-quality ✅ after fixes). -- Latch implemented exactly as specced; `is_pure_mod` requires BOTH `key == Hid(No)` AND `layer.is_none()`. The `"No"` sentinel is `KeyCode::Hid(HidKeyCode::No)` — `KeyCode` has **no bare `No` variant**. -- Code-quality review surfaced a **Stage-1 sentinel defect**: `sk_mod!`/`sk_layer!` in `layout_macro.rs` (and doc prose in `rmk-types/src/action/mod.rs`) emitted the non-existent `KeyCode::No`. Fixed now (commit `d2a17499`) since Task 2.1's predicates define that sentinel contract. This removes one class of the test-crate-compile errors that Task 2.2 must clear (carry-forward D2). -- `is_tap_key` defined as `is_active() && !is_pure_mod() && !is_layer()` (sentinel-independent, per reviewer). -- A `phase()` accessor was **declined** as speculative (YAGNI); Task 2.2 adds accessors when it writes the engine body (same file, zero extra churn). -- Build blast radius after 2.1: exactly the expected consumer-site errors (`process_action_sticky_key` body, `keyboard.rs` `.exit_on_layer_change()`/`Action::OneShot*` arms, `keymap.rs` `one_shot_modifiers`, `oneshot.rs` `one_shot_timeout()`) — all Task 2.2/2.4/3.1 targets. No errors in the new latch/helpers. - -**STAGE 2 EXECUTION DECISION (2026-06-06, confirmed by user): combine Tasks 2.2 + 2.3 + 2.4 into one engine-migration work unit.** Rationale: the `rmk` lib does not compile after Stage 1 (carry-forward D2), and the blocking errors are split across 2.2 (`process_action_sticky_key` body), 2.4 (`keyboard.rs` `Action::OneShot*` arms, `.exit_on_layer_change()` calls; `keymap.rs` `one_shot_modifiers`) and 2.4/3.1 (`oneshot.rs` `one_shot_timeout()`). The three tasks all edit the same function and the same `keyboard.rs` sites, and no test can run until all three land — so the per-task TDD gates in 2.2/2.3 are not individually satisfiable (the plan's line-160 assumption that Stage 1 kept the lib compiling failed). They are executed by one implementer to a compiling, fully test-green state (OSM behavior + non-deferred SK + new 3b/3c regressions), followed by a single two-stage review over the combined diff. OSL behavior tests remain failing until Stage 3 (expected). Task sub-sections 2.2/2.3/2.4 below retain their full specs as the combined work unit's checklist. - -### Task 2.2: Pure-mod path — accumulation, activate_on_keypress, quick_release, terminating-key application - -**Files:** -- Modify: `rmk/src/keyboard/sticky_key.rs` (`process_action_sticky_key`, was 69-125), fold in OSM logic from `oneshot.rs:33-114` -- Modify: `rmk/src/keyboard.rs:1380-1403` (`resolve_explicit_modifiers`), `1220-1230` (foreign-key hook) - -- [ ] **Step 1: Write the failing regression test first (3b — terminating-key).** Add to `rmk/tests/keyboard_sticky_key_test.rs`: -```rust -#[test] -fn test_sk_pure_mod_applies_to_terminating_key() { - // SK(LGui) then P must emit Gui+P (OSM-via-SK; today's SK engine drops the mod). - key_sequence_test! { - keyboard: create_test_keyboard_with_config(/* default StickyKeyConfig */), - sequence: [ - // press+release the SK(LGui) key, then press+release P - [SK_GUI_ROW, SK_GUI_COL, true, 10], [SK_GUI_ROW, SK_GUI_COL, false, 10], - [P_ROW, P_COL, true, 10], [P_ROW, P_COL, false, 10], - ], - expected_reports: [ - [KC_LGUI, [kc_to_u8!(P), 0,0,0,0,0]], // P sent WITH Gui - [0, [0,0,0,0,0,0]], - ] - }; -} -``` -(Adapt row/col + keymap to the file's existing harness; mirror an existing OSM test's scaffolding.) - -- [ ] **Step 2: Run it — verify it fails.** `cargo nextest run ... -E 'test(test_sk_pure_mod_applies_to_terminating_key)'`. Expected: FAIL (mod not applied / wrong report). - -- [ ] **Step 3: Implement pure-mod transmission.** In `process_action_sticky_key`, branch on shape. For pure-mod (`key == No && layer.is_none()`): - - On press: accumulate into the latch (`mods |= params.keep`) — port `cur | new` from `oneshot.rs:42/59/62` (this is 3c). Honor `activate_on_keypress` (config): if true, the mod is emitted immediately (set phase/flag so `resolve_explicit_modifiers` includes it now); if false, defer to the terminating key. - - Set `deadline` from `config.timeout` (replaces the inline `select`). - - Port the OSM state transitions (`Initial/Single/Held`) from `update_osm` (`oneshot.rs:165-182`) into the `SkPhase` transitions. - -- [ ] **Step 4: Terminating-key application (3b) in `resolve_explicit_modifiers` + foreign-key hook.** In `resolve_explicit_modifiers` (`keyboard.rs:1380-1403`) the latch `value()` already contributes held mods. The new work: for a **pure-mod** active latch, the held mod must remain applied **through** the terminating key's report, then release on that key's press or release per `quick_release`. Port OSM's "decorate the next key then release" from the OSM path. In the foreign-key hook (`keyboard.rs:1220-1230`), branch: pure-mod → do **not** release before the foreign key (apply mod, release after per `quick_release`); tap-key → release first (unchanged, clean foreign key). - -- [ ] **Step 5: Run the 3b test + the migrated OSM suite.** `cargo nextest run --no-default-features --features=split,vial,storage,async_matrix,_ble`. Iterate until all `test_osm_*` (basic, held, multiple_keys, rolling, chain_mode_*, quick_release_*) and the new 3b test pass. Use `superpowers:systematic-debugging` on any failure — the parity catalogue says exactly which axis each test pins. - -- [ ] **Step 6: Add + pass the accumulation regression (3c).** -```rust -#[test] -fn test_sk_pure_mod_accumulates_across_taps() { - // SK(LCtrl) then SK(LShift) then P -> Ctrl+Shift+P - // ... harness ... - expected_reports: [ [KC_LCTRL | KC_LSHIFT, [kc_to_u8!(P),0,0,0,0,0]], [0,[0,0,0,0,0,0]] ] -} -``` -Run; verify green (Step 3 already ported accumulation). - -- [ ] **Step 7: Commit.** -```bash -git add rmk/src/keyboard/sticky_key.rs rmk/src/keyboard.rs rmk/tests/keyboard_sticky_key_test.rs -git commit -m "feat(engine): pure-mod SK shape — accumulation, activate_on_keypress/quick_release, terminating-key application (3a/3b/3c)" -``` - -### Task 2.3: Tap-key path parity (preserve alt-tab) + timeout on run-loop deadline - -**Files:** -- Modify: `rmk/src/keyboard/sticky_key.rs` (tap-key branch), `rmk/src/keyboard.rs:144-185` (deadline race already races `sticky_key_state.deadline()`) - -- [ ] **Step 1: Implement tap-key branch.** For `key != No`: always immediate transmission (ignore `activate_on_keypress`/`quick_release`), send `keep` mods + `key` on each press, `repeat_count += 1`, deactivate silently when `max_repeat > 0 && repeat_count > max_repeat` (port the existing `sticky_key.rs:90-102` logic), refresh `deadline` from `config.timeout` each press. On a foreign key, release **without** applying the mod (existing behavior). - -- [ ] **Step 2: Confirm the deadline race already drives SK timeout.** `keyboard.rs:158-177` already races `self.sticky_key_state.deadline()` and calls `release_sticky_key_if_active()` on expiry. Verify the unified latch's `deadline()` helper returns the right `Option` for all shapes (pure-mod, tap-key, layer). No new race machinery — this is the single timeout mechanism the spec wants (Section 3e). - -- [ ] **Step 3: Run the SK suite.** `cargo nextest run ...`. Iterate until `test_sk_basic_flow_press_twice`, `test_sk_shift_does_not_release_sk`, `test_sk_rapid_three_presses`, `test_sk_combined_modifiers`, `test_sk_timeout`, `test_sk_timeout_resets_on_press`, `test_sk_max_repeat` pass. (`test_sk_exits_on_layer_change`/`test_sk_survives_layer_change` finish in Task 2.4.) - -- [ ] **Step 4: Commit.** -```bash -git add rmk/src/keyboard/sticky_key.rs rmk/src/keyboard.rs -git commit -m "feat(engine): tap-key SK shape parity (alt-tab cycling, max_repeat) on unified latch" -``` - -### Task 2.4: Delete inline `select`, remove OSM/OSL `unprocessed_events` producers, retire OSM dispatch - -**Files:** -- Modify: `rmk/src/keyboard/oneshot.rs` (delete OSM logic + inline `select` 75-93), `rmk/src/keyboard.rs:1316-1328` (dispatch), `1380-1403`, `1241/1250/1268/1276/1600` (layer-change release → config `release_on_layer_change`) - -- [ ] **Step 1: Audit `unprocessed_events` (Risk #4) — record the finding.** Verified producers: `oneshot.rs:89` (OSM), `oneshot.rs:148` (OSL), **`keyboard.rs:1683` (Clear Peer BLE, `#[cfg(feature="split")]`)**. Consumer: `keyboard.rs:148-150`. **Conclusion: OSM/OSL are NOT the sole producers — the queue and consumer must STAY for Clear Peer.** Only delete the OSM/OSL push (89, 148) and the OSM `retain` (49). Write this conclusion as a code comment near the consumer so a future reader doesn't re-delete the queue. - -- [ ] **Step 2: Delete OSM from `oneshot.rs`.** Remove `process_action_osm` (33-114) including the inline `select(timeout, …)` (75-93) and the `retain` (49), and `update_osm` (165-182). (OSL removal is Stage 3; if folding `oneshot.rs` into `sticky_key.rs` per DP-2, keep OSL temporarily here or move it — your call, but keep it compiling.) - -- [ ] **Step 3: Retire the OSM dispatch arm.** In `keyboard.rs:1316-1328`, delete the `Action::OneShotModifier(m)` arm (now an unreachable/removed variant) and the cross-wise `update_osm`/`update_osl` calls tied to it. Remove the `osm_state` field (220) and its `use`/init. `resolve_explicit_modifiers` (1380-1403) now reads only the unified latch (the `osm_state` branch at ~1384-1389 is deleted). - -- [ ] **Step 4: Layer-change release → config.** The five `sticky_key_state.exit_on_layer_change()` call sites (1241, 1250, 1268, 1276, 1600) must now read `config.sticky_key.release_on_layer_change` instead of a per-action field (which no longer exists). Replace each `if self.sticky_key_state.exit_on_layer_change()` with `if self.keymap...sticky_key_config().release_on_layer_change` (use the actual config accessor; add one to `keymap.rs` if absent). - -- [ ] **Step 5: Run the full suite.** `cargo nextest run --no-default-features --features=split,vial,storage,async_matrix,_ble`. Iterate until **every** OSM-behavior and SK test is green (OSL tests will still fail until Stage 3 — that's expected; note which). Run `cargo clippy --no-default-features --features=... ` and clear warnings in touched files. - -- [ ] **Step 6: Commit.** -```bash -git add rmk/src/keyboard/oneshot.rs rmk/src/keyboard.rs rmk/src/keymap.rs -git commit -m "refactor(engine): retire OSM path + inline select; remove OSM/OSL unprocessed_events producers (keep queue for Clear Peer)" -``` - -**Stage 2 Gate:** All OSM-behavior tests + all (non-deferred) SK tests + the 3b/3c regressions green. Inline `select` gone. `unprocessed_events` queue retained (Clear Peer), OSM/OSL producers removed. Clippy clean in touched files. - -**STAGE 2 COMPLETE (2026-06-06).** Executed as one combined work unit (see execution decision above). Commits: `e0a67bfb` (pure-mod 3a/3b/3c) · `e9a0c092` (retire OSM path + inline select; remove OSM/OSL `unprocessed_events` producers, keep queue for Clear Peer) · `35e62c77` (code-quality review fix). Both review gates passed: **spec ✅** (independently verified by code read + test run) and **code-quality ✅ APPROVED**. -- **nextest: 466 run, 461 passed, 5 failed.** The 5 failures are EXACTLY the deferred OSL behavior tests — `test_osl_basic_single_behavior`, `test_osl_held_behavior`, `test_osl_multiple_keys`, `test_osl_then_osm`, `test_osm_then_osl` (Stage 3). All 25 OSM tests, all non-deferred SK tests, and both new regressions (`test_sk_puremod_terminating_key` 3b, `test_sk_puremod_cross_tap_accumulation` 3c) are green. Note: `test_osl_timeout`/`test_osm_and_osl_timeout` pass only because their expected base-layer/no-mod output coincides with the no-OSL-layer result. -- Engine: `process_action_sticky_key` dispatches by shape → `process_sticky_pure_mod` (OSM port: accumulation, `activate_on_keypress`/`quick_release`, terminating-key application via `update_sticky_key` foreign-key hook + `resolve_explicit_modifiers`) and `process_sticky_tap_key` (alt-tab cycling, `max_repeat`). Single timeout = run-loop deadline race. -- OSM fully retired: `process_action_osm`/`update_osm` deleted from `oneshot.rs`; `Action::OneShotModifier`/`OneShotLayer` dispatch arms + `osm_state` field + its `resolve_explicit_modifiers` branch removed; `one_shot_modifiers_config()` → `sticky_key_config()` in `keymap.rs`; five `exit_on_layer_change()` sites read `config.release_on_layer_change`. -- OSL kept compiling for Stage 3 (`process_action_osl`/`update_osl`/`OneShotState` retained; `#[allow(dead_code)]`+TODO on the now-uncalled `process_action_osl`; layer branch in `process_action_sticky_key` is an early-return stub). -- **Scope note:** `keyboard_combo_test.rs` was also migrated off the removed OSM API (Stage 1 left it broken; mechanical `osm!`→`sk_mod!` + config rename, no assertion changes) — needed for the suite to compile. -- **Code-quality review adjudication (controller):** #1 *Held pure-mod spurious timeout* — ACCEPTED & FIXED in `35e62c77` (clear deadline on Held promotion; restores OSM's no-timeout-while-held parity, a real divergence not in the accepted-changes list). #2 *cross-shape latch contamination* — behavior DEFERRED (unspecified concurrent-mixed-shape case; no test/spec); documented with a single-latch-assumption comment. #3 *`repeat_count` u16 overflow* — pre-existing, carried over unchanged; left per surgical-changes rule (noted only). - ---- - -## Stage 3 — Engine: absorb OSL - -**Goal:** `SK(MO(n))` activates layer `n` as one-shot on the shared latch + shared deadline/foreign-key plumbing, reusing OSL's activate/deactivate logic. `release_on_layer_change` reads the config. - -**Gate:** all OSL-behavior tests green; full suite + `cargo clippy` clean. - -**STAGE 3 DESIGN DECISION (2026-06-06, confirmed by user): D1/D2 — PRESERVE existing OSM+OSL combination behavior exactly.** The OSL layer shape lives on the SAME single mutually-exclusive `sticky_key_state` latch (DP-1), not a separate field and not a combined mod+layer latch. Rule: a newly-pressed SK shape that lands on an active latch of a *different* shape REPLACES it — dropping the latched mod (→ D1: `test_osm_then_osl` emits `[0, C]`, no LShift) and/or deactivating the latched layer before applying the new shape (→ D2: `test_osl_then_osm` emits `[LShift|LCtrl, A]` because col0 resolves on the still-active layer 1 to `OSM(LShift|LCtrl)`, then that OSM replaces the OSL latch and deactivates layer 1). Same-shape mod+mod still accumulates (Stage 2 cross-tap behavior, unchanged). This is a pure behavior-preserving refactor: D1/D2 are NOT accepted behavior changes — the 5 OSL tests stay as-written and are the grading contract. Rationale: matches the parity catalogue's "the SK engine must reproduce this outcome" note (D1), keeps the one-engine/one-latch model from DP-1, and is the lowest-risk path to green. A combined-latch "fix" was rejected as scope creep with no anchoring test. - -### Task 3.1: Layer shape on the unified latch - -**Files:** -- Modify: `rmk/src/keyboard/sticky_key.rs` (layer branch), `rmk/src/keyboard.rs:1316-1328` (dispatch), `rmk/src/keyboard/oneshot.rs` (port OSL activate/deactivate 119-133/184-193, then delete) - -- [ ] **Step 1: Run the migrated OSL tests — confirm current failure.** `cargo nextest run ... -E 'test(/osl/) or test(/osm_then_osl/) or test(/osl_then_osm/)'`. Expected: FAIL (no layer handling yet). - -- [ ] **Step 2: Implement the layer branch.** In `process_action_sticky_key`, for `layer.is_some()`: - - On press: activate the layer (`self.keymap.activate_layer(n)`) — port from `oneshot.rs:119-133` (including deactivating a previously-latched OSL layer if any). - - Arm the latch (phase transitions mirror OSL's `update_osl` at 184-193: deactivate on the Single→consume transition). - - Set `deadline` from `config.timeout`. - - On the terminating (foreign) key and on timeout: deactivate the layer, clear the latch. Reuse `release_sticky_key_if_active` so the deadline race (Task 2.3 Step 2) covers layer expiry too. - -- [ ] **Step 3: Dispatch.** `keyboard.rs` already routes all `Action::StickyKey` to `process_action_sticky_key` (1326-1328). Delete the `Action::OneShotLayer(l)` arm (1316-1320) and `osl_state` field (217) + its `use`/init + `update_osl`. - -- [ ] **Step 4: Delete OSL from `oneshot.rs`.** Remove `process_action_osl` (116-161) including inline `select` (139-152), the `unprocessed_events.push` (148), and `update_osl` (184-193). If `oneshot.rs` is now empty, delete the file and its `mod oneshot;` decl (`keyboard.rs:44`) + `use` (31) per DP-2. - -- [ ] **Step 5: Run OSL suite + full suite.** `cargo nextest run --no-default-features --features=split,vial,storage,async_matrix,_ble`. Iterate until `test_osl_*`, `test_osm_then_osl`, `test_osl_then_osm`, `test_osm_and_osl_timeout` all pass **and** nothing earlier regressed. - -- [ ] **Step 6: Commit.** -```bash -git add rmk/src/keyboard/sticky_key.rs rmk/src/keyboard.rs rmk/src/keyboard/oneshot.rs -git commit -m "feat(engine): absorb OSL — SK(MO(n)) layer shape on unified latch; delete oneshot.rs" -``` - -### Task 3.2: Full-suite + clippy gate - -**Files:** none (verification task) - -- [ ] **Step 1: Full feature matrix.** From repo root: `sh scripts/test_all.sh`. Expected: all green. (If the script enumerates feature combos, every combo must pass — the wire/snapshot changes from Stage 1 may surface here.) - -- [ ] **Step 2: Clippy across the workspace.** `cargo clippy --workspace --no-default-features --features=split,vial,storage,async_matrix,_ble -- -D warnings` (match the project's lint invocation if different). Fix warnings in touched files only (per surgical-changes rule). - -- [ ] **Step 3: Confirm OSK untouched.** Grep `OneShotKey` — confirm it remains a no-op warning (non-goal this round). No code change; just verify it wasn't accidentally altered. - -- [ ] **Step 4: Commit any lint fixes.** -```bash -git add -A -git commit -m "chore: clippy clean + full-suite green after OSM/OSL absorption (Stage 3 gate)" -``` - -**Stage 3 Gate:** Full suite + full feature matrix + clippy all green. `oneshot.rs` gone (or empty + removed). OSK untouched. - ---- - -**STAGE 3 COMPLETE (2026-06-06).** Commits: `fe1e4ec6` (feat: absorb OSL — SK(MO(n)) layer shape on unified latch; delete oneshot.rs) · `9f03aac5` (docs(engine): Stage 3 review fixes). Plan-decision commit `0171d6d7` (D1/D2 design). - -- **Engine:** layer shape implemented on the single mutually-exclusive `sticky_key_state` latch — `process_sticky_layer` (activate/arm/deadline on press; Latched-arm or Held-deactivate on release), `update_sticky_key` extended to drive the layer shape (Pressed→Held promotion via the shared arm; Latched consumed + layer deactivated on the foreign key's RELEASE), `release_sticky_key_if_active` deactivates the active layer and suppresses the HID report for the layer shape. Latch-replacement rule per the Stage 3 design decision (pure-mod↔layer replace; layer-on-layer deactivate-old/activate-new keeping phase; pure-mod+pure-mod still accumulates). No inline select/Timer — timeout is the run-loop deadline race only. -- **Cleanup:** `oneshot.rs` DELETED (DP-2); removed `osl_state` field+init, `OneShotState` import, `mod oneshot`, and both `update_osl` call sites from `keyboard.rs`. `grep oneshot/OneShotState/update_osl/process_action_osl/osl_state rmk/src` → no live references. -- **Tests:** `cargo nextest ... --features=split,vial,storage,async_matrix,_ble` → **466 run, 466 passed, 0 skipped.** All 7 layer tests green (`test_osl_basic_single_behavior`, `test_osl_held_behavior`, `test_osl_multiple_keys`, `test_osl_timeout`, `test_osm_then_osl` [D1 `[0,C]`], `test_osl_then_osm` [D2 `[LShift|LCtrl,A]`], `test_osm_and_osl_timeout`). No assertions changed. Clippy clean. -- **Reviews:** spec ✅ (independently verified, 466/466) and code-quality ✅ "Ready to merge: Yes" (only Minor items). Adjudication: fixed the stale `unprocessed_events` comment (its OSL inline-select producer was deleted this stage) + added a doc note that a bare `Action::Modifier` intentionally no longer consumes a latched OSL (untested divergence beyond accepted D1/D2; the implementer flagged it too) → commit `9f03aac5`. Left as-is (defensible/pre-existing per surgical rule): `process_sticky_layer` async-without-await (dispatch symmetry), the defensive `Latched`-source release arm (actually reachable via layer-on-layer re-press), and the pre-existing unused `StickyKeyState::value()`. -- **Deferred to Stage 3.2 / later:** the repo-root `sh scripts/test_all.sh` full feature-matrix run and `OneShotKey` (OSK) untouched-confirmation are the remaining Stage 3.2 gate steps; can fold into Stage 5 local verification. - ---- - -## Stage 4 — Docs (Section 6 requirement) - -**Goal:** Document the pure-mod vs tap-key shape distinction — specifically that `activate_on_keypress` and `quick_release` are honored **only for pure-mod SKs and silently ignored for tap-key SKs** — prominently in the keymap config reference and the `[behavior.sticky_key]` section. Include the rationale (a tap-key has nothing to defer) and the three-shape table from the spec Overview. - -### Task 4.1: Write the docs - -**Files:** -- Modify: user docs — locate exact files first (likely `docs/` keymap config reference + a behavior/config page). Grep the repo's docs tree for the old `OSM`/`OSL`/`one_shot` documentation and the `[behavior.sticky_key]`/`[behavior.one_shot*]` sections. - -- [ ] **Step 1: Find the doc pages.** `grep -rn "OSM\|OSL\|one_shot\|sticky_key" docs/ *.md` (in rmk-fork). Identify the keymap-action reference and the behavior-config reference pages. - -- [ ] **Step 2: Replace OSM/OSL syntax docs with the SK shapes.** Document `SK(LGui)` (pure-mod = old OSM), `SK(MO(n))` (layer = old OSL), `SK(key, [mods])` (tap-key = alt-tab). Add the migration table from spec Section 1. - -- [ ] **Step 3: Replace the three config tables' docs with the single `[behavior.sticky_key]`.** Document all five keys (`timeout`, `activate_on_keypress`, `quick_release`, `max_repeat`, `release_on_layer_change`) with defaults (1s / false / false / 0 / false). - -- [ ] **Step 4: Add the shape-magic note prominently.** A callout/warning block stating: `activate_on_keypress` and `quick_release` apply **only to pure-mod SKs** (`SK(LGui)`); they are **silently ignored for tap-key SKs** (`SK(Tab, [LAlt])`) because a tap-key has nothing to defer. Include the three-shape table. - -- [ ] **Step 5: Note the accepted breaks.** Document that `OSM(...)`/`OSL(...)` and the 5-positional `SK(...)` form are removed (build errors), that alt-tab SKs now have a 1s default timeout, and that `exit_on_layer_change` is renamed `release_on_layer_change` (default false). - -- [ ] **Step 6: Commit.** -```bash -git add docs/ -git commit -m "docs: SK shapes + unified [behavior.sticky_key]; pure-mod vs tap-key magic-field rule (Section 6)" -``` - -**Stage 4 Gate:** Docs reviewed (the user reviews; surface the diff). The "magic field" rule is explicit and the three-shape table is present. - ---- - -**STAGE 4 COMPLETE (2026-06-08).** Commit: `242d5945` (docs: SK shapes + unified [behavior.sticky_key]; pure-mod vs tap-key magic-field rule). 3 files, +74/−46. - -- **Files:** `docs/docs/main/docs/configuration/behavior.md` (merged the three old sections — `## One-Shot`, `## One-Shot Modifiers`, `## Sticky Key` — into one `## Sticky Key` section: three-shape table, 5-field config table with defaults, chain-mode vs quick-release detail, `:::warning` magic-field callout, default-values block, OSSM/quick-release/longer-timeout examples, and a `### Migration from OSM / OSL` subsection with table + accepted-breaks bullets; fixed the morse example `OSL(1)`→`SK(MO(1))`), `layout.md` (deleted the `OSL(n)`/`OSM(modifier)` list items, merged into one `SK(...)` item documenting all three shapes, dropped the removed 5-positional sentence, renumbered the list 1–8), `appendix.md` (replaced the `one_shot`/`one_shot_modifiers` example blocks with one `sticky_key` block listing all 5 fields). -- **Reviews:** spec ✅ (independently verified) and code-quality ✅ "Ready to merge: Yes" after fixes. Adjudication — all findings ACCEPTED & FIXED: (spec) appendix block was missing `max_repeat`/`release_on_layer_change` → added all 5; magic-field rationale imprecise for the layer case → rewrote to "a tap-key SK sends its modifier eagerly and holds it across repeats; a layer SK sends no modifier at all." (code-quality) `activate_on_keypress` table cell described `false` behavior under the field (verified against `sticky_key.rs:144-146` — `true` fires on the SK's OWN press) → corrected; pre-existing dead anchor `./layout#keymap-config` on the rewritten cross-link → fixed to `#keyboard-layout-configuration` (folded in only because I was already authoring that line). Reviewer's "both files link to it" was imprecise — only `behavior.md:101` did. -- **No source/test/toml touched** — docs-only. A stray untracked `docs/superpowers/plans/2026-05-22-sticky-key.md` was accidentally swept into an interim amend by `git add docs/`; removed via `git reset --soft` + targeted re-stage, left on disk untracked. Final commit is exactly the 3 config docs. - ---- - -## Stage 5 — Local verification, hardware testing, and wire/Vial migration evaluation (DP-4) - -**Goal:** Run the complete local suite and capture the **DP-4** wire/Vial/storage migration finding before any move toward PR #859. **This stage does not push to the PR.** The user runs hardware testing personally. - -### Task 5.1: Full local verification - -**Files:** none - -- [ ] **Step 1: Full suite, exact command.** From `rmk-fork/rmk/`: `cargo nextest run --no-default-features --features=split,vial,storage,async_matrix,_ble`. From repo root: `sh scripts/test_all.sh`. Capture output. Per `superpowers:verification-before-completion`, paste the real pass/fail counts — no "should pass." - -- [ ] **Step 2: Parity audit against Stage 0 catalogue.** Walk the Stage 0 catalogue row by row; confirm each behavior axis has a green test on the new surface (or is explicitly recorded as a deferred capability — only the per-key timeout). List any axis with no covering test and add a test if found missing. - -- [ ] **Step 3: Build the consumer firmware.** In RMKSofleV2, the `[patch.crates-io]` points at this fork. Build both layouts to confirm the new syntax/wire compiles end-to-end against a real keymap: `cargo make uf2` (from `/mnt/c/RandomProjects/GitHubRepoProjects/RMKSofleV2`). **The Sofle keymaps use `OSM(...)`/`OSL(...)`? If so they must be rewritten to `SK(...)` first** — grep the `keyboard_*.toml` files and migrate. Expected: 4 `.uf2` files build. - -### Task 5.2: DP-4 — evaluate wire/Vial/storage migration impact - -**Files:** -- Append findings to: `docs/superpowers/plans/2026-06-03-sk-absorbs-oneshot-plan.md` (this file) or a sibling `sk-oneshot-migration-findings.md` - -- [ ] **Step 1: Determine the storage blast radius.** The `StickyKeyAction` struct + `Action` enum changed (postcard wire order; removed variants). Determine whether keymaps stored in flash from a *pre-change* firmware deserialize correctly under the new layout, or are corrupted. Inspect the storage schema/version handling in `rmk/src/storage/mod.rs` — is there a schema-version field that triggers a wipe-on-mismatch? Record: **reflash needed? storage schema bump needed?** - -- [ ] **Step 2: Determine Vial state impact.** Per the DP-3 decision: if the Vial `SettingKey::OneShotTimeout` value was preserved, Vial sees no break on that setting; if dropped, Vial loses the setting. Also check whether removed OSM/OSL keycodes (`0x5280-0x52BF`) appear in any stored Vial keymap — if a user's Vial layout referenced them, what happens on load? Record: **Vial re-sync needed?** - -- [ ] **Step 3: Write the finding.** Record concretely: (reflash needed Y/N, Vial re-sync Y/N, storage schema bump Y/N, any migration code required). This is the spec's explicit DP-4 requirement and **must exist before the work moves toward PR #859.** - -- [ ] **Step 4: Hand off to hardware testing.** Stop here. Report to the user: full local suite results, the parity audit, the uf2 build result, and the DP-4 findings. **The user performs hardware testing.** Do not merge, do not push toward PR #859, do not run `finishing-a-development-branch` until the user confirms hardware works. - -- [ ] **Step 5: Commit the findings.** -```bash -git add docs/superpowers/plans/ -git commit -m "docs: DP-4 wire/Vial/storage migration findings; local verification complete (pre-hardware)" -``` - -**Stage 5 Gate:** Full local suite green (with real numbers), parity audit complete, consumer firmware builds, DP-4 findings recorded. **Awaiting user hardware confirmation before any PR movement.** - ---- - -## Self-Review (run against the spec) - -**Spec coverage:** -- Section 1 (syntax migration) → Stage 1 Tasks 1.3-1.5. ✔ -- Section 2 (config consolidation) → Stage 1 Tasks 1.1-1.2. ✔ -- Section 3a (shape dispatch / gated fields) → Stage 2 Task 2.2-2.3. ✔ -- Section 3b (terminating-key) → Stage 2 Task 2.2 (+ regression test). ✔ -- Section 3c (accumulation) → Stage 2 Task 2.2 (+ regression test). ✔ -- Section 3d (absorb OSL) → Stage 3 Task 3.1. ✔ -- Section 3e (shared latch/timeout/foreign-key/resolve sink) → Tasks 2.1, 2.3, 2.4. ✔ -- Section 4 (deferred overrides/profiles) → respected: per-key timeout test retired (1.5), no profile machinery added; DP-1/config resolve to concrete values. ✔ -- Section 5 (action payload) → DP-1 (Task 1.4/2.1); wire variant removal (1.4). ✔ -- Section 6 (docs) → Stage 4. ✔ -- Section 7 (staging/tests) → Stages 0-5 mirror the spec's Stage 0-4 + a verification stage. ✔ -- Section 8 risks: #1 terminating-key (3b test), #2 timeout-shift (Stage 5 hardware watch), #3 wire break (DP-4), #4 unprocessed_events (audited — NOT sole producers, queue kept), #5 behavior loss (Stage 0 catalogue + parity audit). ✔ -- All four open questions → DP-1 (2.1), DP-2 (2.1), DP-3 (1.4), DP-4 (5.2). ✔ - -**Decision-point integrity:** No decision is silently made — DP-1/2/3/4 each have an explicit STOP-and-record step with a stated recommendation that requires confirmation. - -**Known re-verification need:** All file:line anchors were re-checked on 2026-06-03 against `feat/osm-sticky-key-merge`, but every editing task re-greps before touching, because earlier-stage edits shift later-stage lines. The most important corrected fact vs. the spec: **`unprocessed_events` has a third (Clear Peer BLE) producer at `keyboard.rs:1683`**, so the spec's "delete `unprocessed_events`" is downgraded to "remove only the OSM/OSL producers; keep the queue" (Task 2.4 Step 1). diff --git a/docs/superpowers/plans/2026-06-10-sk-oneshot-migration-findings.md b/docs/superpowers/plans/2026-06-10-sk-oneshot-migration-findings.md deleted file mode 100644 index 76f69780d..000000000 --- a/docs/superpowers/plans/2026-06-10-sk-oneshot-migration-findings.md +++ /dev/null @@ -1,111 +0,0 @@ -# DP-4 — SK-absorbs-OneShot: wire / Vial / storage migration findings - -**Date:** 2026-06-10 -**Branch:** `feat/osm-sticky-key-merge` -**Scope:** Impact of removing `Action::OneShotModifier` / `Action::OneShotLayer` (postcard -discriminant shift) and the `0x5280–0x52BF` Vial keycodes, plus the -`one_shot_timeout → sticky_key_timeout` field rename. - -## TL;DR verdict - -| Question | Answer | -| --- | --- | -| Manual reflash-with-erase needed? | **No** — firmware self-erases on upgrade. | -| Vial re-sync / migration code needed? | **No** — stale keycodes degrade to `KeyAction::No`, no panic. | -| Storage schema version bump needed? | **No** — `BUILD_HASH` already serves this role and changes every build. | -| Can `from_via_keycode` panic on a stale keycode? | **No** — catch-all arm warns + returns `KeyAction::No`. | -| User-visible consequence | Vial dynamic keymap edits are wiped on the upgrade flash (one-time). | - -**Net: this is a safe, zero-code migration in practice.** No defensive code, no schema -field, no documented reflash step is strictly required. The single caveat is the Vial -dynamic-keymap wipe, which is inherent to any RMK firmware update (not specific to this change). - -## How the keymap is stored (the load-bearing fact) - -The keymap **is** persisted to flash, per-key, and `KeyAction`/`Action` is serialized via -**postcard (discriminant/positional wire format)** — NOT as a Vial u16 keycode. - -- Write: `FlashOperationMessage::KeymapKey { action }` → `StorageData::KeyAction(action)` - → postcard store. `rmk/src/storage/mod.rs:734`, per-key keys at `:556-567`. -- Read at boot: `StorageData::KeyAction(action)` deserialized straight back into the keymap - array. `rmk/src/host/storage.rs:73-111` (`:96`). -- `from_via_keycode` / `to_via_keycode` (`rmk/src/host/via/keycode_convert.rs:131`, `:5`) - are **protocol-boundary adapters only** — they are NOT the storage encoder. - -Consequence: removing `Action` variants **does** shift postcard discriminants, so old stored -bytes would deserialize to the wrong variant — *if they were ever read against the new layout.* -They are not, because of `BUILD_HASH` (below). - -## Why the discriminant shift is harmless: BUILD_HASH - -`rmk/build.rs:25-51` computes `BUILD_HASH = crc32(format!("{git_short_commit}_{now_nanos}"))`, -where `now_nanos` is the wall-clock build time. It is written into `constants.rs` and consumed -as `BUILD_HASH` (`rmk/src/storage/mod.rs:28`). - -Boot gate (`check_enable`, `rmk/src/storage/mod.rs:634-642`): - -```rust -if let Some(StorageData::StorageConfig(config)) = self.fetch_data(StorageKey::StorageConfig).await - && config.enable - && config.build_hash == BUILD_HASH { return true; } -false -``` - -On mismatch (`:458-485`): `flash.erase_all()` then `initialize_storage_with_config(...)` from -the **compiled-in** keymap + behavior defaults. No panic; on init error it stores -`enable: false` to avoid partial init. A regression test already exists: -`build_hash_mismatch_reinitializes_storage` (`:1009`). - -Because `BUILD_HASH` embeds both the commit id **and** the build timestamp, the old firmware -(built on `main`) and the new firmware (built on the feat branch) will always have different -hashes. Flashing the new `.uf2` therefore always triggers erase + reinit, so the new firmware -**never reads old-layout postcard bytes**. The discriminant shift is masked by design. - -> Edge note: within a single source tree, Cargo caches the build-script output (only -> `rerun-if-changed=build.rs` is declared), so two consecutive rebuilds *without* a change can -> reuse a `BUILD_HASH`. This does not affect the upgrade path — the two firmwares differ in -> source, and any `Action`-layout change forces recompilation. A `cargo clean` (already part of -> the layout cargo-make tasks) regenerates the hash regardless. - -## Vial keycode removal (0x5280–0x52BF): no panic - -Removed in commit `28416fa57`. The old mappings were -`0x5280..=0x529F → OneShotLayer`, `0x52A0..=0x52BF → OneShotModifier`. The range is now -unhandled and hits the catch-all in `from_via_keycode` -(`rmk/src/host/via/keycode_convert.rs:227-230`): - -```rust -_ => { - warn!("Via keycode {:#X} is not processed", via_keycode); - KeyAction::No -} -``` - -- A stale `0x5280` from a live Vial message → `KeyAction::No` (inert) + warn. **No panic.** -- Stale keycodes are **never decoded at boot**: storage reads typed `KeyAction` via postcard, - not via keycodes, so `from_via_keycode` is only on the live-protocol path - (`rmk/src/host/via/mod.rs:105`, `vial.rs`). -- The new `Action::StickyKey` is **not** Vial-keycode-representable: `to_via_keycode` returns `0` - + warn for it (`keycode_convert.rs:88-92`). So SK actions cannot round-trip through the Vial - desktop app — but this fails safe (no panic; SK keys simply aren't editable/visible in Vial). - StickyKey is authored via the `sk!`/`sk_mod!`/`sk_layer!` macros (compile-time TOML), not Vial. - -## The one_shot_timeout → sticky_key_timeout rename: safe - -The `BehaviorConfig` field was renamed to `sticky_key_timeout` (`storage/mod.rs:310`), but the -**wire/setting variant name was deliberately preserved**: `FlashOperationMessage::OneShotTimeout(u16)` -(`:144-145`, comment: *"variant name kept for storage-format stability"*). Its handler updates -the renamed field (`:803-804`). So the persisted setting-key encoding is unchanged — no stored -setting is orphaned by the rename. - -## Recommendation - -Ship as-is. Optionally: - -1. Add one line to the release/upgrade notes: *"Upgrading wipes Vial dynamic keymap - customizations (flash is re-initialized from firmware defaults)."* — true of all RMK updates, - worth restating here since OneShot users are the affected cohort. -2. (Optional, low value) A more specific deprecation `warn!` for the `0x5280–0x52BF` range on the - live path, for user awareness. Not required for correctness. - -No code changes are required for a safe migration. diff --git a/docs/superpowers/plans/sk-oneshot-parity-catalogue.md b/docs/superpowers/plans/sk-oneshot-parity-catalogue.md deleted file mode 100644 index d6c071d02..000000000 --- a/docs/superpowers/plans/sk-oneshot-parity-catalogue.md +++ /dev/null @@ -1,160 +0,0 @@ -# SK / OneShot Parity Catalogue (Stage 0) - -This document is the **parity checklist** for the effort to make a single "Sticky Key" -(SK) engine fully absorb one-shot modifier (OSM) and one-shot layer (OSL) behaviors. -It has one row per behavior axis pinned by the existing tests. Every later stage is -graded against this catalogue. - -**Source files characterized (verified, not copied from the plan):** - -- `rmk/tests/keyboard_one_shot_test.rs` — 25 tests (verified count == 25). -- `rmk/tests/keyboard_sticky_key_test.rs` — 11 tests (verified count == 11). - -**Verification method:** each row below was derived by reading the keymap definitions, -the per-test `BehaviorConfig` / `OneShotModifiersConfig` / `StickyKeyConfig`, and the -literal `expected_reports` assertions in the test bodies — not from the plan's prose. - -## Shared keymap context - -### OSM/OSL keymap (`keyboard_one_shot_test.rs`) - -```text -Layer 0: OSM(LShift) OSL(1) A TH(B,C) OSM(LCtrl) WM(B, LGui) -Layer 1: OSM(LShift|LCtrl) No C D E F -``` - -Cols by index: `0=OSM(LShift)`, `1=OSL(1)`, `2=A`, `3=TH(B,C)`, `4=OSM(LCtrl)`, -`5=WM(B,LGui)`. - -`OneShotConfig` default `timeout = 1000ms`. `OneShotModifiersConfig` fields exercised: -`activate_on_keypress` (default false), `quick_release` (default — see note below). - -### SK keymap (`keyboard_sticky_key_test.rs`) - -```text -Layer 0: A B C MO(1) LShift No -Layer 1: SK(Tab,LAlt,exit=true) SK(Tab,LCtrl,exit=true) SK(Tab,LCtrl|LShift,exit=true) Transparent Transparent No -``` - -SK macro shape used today is **5-positional**: -`sk!(key, mods, max_repeat, per_key_timeout_ms, exit_on_layer_change)`. -Default `StickyKeyConfig { timeout }` (global). Several alternate keymaps exist -(`KEYMAP_MAX_REPEAT`, `KEYMAP_PER_KEY_TIMEOUT`, `KEYMAP_NO_EXIT`). - ---- - -## Step 1 — OSM / OSL test catalogue (25 rows) - -| test name | behavior axis | syntax+config used | maps to (new SK shape/setting) | -|---|---|---|---| -| `test_osm_basic_single_behavior` | OSM applies mod to next key then releases | `osm!(LShift)`; default cfg (timeout 1000ms, activate_on_keypress=false). Tap OSM, tap A → `[LShift, A]` then `[0]` | pure-mod SK, terminating-key (3b): SK(LShift) then A emits Shift+A, mod auto-clears | -| `test_osm_timeout` | OSM expires after timeout; next key clean | `OneShotConfig.timeout=100ms`; A pressed at 150ms → `[0, A]` (no Shift) | shared global timeout on pure-mod SK | -| `test_osm_held_behavior` | held past key press; mod stays until OSM release | press OSM, press A (mod held), release A → `[LShift, A]`, `[LShift]`, then release OSM → `[0]` | pure-mod held-promotion (hold past consuming key keeps mod live) | -| `test_osm_multiple_keys` | mod applies only to the next key | tap OSM, tap A (`[LShift,A]`), tap B (`[0,B]` no Shift) | pure-mod single-consume (one terminating key only) | -| `test_osm_rolling_with_tap_hold` | mod ordering: OSM released before key release still applies | press OSM, press B (col 3 `TH(B,C)`, 10ms tap → B), release OSM, release B → `[LShift, B]` | pure-mod ordering / rolling-release: mod sticks through interleaved release | -| `test_osm_combined_modifiers` | two OSM presses accumulate | tap OSM(LShift) col0, tap OSM(LCtrl) col4, tap A → `[LShift\|LCtrl, A]` | pure-mod accumulation (3c): cross-tap mods stack onto one terminating key | -| `test_osm_multiple_osm_with_wm` | accumulation + WM interaction | OSM(LShift)+OSM(LCtrl)+`WM(B,LGui)` col5 → `[LShift\|LCtrl\|LGui, B]` | accumulation merges with WM's own mod (mods union, not overwrite) | -| `test_osm_activate_on_keypress` | mod emitted immediately on OSM press | `activate_on_keypress=true`; tap OSM → `[LShift]` emitted at once, then A → `[LShift, A]`, `[0]` | `activate_on_keypress` setting (pure-mod only — early mod emission) | -| `test_osm_combined_modifiers_with_activate_on_keypress` | accumulate + early activation | `activate_on_keypress=true`; two OSM then A → `[LShift]`, `[LShift\|LCtrl]`, `[LShift\|LCtrl, A]`, `[0]` | accumulation under `activate_on_keypress` (incremental mod reports) | -| `test_osl_basic_single_behavior` | OSL activates layer for next key only | `osl!(1)`; tap OSL, tap col2 → C (layer-1 key), then `[0]` | layer-shape SK (3d): one-shot layer for next key | -| `test_osl_held_behavior` | held across key press; layer stays until release | press OSL, press col2 (→C), release, release OSL → `[C]`, `[0]` | layer held-promotion | -| `test_osl_timeout` | OSL expires; next key on base layer | `OneShotConfig.timeout=100ms`; col2 at 150ms → A (layer 0) | shared global timeout on layer shape | -| `test_osl_multiple_keys` | layer applies only to the next key | OSL, col2→C (layer 1), col3→B (layer 0) | layer single-consume | -| `test_osm_then_osl` | OSM + OSL combine; mod applies to layer-switched key | OSM(LShift), OSL(1), col2 → **`[0, C]`** (C from layer 1). NOTE: report shows **no LShift modifier** — see discrepancy D1 | combined mod-shape + layer-shape ordering | -| `test_osl_then_osm` | OSL + OSM combine | OSL(1), then col0 OSM resolves (layer1 OSM is `LShift\|LCtrl`; col0 layer-1 is OSM(LShift\|LCtrl)), col2 → `[LShift\|LCtrl, A]`. NOTE: emits **both** Shift+Ctrl, not just Shift | layer-then-mod combination; mod set comes from the layer-active OSM | -| `test_osm_and_osl_timeout` | both time out independently | timeout=100ms; col2 at 200ms → `[A]` (layer 0, no mod) | independent expiry of mod-shape and layer-shape under shared timeout | -| `test_osm_chain_mode_basic` | `quick_release=false`: mod held until key RELEASE | `quick_release=false`; tap A → `[LShift, A]`, release A → `[0]` | chain mode: terminating-key holds mod until its release | -| `test_osm_chain_mode_multiple_keys` | chain mode: only first key modified | `quick_release=false`; A → `[LShift,A]`,`[0]`; B → `[0,B]`,`[0]` | chain single-consume | -| `test_osm_chain_mode_activate_on_keypress` | chain + early activation | `activate_on_keypress=true, quick_release=false`; `[LShift]`,`[LShift,A]`,`[0]` | chain mode under `activate_on_keypress` | -| `test_osm_quick_release_basic` | `quick_release=true`: mod released mid key-press | `quick_release=true`; press A → `[LShift,A]`, then **`[0,A]`** (mod dropped while key still held), release → `[0]` | quick-release mode: mod cleared as soon as terminating key registers | -| `test_osm_quick_release_multiple_keys` | quick-release single-consume | `quick_release=true`; A → `[LShift,A]`,`[0,A]`,`[0]`; B → `[0,B]`,`[0]` | quick-release single-consume | -| `test_osm_quick_release_combined_modifiers` | quick-release with accumulated mods | `quick_release=true`; OSM(LShift)+OSM(LCtrl)+A → `[LShift\|LCtrl,A]`,`[0,A]`,`[0]` | quick-release + accumulation | -| `test_osm_quick_release_with_wm` | OSM mod released, WM mod persists | `quick_release=true`; OSM(LShift)+OSM(LCtrl)+`WM(B,LGui)` → `[LShift\|LCtrl\|LGui,B]`, then **`[LGui,B]`** (only OSM mods dropped; WM's LGui stays), `[0]` | quick-release drops only SK-owned mods, leaves WM/other mods intact | -| `test_osm_quick_release_activate_on_keypress` | quick-release + early activation | `activate_on_keypress=true, quick_release=true`; `[LShift]`,`[LShift,A]`,`[0,A]`,`[0]` | quick-release under `activate_on_keypress` | -| `test_osm_quick_release_combined_activate_on_keypress` | quick-release + accumulation + early activation | both flags true; `[LShift]`,`[LShift\|LCtrl]`,`[LShift\|LCtrl,A]`,`[0,A]`,`[0]` | full combination: accumulation + early activation + quick-release | - -**Removed upstream (noted in the file, not a discrepancy):** the plan's -`test_osm_quick_release_rolling` does **not** exist — it was intentionally deleted -(comment at `keyboard_one_shot_test.rs:661-662`: "OSM + morse/tap-hold interaction -has a known bug where the OSM deadline loop times out before the tap resolves"). -This is why the OSM/OSL file holds 25 tests, not 26. The 25 present all match the -plan's list. - ---- - -## Step 2 — SK test catalogue (11 rows) - -For each row, "axis preserved?" states whether the SK engine must keep the axis after -the merge. Rows that **prove the tap-key shape** require `key != No` semantics -(SK actually emits a HID key, not just a modifier) and are flagged **[TAP-KEY PROOF]**. - -| test name | behavior axis | syntax+config used | maps to (new SK shape/setting) — preserved? | -|---|---|---|---| -| `test_sk_basic_flow_press_twice` | press sends key+mod; release holds mod; re-press repeats; layer exit cleans up | default keymap `sk!(Tab,LAlt,0,0,true)`; MO↓, SK↓→`[LAlt,Tab]`, SK↑→`[LAlt]`, SK↓→`[LAlt,Tab]`, SK↑→`[LAlt]`, MO↑→`[0]` | **[TAP-KEY PROOF]** tap-key core (key Tab + mod LAlt). Preserved — must keep `key != No` | -| `test_sk_layer_change_cleanup` | `exit_on_layer_change=true` → cleanup on MO release | `sk!(...,true)`; MO↑ produces `[0]` cleanup report | maps to new `release_on_layer_change`. **Behavior change:** new default is `false` (see Accepted changes); this test pins the `=true` path | -| `test_sk_shift_does_not_release_sk` | a real modifier press does NOT release SK; they stack | press LShift (col4 transparent→LShift) between SK presses → `[LCtrl\|LShift,...]`; SK stays active | foreign-key rule **excludes bare modifiers**: pressing a modifier stacks, does not terminate SK. Preserved | -| `test_sk_rapid_three_presses` | three rapid presses each send key+mod | `sk!(Tab,LAlt,...)`; 3×(SK↓→`[LAlt,Tab]`, SK↑→`[LAlt]`) | **[TAP-KEY PROOF]** repeated tap-key emission. Preserved | -| `test_sk_combined_modifiers` | SK with `LCtrl\|LShift` sends both | col2 `sk!(Tab, LCtrl\|LShift, ...)` → `[LCtrl\|LShift, Tab]` | **[TAP-KEY PROOF]** multi-mod tap-key. Preserved | -| `test_sk_timeout` | auto-release after global timeout; next key clean | `StickyKeyConfig.timeout=100ms`; SK↑ then 150ms wait → `[0]`; later C clean | shared global timeout on tap-key SK. Preserved | -| `test_sk_timeout_resets_on_press` | timeout resets on each press | timeout=100ms; SK#1↑ (T1), SK#2 at 50ms cancels T1, SK#2↑ (T2), 150ms→fire | timeout-reset-on-press. Preserved | -| `test_sk_max_repeat` | deactivates silently after `max_repeat=2` (3rd press deactivates) | `KEYMAP_MAX_REPEAT` `sk!(Tab,LAlt,2,0,false)`; press#3 → `[0]`, then A clean | `max_repeat` cycling. Preserved | -| `test_sk_per_key_timeout_overrides_global` | per-key `timeout_ms` overrides global | `KEYMAP_PER_KEY_TIMEOUT` `sk!(Tab,LAlt,0,50,false)`, global=100ms; releases at 50ms | **CAPABILITY DEFERRED.** Per-key timeout override is removed this round. This test must be **re-expressed or retired**: convert to a global-timeout assertion (drop the 50ms positional, assert release at the global 100ms boundary) **or delete with justification**. Flagged here per plan Step 2. | -| `test_sk_exits_on_layer_change` | `exit_on_layer_change=true` | duplicates Test 2's exit=true path (default keymap) → `[LAlt,Tab]`,`[LAlt]`,`[0]` | `release_on_layer_change=true` path. Preserved (explicit) | -| `test_sk_survives_layer_change` | `exit_on_layer_change=false` survives; released only by a key press | `KEYMAP_NO_EXIT` `sk!(...,false)`; MO↑ no report; later A↓ releases SK then sends A → `[0]`,`[0,A]`,`[0]` | new **default** `release_on_layer_change=false`. Preserved as the new default behavior | - ---- - -## Step 3 — New tests required by the spec (do NOT exist yet; author in Stage 2) - -| test name (proposed) | behavior axis | syntax+config | maps to (proof) | -|---|---|---|---| -| `test_sk_puremod_terminating_key` | pure-mod SK then a normal key emits mod+key, then mod clears | `sk!` with **no tap key** (pure-mod, e.g. `SK(LGui)`); press P → `[LGui, P]`, then `[0, P]`/`[0]` | **Core 3b proof.** SK(LGui) then P must emit Gui+P. Today's SK engine gets this wrong; today's OSM (`test_osm_basic_single_behavior`) gets it right. This regression test pins the absorbed OSM behavior. | -| `test_sk_puremod_cross_tap_accumulation` | two pure-mod SK taps accumulate onto one key | `SK(LCtrl)` then `SK(LShift)` then P → `[LCtrl\|LShift, P]` | **3c proof.** Mirrors `test_osm_combined_modifiers` but via the SK engine. Pins cross-tap mod accumulation for pure-mod SKs. | - ---- - -## Step 4 — Accepted behavior changes (deltas — NOT regressions) - -Reviewers must not mistake these intentional changes for regressions: - -1. **Alt-tab SKs gain a default 1s timeout.** Previously a tap-key SK could hold its - modifier indefinitely (effectively `Duration::MAX` / no timeout); after the merge - the shared one-shot timeout (default `1000ms`) applies. Behavioral effect: a stuck - Alt auto-clears after 1s of inactivity. -2. **Default `release_on_layer_change=false`.** Several existing SK tests used the - default keymap with `exit_on_layer_change=true` (e.g. `test_sk_basic_flow_press_twice`, - `test_sk_layer_change_cleanup`, `test_sk_exits_on_layer_change`). The new default is - `false` (SK survives a layer change), matching `test_sk_survives_layer_change`. Tests - that assert the `=true` cleanup path must opt in explicitly. -3. **`per-key timeout_ms` and the 5-positional `SK(...)` tail are removed.** The current - macro is `sk!(key, mods, max_repeat, per_key_timeout_ms, exit_on_layer_change)`. The - per-key timeout positional is dropped (capability deferred), so the positional tail - shrinks. `test_sk_per_key_timeout_overrides_global` is directly affected (Step 2). - ---- - -## Discrepancies found between the plan and the actual test files - -- **D0 — `test_osm_quick_release_rolling` absent (expected).** The plan's preamble said - "25 tests" but its bullet list and the actual file agree on 25; the 26th - (`..._rolling`) was deleted upstream with an explanatory comment. No action needed - beyond noting it. All 25 named tests in the plan exist with matching names. -- **D1 — `test_osm_then_osl` does NOT emit the OSM modifier.** The plan describes it as - "OSM+OSL combine, mod applies to layer-switched key." The **actual assertion is - `[0, C]` — no LShift modifier on the layer-switched key C.** The OSM appears to be - consumed/dropped by the intervening OSL activation rather than carried onto C. The - catalogue row reflects the real assertion. This is a meaningful parity detail for the - merge: the SK engine must reproduce this "OSM-then-OSL drops the mod" outcome, or the - behavior must be explicitly re-decided. **Flagged for design review.** -- **D2 — `test_osl_then_osm` emits `LShift|LCtrl`, not just `LShift`.** The plan row - says only "OSL+OSM combine." In reality, after OSL(1) the col-0 key resolves to the - **layer-1** OSM which is `OSM(LShift|LCtrl)`, so the final key A carries **both** - modifiers (`[LShift|LCtrl, A]`). The catalogue row captures the real mod set. -- **D3 — `test_sk_exits_on_layer_change` is an intentional duplicate of - `test_sk_layer_change_cleanup`.** Both pin the `exit=true` MO-release cleanup on the - default keymap; the file's own doc comment acknowledges this ("This is the same as - Test 2"). Not a problem, but flagged so a reviewer doesn't think one is redundant by - mistake — both should be migrated to the explicit `release_on_layer_change=true` opt-in. - -No test-count or test-name mismatches otherwise: 25 OSM/OSL + 11 SK = 36 existing tests, -matching the plan's "36 existing tests" total. diff --git a/docs/superpowers/specs/2026-06-02-unify-osm-sticky-key-design.md b/docs/superpowers/specs/2026-06-02-unify-osm-sticky-key-design.md deleted file mode 100644 index ffaa6633d..000000000 --- a/docs/superpowers/specs/2026-06-02-unify-osm-sticky-key-design.md +++ /dev/null @@ -1,463 +0,0 @@ -# Unify One-Shot Modifier (OSM) and Sticky Key (SK) — Design - -**Date:** 2026-06-02 -**Status:** Designed (not yet implemented) -**Branch:** `feat/osm-sticky-key-merge` (forked from `feat/sticky-mod`) -**Repo:** rmk-fork (RMK firmware), consumed by RMKSofleV2 via `[patch.crates-io]` -**Context:** RMK PR [#859](https://github.com/HaoboGu/rmk/pull/859) - -## Overview - -The `feat/sticky-mod` branch added a `StickyKey` (SK) behavior and HaoboGu (RMK -owner) asked, in PR #859, that one-shot modifiers and sticky-mod be **unified** -into a single behavior. As implemented, they are **not** unified — OSM and SK -share essentially no runtime logic. They have separate state types, separate -timeout mechanisms, and separate release-trigger plumbing. The only shared code -is the final modifier-merge sink (`resolve_explicit_modifiers`). - -This design unifies the **runtime** of OSM and SK behind one shared latch state -machine, driven by a small per-behavior **preset**, while keeping the public -surface (wire format, keycodes, config blocks, TOML syntax, tests) frozen for -strict backward compatibility. One-shot **layer** (OSL) is deliberately left on -its own layer-activation path this round, but rides the shared plumbing and is -left with a documented seam to fold in later. - -**Goals:** - -1. **Backward compatible** — no change to the postcard wire format, Via/Vial - keycodes, config blocks, or TOML keymap syntax. -2. **Keep every feature** of both the current OSM implementation *and* SK — - nothing dropped. The existing test suites are the parity oracle. -3. **Cut shared code** — one state type, one timeout mechanism, one - foreign-key release pass, one modifier-resolve sink. -4. **Fix the OSM "select race"** as a natural consequence of moving OSM onto - SK's non-blocking deadline mechanism. - -**Mostly internal.** The unification itself adds no user-facing feature — -OSM/OSL observable behavior is identical except that the OSM timeout race goes -away. The one intentional new capability is **SK-only**: a global -`[behavior.sticky_key]` default plus an optional per-key **profile** that can -override any SK setting field-by-field (Section 5). It is purely additive — -existing SK configs, the SK keymap syntax, the `StickyKeyAction` wire struct, -and the 11 SK tests are all untouched. - ---- - -## Current state (why they are not unified) - -Three plumbing layers diverge today. - -### State representation - -- **OSM/OSL** — `OneShotState` (`rmk/src/keyboard/oneshot.rs:10`), a 4-state - machine `Initial(T)` / `Single(T)` / `Held(T)` / `None`, generic over - `T = ModifierCombination` (OSM) or `T = u8` (OSL). -- **SK** — `StickyKeyState` (`rmk/src/keyboard/sticky_key.rs:24`), a 2-state - machine `None` / `Active { mods, repeat_count, max_repeat, exit_on_layer_change, - deadline }`. - -### Timeout mechanism (the core divergence) - -- **SK** is **non-blocking**: it stores `deadline: Option` in its state, - and the `run()` loop (`rmk/src/keyboard.rs:158-185`) races the event subscriber - against `sk_deadline`. Expiry is handled in the main loop. -- **OSM/OSL** are **blocking**: on release they call - `select(Timer::after(timeout), subscriber.next_message())` inline - (`oneshot.rs:75-93` for OSM, `139-152` for OSL). If a real key event wins the - race, it is pushed onto `self.unprocessed_events` to be replayed. This inline - `select` is the source of the documented "select race" (RMKSofleV2 `TODO.md`) - and the `keyboard.rs:146` TODO wondering whether `unprocessed_events` "can be - removed in the future." - -### Release-trigger plumbing - -- **OSM** consume runs in `process_action_key` (`keyboard.rs:1586`): - `update_osm(event)` flips `Single → None`, honoring `quick_release` (consume on - next *press* vs. next *release*) and promoting `Initial → Held` (the held / - mouse-click path). -- **SK** release runs in `process_key_action_normal` (`keyboard.rs:1220-1230`): - any non-SK, non-modifier key tears the latch down. - -### The one shared sink - -`resolve_explicit_modifiers` (`keyboard.rs:1380-1403`) already merges -`held_modifiers + osm_state.value() + sticky_key_state.value()`. This is the -only place the two behaviors meet today. - -### Why HaoboGu's sketch is insufficient - -PR #859 sketches `OSM(mod) == SK(mod, [], 1)`. That is a *simplification* that -drops OSM's richer behaviors (modifier accumulation, held-promotion, double-press -un-latch, `quick_release`, `activate_on_keypress`). The unified mechanism must -therefore be a **superset** with OSM and SK as two **presets**, not a collapse of -one into the other. - ---- - -## Section 1 — Scope & compatibility contract - -**Frozen surface (nothing here moves — this is what guarantees backward compat):** - -- `Action::OneShotModifier`, `Action::OneShotLayer`, `Action::StickyKey` stay as - distinct variants in the **same wire order** (`rmk-types/src/action/mod.rs:57`). - The `Action` enum derives `Serialize`/`Deserialize`/`MaxSize` and postcard - encodes by variant order, so stored keymaps and Vial stay valid. -- Via/Vial keycode mappings for OSM/OSL - (`rmk/src/host/via/keycode_convert.rs`) unchanged. (`StickyKey` is absent from - that map today — confirming it is *not* on the keycode wire — and stays absent.) -- **Two separate config blocks stay:** `[behavior.one_shot]` + - `one_shot_modifiers` (`activate_on_keypress`, `quick_release`) and - `[behavior.sticky_key]` (`timeout`). Independent timeouts are a feature, and - keeping both is also what compat requires. (`[behavior.sticky_key]` *grows* - additively — optional default fields plus a `profiles` subtable, per - Section 5; a config that sets only `timeout` is unaffected.) -- TOML keymap syntax `OSM(...)` / `OSL(...)` / `SK(...)` unchanged. -- **Parity oracle:** all existing OSM/OSL tests - (`rmk/tests/keyboard_one_shot_test.rs`, 25 tests) and SK tests - (`rmk/tests/keyboard_sticky_key_test.rs`, 11 tests) stay green, **unmodified**. - They *are* the definition of "all features preserved." - -**In scope:** merge the *runtime* of OSM and SK into one shared mechanism. -**Out of scope this round:** OSL keeps its own layer activate/deactivate code path -(documented seam only). - ---- - -## Section 2 — The unified state + preset model - -Replace `OneShotState` and `StickyKeyState` with **one** internal latch type -that is the union of both: - -```rust -enum StickyLatch { - None, - Engaged { - mods: ModifierCombination, // OSM accumulates; SK = `keep` - key: Option, // SK bundled key; None for OSM - phase: Phase, // Pressed | Latched | Held (≈ OSM Initial/Single/Held) - repeat_count: u16, // SK cycling; OSM stays 1 - preset: Preset, // which feature-set is active - deadline: Option, // unified timeout - }, -} -``` - -`Preset` is the small config that selects *which* behaviors are live, so OSM and -SK become two configurations of one machine: - -| Preset field | OSM value | SK value | -|---|---|---| -| `accumulate` (combine repeated presses) | yes | no | -| `held_promotion` (foreign key while held → normal modifier) | yes | no | -| `double_press_consume` (re-press same mod un-latches) | yes | no | -| `quick_release` / `activate_on_keypress` | from config | n/a | -| `bundles_key` | no | yes | -| `max_repeat` (0 = infinite) | 1 | from action | -| `keep_set` (which keys re-arm vs. release) | empty | `keep` mods | -| `exit_on_layer_change` | no | from action | -| `timeout_source` | `[behavior.one_shot]` | per-key or `[behavior.sticky_key]` | - -The `Action::OneShotModifier` and `Action::StickyKey` dispatch arms -(`keyboard.rs:1321-1328`) become **thin adapters** that build the right `Preset` -and hand off to the shared engine. - -**Readability guardrail:** the engine keeps OSM's and SK's transition logic as -preset-aware paths over this one state. We are explicitly **not** forcing a single -mega-`match` if it hurts readability. The win is one *state type* + one plumbing -layer + one set of release/timeout rules — not necessarily one giant function. - ---- - -## Section 3 — Shared plumbing (and what gets deleted) - -### 3a. Timeout — one mechanism (deadline), delete the inline `select` - -The unified latch carries `deadline: Option` (already in the Section 2 -shape). The `run()` loop's existing deadline race (`keyboard.rs:158-185`) handles -expiry for **both** OSM and SK. When the deadline fires, the engine runs the same -consume/release path SK uses today. - -**Deleted:** the `select(timeout, next_message)` blocks in `process_action_osm` -(`oneshot.rs:75-93`) and `process_action_osl` (`oneshot.rs:139-152`); and — if -nothing else still pushes to it — the `unprocessed_events` re-queue path. - -**Audit gate:** before removing `unprocessed_events`, confirm OSM/OSL are its -only producers (grep). If something else uses it, it stays and only the OSM/OSL -producers are removed. - -This is the part that **fixes the select race** rather than carrying it forward, -and it is what the `keyboard.rs:146` TODO is asking for. - -### 3b. Release-on-foreign-key — one pass - -OSM's `update_osm` consume (`keyboard.rs:1586`) and SK's non-SK-key release -(`keyboard.rs:1220-1230`) merge into **one "foreign key arrived" hook** over the -unified latch, parameterized by the preset: - -- OSM preset: "consume per `quick_release`; promote `Initial → Held` first." -- SK preset: "release unless the key is in `keep_set` or is another SK press that - cycles." - -Same call site; the preset picks the rule. - -### 3c. Layer-change release + the resolve sink - -- Layer-change release is SK-only today (`exit_on_layer_change`, fired from - `process_action_layer_switch:1600` and four spots in - `process_key_action_normal`). It stays, now reading the unified latch's preset - flag. OSM's preset leaves it off → no behavior change for OSM. -- `resolve_explicit_modifiers` (`keyboard.rs:1380-1403`) already merges held + - OSM + SK modifiers. After unification it reads one `latch.value()` instead of - two. Pure simplification. - -### Net deletion target - -- `OneShotState` **and** `StickyKeyState` both go away → replaced by the one - `StickyLatch`. -- The two inline-`select` timeout blocks go away. -- `unprocessed_events` re-queue likely goes away (pending the 3a audit). -- OSL keeps its own layer activate/deactivate calls (documented seam) but rides - the same latch state and the same deadline / foreign-key plumbing. - ---- - -## Section 4 — Staging & test strategy - -Every commit stays test-green against the frozen 25 OSM/OSL + 11 SK tests. The -merge proceeds in dependency order — plumbing first, state second — so any -regression is bisectable to one stage. Tests run via `cargo nextest`. - -- **Stage 0 — Characterize.** Run the full OSM/OSL/SK suite; record the green - baseline. No code change. -- **Stage 1 — Unify the timeout plumbing, keep both state types.** Move OSM/OSL - off inline `select` onto a `deadline` surfaced to the `run()` loop (reusing SK's - deadline race). `OneShotState` and `StickyKeyState` still exist separately — - only the *expiry mechanism* is shared. Delete the `select` blocks; remove the - OSM/OSL `unprocessed_events` producers (pending 3a audit). **Gate: all 36 tests - green.** This isolates the single riskiest change (the timeout-semantics shift) - to one commit — `git bisect` lands here if a hardware surprise appears. -- **Stage 2 — Merge the state representation.** Replace `OneShotState` + - `StickyKeyState` with `StickyLatch` + `Preset`. The `OneShotModifier` / - `StickyKey` dispatch arms become thin preset-building adapters. Fold the - foreign-key hook (3b) and resolve sink (3c) onto the single latch. **Gate: all - 36 tests green.** -- **Stage 3 — Tidy + document the OSL seam.** OSL still does its own layer - activate/deactivate but now rides the shared latch + plumbing. Leave a clearly - commented seam (a `// OSL fold point:` marker + short note) describing what a - future "fold OSL fully in" change would collapse. **Gate: all 36 tests green + - `cargo clippy` clean.** -- **Stage S — SK profile override (independent; Section 5).** Config-resolve + - codegen only; orthogonal to the runtime-merge Stages 1–3, so it can land before - or after them. Adds the new profile resolution tests (below) and keeps the 36 - existing tests green. **Gate: 36 existing tests green + new resolution tests - green.** - -**New tests:** for the *merge* (Stages 1–3), none for unchanged behavior — the -existing suite already defines parity; add one targeted regression test only if -the Stage 1 race fix creates a newly-correct behavior the old suite did not pin -(e.g. a key event arriving in the exact timeout window). For the *SK profile -override* (Stage S), add the small resolution-tier test set described in -Section 5. - ---- - -## Section 5 — SK config: global default + per-key profile override (new SK capability) - -This is the one intentional new feature in this work. It applies to **SK only** — -OSM/OSL are frozen (Section 1) and keep their global-only config. It is an -**independent workstream**: it touches the config-resolve + codegen layers, not -the runtime engine, so it can land before or after the Section 4 merge stages, -each step staying test-green. - -### Requirement: profile-first configuration - -The TOML profile (`[behavior.sticky_key]` plus its named `profiles`) is the -**preferred and primary** way to specify SK settings. Per-key overrides in the -`SK(...)` action string are a **secondary convenience, retained for now but -explicitly optional** — they may be removed in a later pass to simplify the code. - -Consequence for the design: nothing may architecturally depend on key-level -overrides existing. Because every setting is resolved to a concrete value at -codegen (profile/global folded in), the runtime engine reads only fully-resolved -values and is blind to *where* a value came from. Dropping the per-key override -syntax later must therefore be a clean deletion of parser/codegen arms — no -runtime change, no engine coupling. - -### Motivation - -SK's current keymap form `SK(key, [mods], max_repeat, timeout_ms, -exit_on_layer_change)` is the only 5-positional-argument action in RMK; the -trailing `0, 0, true` is unreadable and the `0` sentinels are easy to mis-order. -We want a global default that every SK key inherits, with any individual key able -to override any field — **without** inventing inline named-parameter syntax (no -RMK action uses `key=value` inside the action string; named params live only in -`[behavior.*]` TOML tables). - -### The three RMK override patterns (and which we pick) - -RMK already solves "global default, override per key" two ways, and uses a third -(global-only) for OSM: - -1. **Global-only** (OSM): one `[behavior.one_shot]` block, no per-key override. - *Rejected* — too rigid for the stated need. -2. **Sentinel fallback** (SK `timeout_ms = 0` today → inherit global). Works for a - numeric field with a spare sentinel, but can't express "inherit" for a `bool`, - and `max_repeat = 0` already means "infinite" so `0` is taken there. -3. **Option-field merge** (morse / tap-hold profiles): a per-key named profile - whose `Option` fields override the global default *field by field*; unset - fields inherit. Most general, reads naturally in TOML, and is a pattern RMK - users and maintainers already recognize. - -**Chosen: pattern 3 — Option-field merge, mirroring morse profiles.** - -### TOML surface (additive) - -`[behavior.sticky_key]` gains the full set of SK defaults; a new -`[behavior.sticky_key.profiles.]` subtable defines named overrides: - -```toml -[behavior.sticky_key] # global default for every SK key -timeout = "5s" -max_repeat = 0 # 0 = infinite -exit_on_layer_change = false - -[behavior.sticky_key.profiles.tabber] # overrides only what it names -max_repeat = 3 -exit_on_layer_change = true # timeout inherited from the global default -``` - -Purely additive: an existing config that sets only `timeout` keeps working; the -new default fields and the `profiles` table are optional. - -### Keymap DSL (reuses MT/LT/TH's optional profile slot) - -The bare form is unchanged; an optional trailing **profile name** stands in for -the positional numeric tail: - -```toml -SK(Tab, [LAlt]) # every setting from the global default -SK(Tab, [LAlt], tabber) # override per profile "tabber", inherit the rest -``` - -This reuses the same optional 3rd positional slot that `MT` / `LT` / `TH` already -use for their morse profile, so it introduces no new DSL shape. The parser -distinguishes the slot by token kind: a **numeric** token keeps the legacy -positional `SK(key,[mods],max_repeat,timeout_ms,exit)` parse (so the 11 existing -SK tests stay green, unmodified); an **identifier** token is resolved as a -profile name. - -### Resolution — codegen-time merge, no wire change - -Because both the global defaults and the named profiles are compile-time TOML, -the merge happens entirely at **codegen** — exactly as morse's `expand_profile` -bakes resolved values. For each field the resolution order is: - -> explicit per-key value (positional arg, if present) → -> named-profile field (if `Some`) → -> `[behavior.sticky_key]` global default (if set) → -> built-in default (`timeout` sentinel `0`, `max_repeat` `0`, `exit` `false`). - -The codegen folds this down to concrete values and emits the existing -`sk!(key, mods, max_repeat, timeout_ms, exit)` macro. Therefore: - -- **`StickyKeyAction` is unchanged** — still all-concrete `{ key, keep, - max_repeat, timeout_ms, exit_on_layer_change }`. No `Option` fields reach the - wire; `MaxSize` and the postcard encoding are untouched. -- **The SK runtime engine is unchanged** by this feature — it still receives one - fully-resolved action. The profile indirection is a zero-runtime-cost - compile-time convenience. - -### Tests - -The legacy positional form keeps its 11 tests unmodified (parity oracle). Add a -small set of **new** codegen/resolution tests for the profile form: bare key -inherits all global defaults; a profile overrides only its named fields and -inherits the rest; a missing global default falls to the built-in default; -numeric-vs-identifier slot disambiguation. - ---- - -## Section 6 — Risks & non-goals - -**Risks (ranked):** - -1. **OSM timeout semantics shift (Stage 1).** Moving from blocking inline - `select` to the run-loop deadline changes *when* expiry is observed relative - to an incoming event. Mitigated by: isolated to one commit, the 25 OSM/OSL tests, - and a possible targeted race test. This is the one to watch on real hardware. -2. **`unprocessed_events` removal.** Only safe if OSM/OSL are its sole producers. - Mitigated by an explicit grep/audit before deletion; if shared, it stays and - only the OSM/OSL pushes are removed. -3. **Preset adapter drift.** Risk that an OSM behavioral axis (double-press - toggle, `activate_on_keypress`, held-promotion, accumulation, `quick_release`) - is dropped when re-expressed as a preset. Mitigated by the frozen test suite — - each axis has a named test. -4. **SK profile-merge resolution (Section 5).** Risk that the codegen merge - resolves a field from the wrong tier (per-key vs. profile vs. global vs. - built-in), or that numeric-vs-identifier slot disambiguation misreads a token. - Mitigated by: the merge is pure compile-time logic with no runtime state, the - legacy positional path is left intact (existing tests pin it), and the new - resolution tests cover each tier and the slot-kind split. Low blast radius — a - bad resolve produces a wrong baked constant caught at build/test time, not a - runtime hazard. - -**Non-goals (explicitly out of scope this round):** - -- Folding OSL fully into the latch (keeps its own layer calls — documented seam - only). This is the eventual "everything folds into sticky-key" direction - HaoboGu wants, deferred to a follow-up. -- Touching the wire format, Via/Vial keycodes, or the OSM/OSL config blocks - (frozen per Section 1). The SK config block grows additively only (Section 5). -- Extending the per-key profile override to OSM/OSL. OSM stays global-only; the - new profile mechanism is SK-only this round. -- `OneShotKey` (OSK) — still unsupported, stays a warning (`keyboard.rs:1329`). -- Any new user-facing feature. - ---- - -## File map (anticipated) - -**Modify:** - -- `rmk/src/keyboard/oneshot.rs` — remove inline `select` timeout; OSM/OSL onto - deadline (Stage 1); replaced by `StickyLatch` usage (Stage 2). -- `rmk/src/keyboard/sticky_key.rs` — `StickyKeyState` replaced by `StickyLatch` - (Stage 2); SK becomes a preset adapter. -- `rmk/src/keyboard.rs` — state fields (`osl_state`, `osm_state`, - `sticky_key_state` → unified latch), deadline race, dispatch arms - (`1321-1328`), foreign-key hook (`1220-1230`, `1586`), layer-change release - (`1600` + four spots), `resolve_explicit_modifiers` (`1380-1403`); remove - `unprocessed_events` producers (pending audit). -- Likely a new shared module (e.g. `rmk/src/keyboard/sticky_latch.rs`) housing - `StickyLatch` + `Preset`, depending on how Stage 2 shakes out. - -**Modify (Section 5 — SK profile override, independent of the merge stages):** - -- `rmk-config/src/resolved/behavior.rs` — extend the `[behavior.sticky_key]` - resolve to read the new default fields (`max_repeat`, `exit_on_layer_change`) - and a `profiles` map; plus the corresponding raw-TOML config structs. -- `rmk-macro/src/codegen/action_parser.rs` — SK parse path: numeric-vs-identifier - slot disambiguation, profile lookup, and the codegen-time tier merge that bakes - concrete values into the existing `sk!(...)` emission. -- New resolution tests for the profile form (alongside the existing SK tests). - -**Frozen (do not touch):** - -- `rmk-types/src/action/mod.rs` — `Action` variants + wire order, **and the - `StickyKeyAction` struct** (Section 5 bakes resolved values into the existing - fields at codegen, so the wire struct stays all-concrete and unchanged). -- `rmk/src/host/via/keycode_convert.rs` — OSM/OSL keycodes. -- `rmk/src/config/behavior.rs` — `OneShotModifiersConfig` + `StickyKeyConfig` - (both blocks stay). -- `rmk/tests/keyboard_one_shot_test.rs`, `rmk/tests/keyboard_sticky_key_test.rs` - — the parity oracle (the legacy positional SK form keeps these green unmodified). - ---- - -## Open questions for the implementation plan - -- Exact home of `StickyLatch` + `Preset` (new module vs. folded into - `sticky_key.rs`) — decide during Stage 2. -- Whether the foreign-key hook (3b) is best expressed as one function with a - preset branch, or two small functions sharing the latch — decide by which reads - cleaner once the state is merged. diff --git a/docs/superpowers/specs/2026-06-03-sk-absorbs-oneshot-design.md b/docs/superpowers/specs/2026-06-03-sk-absorbs-oneshot-design.md deleted file mode 100644 index 6b11497c3..000000000 --- a/docs/superpowers/specs/2026-06-03-sk-absorbs-oneshot-design.md +++ /dev/null @@ -1,425 +0,0 @@ -# Sticky Key Absorbs One-Shot (OSM + OSL) — Design - -**Date:** 2026-06-03 -**Status:** Designed (not yet implemented) -**Branch:** `feat/osm-sticky-key-merge` -**Repo:** rmk-fork (RMK firmware), consumed by RMKSofleV2 via `[patch.crates-io]` -**Context:** RMK PR [#859](https://github.com/HaoboGu/rmk/pull/859) -**Supersedes:** `2026-06-02-unify-osm-sticky-key-design.md` (kept as the historical -record of the strict-backward-compat approach). - -## Overview - -The 06-02 design unified the OSM and SK **runtime** while freezing every public -surface for strict backward compatibility, and deliberately left one-shot -**layer** (OSL) on its own path. That strict-compat work has since been merged -into the branch/PR. - -This design takes the next step HaoboGu approved in PR #859: **completely replace -one-shot with sticky key.** Sticky Key becomes the single behavior; OSM and OSL -are absorbed into it and cease to exist as independent behaviors. The user-facing -`OSM(...)` / `OSL(...)` keymap forms are replaced by `SK(...)` forms, the three -one-shot/sticky config tables collapse into one, and the runtime is a single -engine whose behavior is selected by the **shape of the SK action**, not by which -legacy syntax was used. - -This is intentionally **not** backward compatible — it changes user-facing syntax, -config table names, and default values. Those breaks are accepted: the goal is one -coherent feature set with the maximum code reuse, not preservation of the old -surface. - -**Goals:** - -1. **One behavior.** A single SK engine and a single `Action::StickyKey` path - absorb OSM (one-shot modifier) and OSL (one-shot layer). Reuse as much of the - existing OSM and SK code as possible. -2. **One config table.** Collapse `[behavior.one_shot]`, - `[behavior.one_shot_modifiers]`, and `[behavior.sticky_key]` into a single - `[behavior.sticky_key]`. -3. **Keep every capability** of today's OSM, OSL, and SK — modifier accumulation, - held-promotion, `quick_release`, `activate_on_keypress`, alt-tab cycling - (`max_repeat`), layer-change release, one-shot layers. Nothing is dropped; the - features are reorganized, not removed. -4. **Zero cost when unused.** Because there are no per-key overrides and no named - profiles in this round (deferred — see Section 4), every SK setting resolves to - a concrete value at codegen and the runtime engine reads only fully-resolved - values. RAM/flash reflect the simple single-profile model. - -**The central idea — behavior is selected by action shape.** A single SK action -takes one of three shapes, and the shape alone decides which behaviors are live: - -| Shape | Example | Old equivalent | Engine behavior | -|---|---|---|---| -| **Pure modifier** (`key == No`) | `SK(LGui)` | `OSM(LGui)` | One-shot modifier: honors `activate_on_keypress`/`quick_release`, applies the held mod **to** the terminating key, accumulates across taps. | -| **Tap-key + mods** (`key != No`) | `SK(Tab, [LAlt])` | sticky-mod / alt-tab | Holds mods, taps the key, cycles up to `max_repeat`, releases on a foreign key **without** applying the mod to it. Ignores `activate_on_keypress`/`quick_release`. | -| **Layer** (`SK(MO(n))`) | `SK(MO(1))` | `OSL(1)` | One-shot layer: activates the layer for the next key, then reverts. Reuses OSL's layer activate/deactivate logic. | - -This shape-based dispatch is what lets one engine serve all three without -per-key config flags for the OSM↔alt-tab differences (Section 3). - ---- - -## Section 1 — Syntax migration (user-facing) - -The `SK(...)` action becomes the single entry point. Migration: - -```txt -old: OSM(LGui) new: SK(LGui) -old: OSL(1) new: SK(MO(1)) -old: SK(Tab, [LAlt], 0, 0, true) new: SK(Tab, [LAlt]) -old: SK(Tab, [LCtrl]) new: SK(Tab, [LCtrl]) # unchanged -``` - -**Mental model:** *SK makes the wrapped thing one-shot / sticky.* -`SK(LGui)` = sticky modifier; `SK(MO(1))` = sticky momentary-layer (= one-shot -layer); `SK(Tab, [LAlt])` = sticky-mod (alt-tab). - -**The trailing positional tail is gone.** The legacy -`SK(key, [mods], max_repeat, timeout_ms, exit_on_layer_change)` form — the only -5-positional-arg action in RMK, with its unreadable `0, 0, true` tail — is -**removed**. `max_repeat`, `timeout`, and layer-release now come from the -`[behavior.sticky_key]` table (Section 4). The bare forms above are the entire -surface. - -**`OSM(...)` / `OSL(...)` keymap forms are dropped entirely.** No deprecated -aliases, no lowering shim. The parser removes the `OSM` and `OSL` action keywords; -existing keymaps using them must be rewritten to the `SK(...)` forms above. (This -is a fork whose keymaps we control and rewrite as part of the migration, so a -compatibility shim buys nothing.) Encountering `OSM(...)`/`OSL(...)` after this -change is a build error, not a silent fallback. - -**OSL payload requires the SK action to carry a layer.** `SK(MO(n))` cannot be -expressed by today's `StickyKeyAction { key, keep, max_repeat, timeout_ms, -exit_on_layer_change }`. The action gains a layer-carrying shape (Section 5). This -is the accepted wire-format break that absorbing OSL requires. - ---- - -## Section 2 — Config consolidation - -The three tables collapse into one. **Old:** - -```toml -[behavior.one_shot] -timeout = "1s" # shared by OSM + OSL - -[behavior.one_shot_modifiers] -activate_on_keypress = false -quick_release = false - -[behavior.sticky_key] -timeout = "5s" # default was: no timeout (Duration::MAX) -max_repeat = 0 # 0 = infinite -exit_on_layer_change = false -``` - -**New — a single `[behavior.sticky_key]`:** - -```toml -[behavior.sticky_key] -timeout = "1s" # default 1s; applies to every SK shape -activate_on_keypress = false # honored by pure-mod SK only (Section 3) -quick_release = false # honored by pure-mod SK only (Section 3) -max_repeat = 0 # 0 = infinite; governs tap-key cycling -release_on_layer_change = false # renamed from exit_on_layer_change -``` - -**Decisions baked in (all confirmed):** - -- **One shared `timeout`** for all shapes, default **1s**. This intentionally - changes two prior behaviors, both accepted: - - alt-tab SKs previously had *no* timeout; they now auto-release after 1s of no - tap. - - the timeout *action* is uniform — on expiry the latch simply releases. - A future per-key override / named profile is the planned way to give alt-tab a - longer timeout without lengthening OSM's window; it is **deferred** this round - (Section 4). -- **`exit_on_layer_change` → `release_on_layer_change`** (same polarity: `true` = - layer change releases the SK; `false` = SK survives layer changes). Default - `false` (survives), matching the preferred behavior for one-shot mods. -- **`max_repeat` is harmless to pure-mod SK.** A pure-mod SK has no tap-key to - re-press, so cycling never triggers; its termination is the foreign-key path. - The shared default `0` is therefore safe for OSM-shaped keys. - -**Why a single table is correct here.** Of the prior cross-table conflicts, only -`timeout` is a genuine single-default compromise, and it is accepted. The two -transmission fields (`activate_on_keypress`, `quick_release`) are resolved -*structurally* by action shape, not by a config value (Section 3), so they need no -per-key distinction. `max_repeat` and `release_on_layer_change` apply cleanly -across shapes. Nothing forces the tables to stay separate. - ---- - -## Section 3 — The engine model - -### 3a. Shape-driven behavior (the unifying rule) - -The engine reads the action's shape and applies the matching rules. Two fields, -`activate_on_keypress` and `quick_release`, are **honored only for the pure-mod -shape** and **ignored for the tap-key shape** — this is structural, not -configurable: - -| | Pure modifier (`key == No`) | Tap-key (`key != No`) | -|---|---|---| -| transmission on press | per `activate_on_keypress` | **always immediate** | -| `quick_release` | honored | **n/a** | -| termination by foreign key | apply held mod **to** that key, then release | release **without** applying | -| accumulation across taps | yes (`Ctrl` then `Shift` then `P` → `Ctrl+Shift+P`) | no | -| `max_repeat` cycling | n/a (no tap-key) | yes | - -**Why ignoring those two fields for tap-key SK is principled, not a hack:** -`activate_on_keypress` means "defer the mod and fuse it into the *next* key." A -tap-key SK has nothing to defer — the tap *is* the action on each press, and you -cannot fold a `Tab` keystroke into a later key. So "deferred" is undefined for the -tap-key shape; immediate transmission is the only coherent behavior. `quick_release` -(consume on the next key's press vs. release) is likewise meaningless when -termination is a foreign key that the mod is not applied to. Both ride the same -`key == No` axis the engine already needs for the termination rule, so honoring -them only in the pure-mod arm costs no new machinery — one branch, reused. - -This is also what makes a **single** `[behavior.sticky_key]` profile serve both -OSM and alt-tab correctly on the transmission axes from day one: the -`activate_on_keypress = false` default gives clean OSM chords, while alt-tab keys -auto-force immediate transmission by virtue of having a tap-key. The only residual -single-default compromise is `timeout`. - -### 3b. The terminating-key behavior (the real new work) - -Today (`keyboard.rs:1220-1232`) a foreign key press releases the SK **before** the -foreign key is processed, so the foreign key is sent **without** the held mod. -That is correct for alt-tab (no Alt on the Enter that picks a window) but **wrong** -for OSM (`SK(LGui)` then `P` must send `Gui+P`). The engine must therefore branch -on shape: - -- **pure-mod:** the held mod must remain applied **through** the terminating key's - report, then release (on that key's press or release per `quick_release`). This - is OSM's existing "decorate the next key" behavior, now driven from the SK path. -- **tap-key:** unchanged from today — release first, foreign key sent clean. - -### 3c. Modifier accumulation (preserve OSM's behavior) - -Today a second SK press just increments `repeat_count` and **ignores** the new -mods (`sticky_key.rs:90-102`). OSM instead accumulates (`oneshot.rs:42/59`, -`cur | new`). For the pure-mod shape the engine must accumulate so -`SK(LCtrl)` then `SK(LShift)` then `P` yields `Ctrl+Shift+P`. The tap-key shape -keeps the repeat-count behavior. - -### 3d. Layer shape (absorb OSL) - -`SK(MO(n))` activates layer `n` as one-shot. The engine reuses OSL's existing -layer activate/deactivate logic (`oneshot.rs:116-161`, `184-193`) but on the -shared latch + the shared deadline/foreign-key plumbing — fully folded in, not the -"documented seam" the 06-02 spec deferred. - -### 3e. Shared latch + plumbing (reused from the 06-02 design) - -The unification mechanics from the prior design still apply and should be reused: - -- **One latch state** replacing `OneShotState` and `StickyKeyState`, carrying - `mods`, optional `key`, optional `layer`, `phase` (Pressed/Latched/Held), - `repeat_count`, and `deadline: Option`. -- **One timeout mechanism** — the non-blocking deadline raced in the `run()` loop - (`keyboard.rs:158-185`). Delete the blocking inline `select(timeout, …)` blocks - in `process_action_osm`/`process_action_osl` (`oneshot.rs:75-93`, `139-152`) and - the `unprocessed_events` re-queue path (pending the audit that OSM/OSL are its - only producers). This also fixes the documented OSM "select race." -- **One foreign-key hook** and **one modifier-resolve sink** - (`resolve_explicit_modifiers`, `keyboard.rs:1380-1403`), now reading one latch. - -The "preset" concept from the 06-02 spec is subsumed here: the preset is no longer -a tag carried alongside the action — it is **derived from the action shape** -(`key == No` / `key != No` / layer), which is strictly simpler. - ---- - -## Section 4 — Deferred: per-key overrides & named profiles - -Both per-key argument overrides and named `[behavior.sticky_key.profiles.]` -subtables are **out of scope this round.** Rationale: keep the consolidation as -simple as possible, get it working and validated, **then** measure the RAM/flash -impact of adding overrides before committing to them. - -Design constraint this imposes: nothing may architecturally depend on per-key -overrides or named profiles existing. Every setting resolves to a concrete value -at codegen from the single global table, and the runtime engine is blind to where -a value came from. Adding overrides/profiles later must be an additive change to -the config-resolve + codegen layers with **no** runtime/engine coupling — and the -known first use is giving alt-tab keys a longer `timeout` than OSM keys. - ---- - -## Section 5 — Action payload (wire shape) - -Absorbing OSL forces the SK action to carry a layer, which the current -all-concrete `StickyKeyAction` cannot. The action must represent the three shapes. -Two candidate encodings (decide during implementation): - -- **Tagged variant** — `StickyKeyAction` becomes a small enum: - `Mods { keep, key, max_repeat }` | `Layer { layer }`, sharing the - table-sourced `timeout` / `activate_on_keypress` / `quick_release` / - `release_on_layer_change` at runtime. -- **Added optional field** — keep a struct, add `layer: Option`; `Some` - marks the layer shape (`key`/`keep` unused), `None` is the mod/tap-key shape - with `key == No` distinguishing the two. - -Either way this is a **postcard wire-order / struct change.** Whether it -invalidates stored keymaps in flash and Vial state — and if so, what migration -handling (if any) is needed — is **to be determined after the engine is -implemented and working**; the impact may only become clear during hardware -testing. Do not assume it is harmless. Flag it explicitly for evaluation in the -test phase, and capture whatever is found (reflash needed? Vial re-sync? storage -schema bump?) before this work moves toward PR #859. - -The `OneShotModifier` / `OneShotLayer` `Action` variants are **removed** from the -wire — with `OSM(...)`/`OSL(...)` dropped from the parser (Section 1), there is no -remaining producer, and every SK form lowers to `Action::StickyKey`. - ---- - -## Section 6 — Documentation requirement - -The pure-mod vs. tap-key shape distinction — and specifically that -`activate_on_keypress` and `quick_release` are **honored only for pure-mod SKs and -silently ignored for tap-key SKs** — MUST be explained clearly and prominently in -the user docs (keymap config reference and the `[behavior.sticky_key]` section). -This is the one piece of "magic" in the model: a setting present in the table that -applies to some SK keys and not others. Leaving it implicit would make tap-key -behavior look like a bug. The docs must state the rule, the rationale (a tap-key -has nothing to defer), and the three-shape table from the Overview. - ---- - -## Section 7 — Staging & tests - -The existing OSM/OSL tests (`keyboard_one_shot_test.rs`, 25) and SK tests -(`keyboard_sticky_key_test.rs`, 11) are the **capability oracle** — but unlike the -06-02 design they will **not** all stay byte-for-byte green, because syntax, -config, and defaults change. They are instead the checklist of *behaviors* that -must still exist after migration; each gets re-expressed against the new surface. -Tests run via `cargo nextest`. - -- **Stage 0 — Characterize.** Catalogue every behavior the 36 tests pin (one row - per OSM/OSL/SK axis). This list is the parity contract for the new surface. -- **Stage 1 — Config + parser.** Collapse the three tables into - `[behavior.sticky_key]` (with the rename); add the `SK(LGui)` / `SK(MO(n))` - parse paths; remove the `OSM`/`OSL` keywords and the legacy 5-positional SK tail. - Update keymaps/tests to the new syntax. **Gate: rewritten config/parse tests - green.** -- **Stage 2 — Engine: shape dispatch + absorb OSM.** Single latch; pure-mod path - with terminating-key application (3b), accumulation (3c), and shape-gated - `activate_on_keypress`/`quick_release` (3a). Delete the inline `select` and (per - audit) `unprocessed_events`. **Gate: all OSM-behavior tests green against the new - syntax; SK tests green.** -- **Stage 3 — Engine: absorb OSL.** Fold `SK(MO(n))` onto the latch reusing the - layer activate/deactivate logic; `release_on_layer_change` reads the latch. - **Gate: all OSL-behavior tests green; full suite + `cargo clippy` clean.** -- **Stage 4 — Docs.** Write the Section 6 documentation. **Gate: docs reviewed.** - -**New tests:** a targeted test that the pure-mod terminating-key behavior applies -the mod to the consuming key (the OSM-via-SK regression that today's SK engine -gets wrong), and one for cross-tap accumulation on the pure-mod shape. - ---- - -## Section 8 — Risks & non-goals - -**Risks (ranked):** - -1. **Terminating-key semantics (3b).** Making the held mod survive *through* the - foreign key for pure-mod SK while still dropping it for tap-key SK is the core - behavioral change and the easiest to get subtly wrong (off-by-one on - press/release ordering). Mitigated by the dedicated regression test and the - re-expressed OSM suite. -2. **OSM timeout-semantics shift.** Moving OSM off the blocking inline `select` - onto the run-loop deadline changes *when* expiry is observed relative to an - incoming event (carried over from the 06-02 risk list). Watch on real hardware. -3. **Wire/struct break (Section 5).** A postcard wire-order / struct change is - unavoidable (the layer payload requires it). Its blast radius on stored keymaps - and Vial state is **undetermined** — evaluate during hardware testing and record - the finding (and any migration step) before moving toward PR #859. -4. **`unprocessed_events` removal.** Only safe if OSM/OSL were its sole producers; - grep/audit before deleting. -5. **Behavior loss during re-expression.** Any OSM/OSL axis (double-press un-latch, - held-promotion, accumulation, `quick_release`, `activate_on_keypress`, layer - one-shot) could be dropped when re-homed into the SK engine. Mitigated by the - Stage 0 catalogue used as the parity checklist. - -**Non-goals (explicitly out of scope this round):** - -- Per-key argument overrides and named `profiles` subtables (Section 4 — - deferred, pending RAM/flash measurement). -- Separate timeouts for OSM vs. alt-tab keys (the deferred override is the planned - mechanism; this round uses one shared `timeout`). -- `OneShotKey` (OSK) — still unsupported, stays a no-op warning this round. Its - role and whether it folds into SK is **deferred and revisited after the OSM+OSL - migration is confirmed working** (it is not yet understood well enough to design - for here). -- Preserving the old `OSM`/`OSL`/legacy-positional-`SK` surfaces — all dropped - (Section 1); no compatibility shim. - ---- - -## File map (anticipated) - -**Modify — config:** - -- `rmk-config/src/lib.rs` — replace `StickyKeyConfig { timeout }` and the - one-shot config structs with the unified `[behavior.sticky_key]` shape - (`timeout`, `activate_on_keypress`, `quick_release`, `max_repeat`, - `release_on_layer_change`); remove `[behavior.one_shot]` / - `[behavior.one_shot_modifiers]`. -- `rmk/src/config/behavior.rs` — collapse `OneShotConfig` + - `OneShotModifiersConfig` + `StickyKeyConfig` into one resolved config; new - defaults (Section 2). - -**Modify — parser/codegen:** - -- `rmk-macro/src/codegen/action_parser.rs` — `SK(LGui)` (pure-mod), `SK(MO(n))` - (layer), `SK(key,[mods])` parse; remove the 5-positional tail; remove the - `OSM` / `OSL` action keywords entirely (build error if used). -- `rmk/src/layout_macro.rs` — update/remove the `SK(...)` macro arms for the new - shapes; layer-carrying payload. - -**Modify — engine:** - -- `rmk/src/keyboard/sticky_key.rs` — `StickyKeyState` → unified latch; shape - dispatch (3a), terminating-key application (3b), accumulation (3c), layer shape - (3d). -- `rmk/src/keyboard/oneshot.rs` — absorb OSM/OSL logic into the latch; delete the - inline `select` timeout blocks; this file likely shrinks to nothing or merges - into `sticky_key.rs`. -- `rmk/src/keyboard.rs` — state fields (`osm_state`, `osl_state`, - `sticky_key_state` → one latch), deadline race, dispatch arms, foreign-key hook - (`1220-1232`, `1586`), layer-change release (`1600` + spots), - `resolve_explicit_modifiers` (`1380-1403`); remove `unprocessed_events` - producers (pending audit). - -**Modify — wire:** - -- `rmk-types/src/action/mod.rs` — `StickyKeyAction` gains the layer shape - (Section 5); remove `OneShotModifier` / `OneShotLayer` variants. -- `rmk/src/host/via/keycode_convert.rs` — drop OSM/OSL keycode mappings. -- `rmk/src/storage/mod.rs` — `one_shot_timeout` persisted field → the unified - config; Vial one-shot-timeout handling. - -**Modify — docs/tests:** - -- User docs — Section 6 documentation requirement. -- `rmk/tests/keyboard_one_shot_test.rs`, `keyboard_sticky_key_test.rs` — - re-expressed against the new surface; add the 3b/3c regression tests. - ---- - -## Open questions for the implementation plan - -1. **Action payload encoding (Section 5):** tagged variant vs. added - `layer: Option` — decide by which keeps the engine dispatch cleanest once - the latch is merged. -2. **Home of the unified latch** — fold `oneshot.rs` into `sticky_key.rs`, or a - new shared module — decide during Stage 2. -3. **Vial one-shot-timeout control** — does the unified `timeout` keep a Vial - runtime-set path, or is that dropped with the OSM keycodes? Decide in Stage 1. -4. **Wire/Vial/storage migration impact (Section 5)** — determine after the engine - works (likely during hardware testing) whether the struct change invalidates - stored keymaps / Vial state, and what migration is needed, before moving toward - PR #859. From b25fb3d06f70fbf4de82d089cbf6722a9e73b7e7 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Fri, 12 Jun 2026 15:49:00 -0500 Subject: [PATCH 066/119] fix(examples): migrate use_config keymaps OSM/OSL -> SK(...) 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). --- examples/use_config/esp32_ble_split/keyboard.toml | 2 +- examples/use_config/esp32c3_ble/keyboard.toml | 2 +- examples/use_config/esp32c6_ble/keyboard.toml | 2 +- examples/use_config/esp32s3_ble/keyboard.toml | 2 +- examples/use_config/nrf52832_ble/keyboard.toml | 2 +- examples/use_config/nrf52840_ble/keyboard.toml | 2 +- examples/use_config/nrf52840_ble_split/keyboard.toml | 2 +- examples/use_config/nrf52840_ble_split_direct_pin/keyboard.toml | 2 +- examples/use_config/nrf52840_ble_split_dongle/keyboard.toml | 2 +- examples/use_config/pi_pico_w_ble/keyboard.toml | 2 +- examples/use_config/pi_pico_w_ble_split/keyboard.toml | 2 +- examples/use_config/rp2040/keyboard.toml | 2 +- examples/use_config/rp2040_direct_pin/keyboard.toml | 2 +- examples/use_config/rp2040_oled/keyboard.toml | 2 +- examples/use_config/rp2040_split/keyboard.toml | 2 +- examples/use_config/rp2040_split_pio/keyboard.toml | 2 +- examples/use_config/stm32f1/keyboard.toml | 2 +- examples/use_config/stm32f4/keyboard.toml | 2 +- examples/use_config/stm32h7/keyboard.toml | 2 +- 19 files changed, 19 insertions(+), 19 deletions(-) diff --git a/examples/use_config/esp32_ble_split/keyboard.toml b/examples/use_config/esp32_ble_split/keyboard.toml index 5b252689a..3ddfb3691 100644 --- a/examples/use_config/esp32_ble_split/keyboard.toml +++ b/examples/use_config/esp32_ble_split/keyboard.toml @@ -20,7 +20,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], diff --git a/examples/use_config/esp32c3_ble/keyboard.toml b/examples/use_config/esp32c3_ble/keyboard.toml index 36ec4a23c..0ebe277fc 100644 --- a/examples/use_config/esp32c3_ble/keyboard.toml +++ b/examples/use_config/esp32c3_ble/keyboard.toml @@ -24,7 +24,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], diff --git a/examples/use_config/esp32c6_ble/keyboard.toml b/examples/use_config/esp32c6_ble/keyboard.toml index 97c021044..35b41a8e7 100644 --- a/examples/use_config/esp32c6_ble/keyboard.toml +++ b/examples/use_config/esp32c6_ble/keyboard.toml @@ -24,7 +24,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], diff --git a/examples/use_config/esp32s3_ble/keyboard.toml b/examples/use_config/esp32s3_ble/keyboard.toml index 9f8c16a1e..4fd94bdc9 100644 --- a/examples/use_config/esp32s3_ble/keyboard.toml +++ b/examples/use_config/esp32s3_ble/keyboard.toml @@ -23,7 +23,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], diff --git a/examples/use_config/nrf52832_ble/keyboard.toml b/examples/use_config/nrf52832_ble/keyboard.toml index b8857e677..d1f3ec1b9 100644 --- a/examples/use_config/nrf52832_ble/keyboard.toml +++ b/examples/use_config/nrf52832_ble/keyboard.toml @@ -23,7 +23,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], diff --git a/examples/use_config/nrf52840_ble/keyboard.toml b/examples/use_config/nrf52840_ble/keyboard.toml index f9831ebb3..75c7cce2a 100644 --- a/examples/use_config/nrf52840_ble/keyboard.toml +++ b/examples/use_config/nrf52840_ble/keyboard.toml @@ -42,7 +42,7 @@ name = "second_layer" keys = """ TD(1) TO(0) WM(W,LShift) No DF(0) LT(1, Space) LM(0, LShift | RGui) -OSL(0) OSM(LAlt) TH(Kp1, Kp2) SHIFTED(Kp2) +SK(MO(0)) SK(LAlt) TH(Kp1, Kp2) SHIFTED(Kp2) @my_copy @my_paste """ # Encoder 0 - CW: BrightnessUp, CCW: BrightnessDown diff --git a/examples/use_config/nrf52840_ble_split/keyboard.toml b/examples/use_config/nrf52840_ble_split/keyboard.toml index 02a29c477..1cf088a20 100644 --- a/examples/use_config/nrf52840_ble_split/keyboard.toml +++ b/examples/use_config/nrf52840_ble_split/keyboard.toml @@ -15,7 +15,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["TD(1)", "TT(1)", "TG(2)"], diff --git a/examples/use_config/nrf52840_ble_split_direct_pin/keyboard.toml b/examples/use_config/nrf52840_ble_split_direct_pin/keyboard.toml index da49b93e3..2d253a575 100644 --- a/examples/use_config/nrf52840_ble_split_direct_pin/keyboard.toml +++ b/examples/use_config/nrf52840_ble_split_direct_pin/keyboard.toml @@ -15,7 +15,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], diff --git a/examples/use_config/nrf52840_ble_split_dongle/keyboard.toml b/examples/use_config/nrf52840_ble_split_dongle/keyboard.toml index 97cf795a8..f3dc5f900 100644 --- a/examples/use_config/nrf52840_ble_split_dongle/keyboard.toml +++ b/examples/use_config/nrf52840_ble_split_dongle/keyboard.toml @@ -15,7 +15,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["TD(1)", "TT(1)", "TG(2)"], diff --git a/examples/use_config/pi_pico_w_ble/keyboard.toml b/examples/use_config/pi_pico_w_ble/keyboard.toml index 5cf21bddb..de270815c 100644 --- a/examples/use_config/pi_pico_w_ble/keyboard.toml +++ b/examples/use_config/pi_pico_w_ble/keyboard.toml @@ -22,7 +22,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], diff --git a/examples/use_config/pi_pico_w_ble_split/keyboard.toml b/examples/use_config/pi_pico_w_ble_split/keyboard.toml index 966106f1f..9e032dc96 100644 --- a/examples/use_config/pi_pico_w_ble_split/keyboard.toml +++ b/examples/use_config/pi_pico_w_ble_split/keyboard.toml @@ -15,7 +15,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], diff --git a/examples/use_config/rp2040/keyboard.toml b/examples/use_config/rp2040/keyboard.toml index d24fb13ca..e0bae4c67 100644 --- a/examples/use_config/rp2040/keyboard.toml +++ b/examples/use_config/rp2040/keyboard.toml @@ -35,7 +35,7 @@ keymap = [ "LShift", ], [ - "OSL(1)", + "SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)", ], diff --git a/examples/use_config/rp2040_direct_pin/keyboard.toml b/examples/use_config/rp2040_direct_pin/keyboard.toml index 88196dbfb..3c5ad1a59 100644 --- a/examples/use_config/rp2040_direct_pin/keyboard.toml +++ b/examples/use_config/rp2040_direct_pin/keyboard.toml @@ -25,7 +25,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "_", "_"], - ["OSL(1)", "LT(2, Kc9)", "_"] + ["SK(MO(1))", "LT(2, Kc9)", "_"] ], [ ["_", "TT(1)", "TG(2)"], diff --git a/examples/use_config/rp2040_oled/keyboard.toml b/examples/use_config/rp2040_oled/keyboard.toml index f77446524..1986afd29 100644 --- a/examples/use_config/rp2040_oled/keyboard.toml +++ b/examples/use_config/rp2040_oled/keyboard.toml @@ -35,7 +35,7 @@ keymap = [ "LShift", ], [ - "OSL(1)", + "SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)", ], diff --git a/examples/use_config/rp2040_split/keyboard.toml b/examples/use_config/rp2040_split/keyboard.toml index e855a1179..3b5b8f235 100644 --- a/examples/use_config/rp2040_split/keyboard.toml +++ b/examples/use_config/rp2040_split/keyboard.toml @@ -15,7 +15,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], diff --git a/examples/use_config/rp2040_split_pio/keyboard.toml b/examples/use_config/rp2040_split_pio/keyboard.toml index b5d288caf..01c0cf675 100644 --- a/examples/use_config/rp2040_split_pio/keyboard.toml +++ b/examples/use_config/rp2040_split_pio/keyboard.toml @@ -15,7 +15,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], diff --git a/examples/use_config/stm32f1/keyboard.toml b/examples/use_config/stm32f1/keyboard.toml index a23b4ce92..2825fbed0 100644 --- a/examples/use_config/stm32f1/keyboard.toml +++ b/examples/use_config/stm32f1/keyboard.toml @@ -22,7 +22,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], diff --git a/examples/use_config/stm32f4/keyboard.toml b/examples/use_config/stm32f4/keyboard.toml index b7f3bcde2..c43f59a50 100644 --- a/examples/use_config/stm32f4/keyboard.toml +++ b/examples/use_config/stm32f4/keyboard.toml @@ -22,7 +22,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], diff --git a/examples/use_config/stm32h7/keyboard.toml b/examples/use_config/stm32h7/keyboard.toml index 53678a101..354bfa3b9 100644 --- a/examples/use_config/stm32h7/keyboard.toml +++ b/examples/use_config/stm32h7/keyboard.toml @@ -22,7 +22,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], From 378a7b867599e1ef2a3c3ab1982742f5fdbda62b Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:10:57 -0500 Subject: [PATCH 067/119] fix(macro): use re-exported ::rmk::embassy_time in sticky_key codegen 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. --- rmk-macro/src/codegen/behavior.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rmk-macro/src/codegen/behavior.rs b/rmk-macro/src/codegen/behavior.rs index 96a5dc044..d79129828 100644 --- a/rmk-macro/src/codegen/behavior.rs +++ b/rmk-macro/src/codegen/behavior.rs @@ -24,8 +24,8 @@ fn expand_tri_layer(tri_layer: &Option<[u8; 3]>) -> proc_macro2::TokenStream { fn expand_sticky_key(behavior: &Behavior) -> proc_macro2::TokenStream { let timeout = match behavior.sticky_key_timeout_ms { - Some(millis) => quote! { ::embassy_time::Duration::from_millis(#millis) }, - None => quote! { ::embassy_time::Duration::from_secs(1) }, + Some(millis) => quote! { ::rmk::embassy_time::Duration::from_millis(#millis) }, + None => quote! { ::rmk::embassy_time::Duration::from_secs(1) }, }; let activate_on_keypress = behavior.sticky_key_activate_on_keypress.unwrap_or(false); let quick_release = behavior.sticky_key_quick_release.unwrap_or(false); From 473c595c6a8d3f5cc5a4bdf6d816dc2f45100a0f Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:28:20 -0500 Subject: [PATCH 068/119] test(sticky-key): assert activate_on_keypress/quick_release ignored on tap-key SKs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- rmk/tests/keyboard_sticky_key_test.rs | 72 +++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/rmk/tests/keyboard_sticky_key_test.rs b/rmk/tests/keyboard_sticky_key_test.rs index fae2ae295..640157ebe 100644 --- a/rmk/tests/keyboard_sticky_key_test.rs +++ b/rmk/tests/keyboard_sticky_key_test.rs @@ -633,4 +633,76 @@ rusty_fork_test! { ] }; } + + /// StickyKey Test 15: `activate_on_keypress` is IGNORED for tap-key SKs. + /// + /// Docs: `activate_on_keypress` is "honored only for pure-mod SKs" and is + /// "silently ignored for tap-key SKs". A tap-key already sends its modifier + /// eagerly on the first press, so the flag has nothing to tune. With + /// activate_on_keypress=true the report stream must be identical to the + /// default tap-key flow (cf. test_sk_basic_flow_press_twice). + #[test] + fn test_sk_tap_key_ignores_activate_on_keypress() { + key_sequence_test! { + keyboard: create_test_keyboard_with_behavior_config(BehaviorConfig { + sticky_key: StickyKeyConfig { + activate_on_keypress: true, // pure-mod-only knob — must be ignored here + release_on_layer_change: true, // match create_test_keyboard so MO release cleans up + ..StickyKeyConfig::default() + }, + ..BehaviorConfig::default() + }), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SK(Tab, LAlt) + [0, 0, false, 10], // Release SK + [0, 0, true, 10], // Press SK again + [0, 0, false, 10], // Release SK + [0, 3, false, 10], // Release MO(1) + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press again: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held + [0, [0, 0, 0, 0, 0, 0]], // MO release: SK cleaned up + ] + }; + } + + /// StickyKey Test 16: `quick_release` is IGNORED for tap-key SKs. + /// + /// Docs: `quick_release` is "honored only for pure-mod SKs" and is "silently + /// ignored for tap-key SKs". Its pure-mod semantics (release the modifier on + /// the next key *press*) have nothing to tune on a tap-key, which deliberately + /// holds its modifier across repeats. With quick_release=true the report stream + /// must be identical to the default tap-key flow (cf. test_sk_basic_flow_press_twice). + #[test] + fn test_sk_tap_key_ignores_quick_release() { + key_sequence_test! { + keyboard: create_test_keyboard_with_behavior_config(BehaviorConfig { + sticky_key: StickyKeyConfig { + quick_release: true, // pure-mod-only knob — must be ignored here + release_on_layer_change: true, // match create_test_keyboard so MO release cleans up + ..StickyKeyConfig::default() + }, + ..BehaviorConfig::default() + }), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SK(Tab, LAlt) + [0, 0, false, 10], // Release SK + [0, 0, true, 10], // Press SK again + [0, 0, false, 10], // Release SK + [0, 3, false, 10], // Release MO(1) + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press again: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held + [0, [0, 0, 0, 0, 0, 0]], // MO release: SK cleaned up + ] + }; + } } From 5ac8d57f5a0d19055c6be3adaebbd8da0ceb6f0c Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:29:10 -0500 Subject: [PATCH 069/119] feat(sticky-key): restore OSM/OSL as aliases for SK(mod)/SK(MO(n)) Per HaoboGu's 2026-06-16 review of #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 --- docs/docs/main/docs/configuration/behavior.md | 25 ++++--- docs/docs/main/docs/configuration/layout.md | 4 +- rmk-config/src/keymap.pest | 11 +++- rmk-config/src/layout.rs | 58 +++++++++++++++++ rmk-macro/src/codegen/action_parser.rs | 65 +++++++++++++++++++ 5 files changed, 147 insertions(+), 16 deletions(-) diff --git a/docs/docs/main/docs/configuration/behavior.md b/docs/docs/main/docs/configuration/behavior.md index dfc7245ca..a7323ea82 100644 --- a/docs/docs/main/docs/configuration/behavior.md +++ b/docs/docs/main/docs/configuration/behavior.md @@ -33,7 +33,7 @@ Note that `"#layer_name"` could also be used in place of layer numbers. ## Sticky Key -The `[behavior.sticky_key]` table configures the unified **Sticky Key** (`SK`) feature. `SK` replaces the former `OSM` (one-shot modifier) and `OSL` (one-shot layer) actions, which have been removed and are now a build error. +The `[behavior.sticky_key]` table configures the unified **Sticky Key** (`SK`) feature. `SK` unifies the former `OSM` (one-shot modifier) and `OSL` (one-shot layer) actions into a single engine. `OSM(mod)` and `OSL(n)` remain available as aliases for `SK(mod)` and `SK(MO(n))` — they desugar to the exact same action, so either spelling works. ### SK shapes @@ -52,7 +52,7 @@ The `[behavior.sticky_key]` table configures the unified **Sticky Key** (`SK`) f | `timeout` | `"1s"` | Auto-release an unused sticky key after this idle time. String suffixed `s` or `ms`. | | `activate_on_keypress` | `false` | **Pure-mod SKs only.** When `true`, send the modifier immediately as the SK key itself is pressed, instead of waiting and applying it to the next key. (Also known as One-Shot Sticky Modifiers / OSSM.) | | `quick_release` | `false` | **Pure-mod SKs only.** Release the modifier as soon as the next key is *pressed* (`true`) rather than when it is *released* (`false`, chain mode). | -| `max_repeat` | `0` | Max number of keys the sticky modifier applies to; `0` = unlimited. | +| `max_repeat` | `0` | **Tap-key SKs only.** Caps how many repeated presses of the key keep the modifier held; `0` = unlimited. Pure-mod (`SK(LGui)`) and layer (`SK(MO(n))`) SKs ignore this — they always apply to exactly one following key. | | `release_on_layer_change` | `false` | Whether a layer change releases the sticky key. `false` = it survives layer changes. | The `quick_release` option in detail: @@ -62,7 +62,7 @@ The `quick_release` option in detail: :::warning -`activate_on_keypress` and `quick_release` are honored **only for pure-mod SKs** (`SK(LGui)`). They are **silently ignored** for tap-key SKs (`SK(Tab, [LAlt])`) and layer SKs (`SK(MO(n))`). Both fields tune *when a one-shot modifier is sent and released*: a tap-key SK sends its modifier eagerly and deliberately holds it across repeats, and a layer SK sends no modifier at all — so neither has anything for these fields to tune. +`activate_on_keypress` and `quick_release` are honored **only for pure-mod SKs** (`SK(LGui)`, equivalently `OSM(LGui)`). They are **silently ignored** for tap-key SKs (`SK(Tab, [LAlt])`) and layer SKs (`SK(MO(n))` / `OSL(n)`). Both fields tune *when a one-shot modifier is sent and released*: a tap-key SK sends its modifier eagerly and deliberately holds it across repeats, and a layer SK sends no modifier at all — so neither has anything for these fields to tune. ::: @@ -102,20 +102,19 @@ For keymap usage, see `SK(...)` in the [keymap configuration](./layout#keyboard- ### Migration from OSM / OSL -The former `OSM`, `OSL`, the 5-positional `SK` form, and the `[behavior.one_shot]` / `[behavior.one_shot_modifiers]` tables are **removed** — using them is a build error. +`OSM(mod)` and `OSL(n)` are **still supported** as aliases — they desugar to `SK(mod)` and `SK(MO(n))` respectively, so existing keymaps keep working unchanged. The `SK` forms are the canonical spelling; use whichever you prefer. The old 5-positional `SK` form and the `[behavior.one_shot]` / `[behavior.one_shot_modifiers]` config tables, however, are **removed** — using them is a build error. -| Old | New | -|-----|-----| -| `OSM(LGui)` | `SK(LGui)` | -| `OSL(1)` | `SK(MO(1))` | -| `SK(Tab, [LAlt], 0, 0, false)` (5-positional) | `SK(Tab, [LAlt])` + `[behavior.sticky_key]` | -| `[behavior.one_shot]` `timeout` | `[behavior.sticky_key]` `timeout` | -| `[behavior.one_shot_modifiers]` `activate_on_keypress` / `quick_release` | `[behavior.sticky_key]` `activate_on_keypress` / `quick_release` | -| `exit_on_layer_change` | `release_on_layer_change` | +| Old | New (canonical) | Alias still accepted | +|-----|-----------------|----------------------| +| `OSM(LGui)` | `SK(LGui)` | `OSM(LGui)` | +| `OSL(1)` | `SK(MO(1))` | `OSL(1)` | +| `SK(Tab, [LAlt], 0, 0, false)` (5-positional) | `SK(Tab, [LAlt])` + `[behavior.sticky_key]` | — | +| `[behavior.one_shot]` `timeout` | `[behavior.sticky_key]` `timeout` | — | +| `[behavior.one_shot_modifiers]` `activate_on_keypress` / `quick_release` | `[behavior.sticky_key]` `activate_on_keypress` / `quick_release` | — | +| `exit_on_layer_change` | `release_on_layer_change` | — | Accepted breaking changes: -- `OSM(...)` and `OSL(...)` keymap actions are **removed** → build error. Use `SK(mod)` and `SK(MO(n))`. - The old 5-positional `SK(key, [mod], max_repeat, timeout_ms, exit_on_layer_change)` form is **removed** → build error. The trailing knobs now live in `[behavior.sticky_key]`. - The `[behavior.one_shot]` and `[behavior.one_shot_modifiers]` config tables are **removed** → use `[behavior.sticky_key]`. - The old per-key `exit_on_layer_change` is renamed to the global `release_on_layer_change` (default `false`). diff --git a/docs/docs/main/docs/configuration/layout.md b/docs/docs/main/docs/configuration/layout.md index 3891b2507..0879298ff 100644 --- a/docs/docs/main/docs/configuration/layout.md +++ b/docs/docs/main/docs/configuration/layout.md @@ -123,8 +123,8 @@ The `layer.keys` string should follow several rules: 3. Use `LM(n, modifier)` to create layer activate with modifier action. The modifier can be chained in the same way as `WM` 4. Use `LT(n, key, )` to create a layer activate action or tap key(tap/hold). The `key` here is the RMK [`KeyCode`](https://docs.rs/rmk/latest/rmk/keycode/enum.KeyCode.html), The `profile_name` is optional, which defines the key's [profile](./behavior#per-key-profiles-for-morse-tapdance-tap-hold-fine-tuning) 5. Use `SK(...)` to create a sticky key action — behavior is selected by argument shape: - - `SK(modifier)` — one-shot modifier (replaces the removed `OSM`): the modifier is held for the next key press, then released automatically. Modifiers chain like `WM`, e.g. `SK(LCtrl|LShift)`. - - `SK(MO(n))` — one-shot layer (replaces the removed `OSL`): layer `n` is active for the next key press, then released. + - `SK(modifier)` — one-shot modifier (also spelled `OSM(modifier)`, an alias): the modifier is held for the next key press, then released automatically. Modifiers chain like `WM`, e.g. `SK(LCtrl|LShift)`. + - `SK(MO(n))` — one-shot layer (also spelled `OSL(n)`, an alias): layer `n` is active for the next key press, then released. - `SK(key, [modifier])` — tap-key (Alt+Tab-style cycling): the modifier stays held across repeated presses of `key` until any non-SK, non-modifier key is pressed. The modifier list is in `[ ]` and can be chained, e.g. `SK(Tab, [LCtrl|LShift])`. See [Sticky Key](./behavior#sticky-key) for global config (`timeout`, `activate_on_keypress`, `quick_release`, etc.). diff --git a/rmk-config/src/keymap.pest b/rmk-config/src/keymap.pest index 159e31d3f..c0d62d5a5 100644 --- a/rmk-config/src/keymap.pest +++ b/rmk-config/src/keymap.pest @@ -54,6 +54,15 @@ transparent_action = @{ ("_")+ | (^"Trns" ~ !ASCII_ALPHANUMERIC) } // One or mor // Rule 1: WM(key, modifier) - Key with Modifier wm_action = { ^"WM" ~ "(" ~ keycode_name ~ "," ~ modifier_combination ~ ")" } +// Rule 4.6: OSM(modifier) - One-Shot Modifier. User-facing alias for the +// pure-mod sticky key SK(modifier); desugared to SK in keymap_parser. +osm_action = { ^"OSM" ~ "(" ~ modifier_combination ~ ")" } + +// Rule 4.5: OSL(n) - One-Shot Layer. User-facing alias for the layer sticky +// key SK(MO(n)); desugared to SK in keymap_parser. Kept out of layer_action so +// nonsense like SK(OSL(n)) stays a grammar error. +osl_action = { ^"OSL" ~ "(" ~ layer_reference ~ ")" } + // Rule 4.1: DF(n) - Switch Default Layer df_action = { ^"DF" ~ "(" ~ layer_reference ~ ")" } @@ -113,7 +122,7 @@ sk_action = { // 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 | layer_action | mt_action | th_action | shifted_action | morse_action | trigger_macro_action | sk_action | no_action | transparent_action | simple_keycode + wm_action | osm_action | osl_action | layer_action | mt_action | th_action | shifted_action | morse_action | trigger_macro_action | sk_action | no_action | transparent_action | simple_keycode } // The entire key map string: Start, zero or more key actions, End. diff --git a/rmk-config/src/layout.rs b/rmk-config/src/layout.rs index 5c6a36d9d..cddadd05f 100644 --- a/rmk-config/src/layout.rs +++ b/rmk-config/src/layout.rs @@ -401,6 +401,19 @@ impl KeyboardTomlConfig { key_action_sequence.push(action); } + // OSM(modifier)/OSL(n) are user-facing aliases for the pure-mod and + // layer sticky keys. They are forwarded as-is (like sk_action) and + // desugared to SK in the codegen parser, so they work in every context + // SK works (keymap grid and encoders) and produce byte-identical actions. + Rule::osm_action => { + let action = inner_pair.as_str().to_string(); + key_action_sequence.push(action); + } + Rule::osl_action => { + let action = inner_pair.as_str().to_string(); + key_action_sequence.push(action); + } + //layer actions: Rule::df_action => { key_action_sequence.push(Self::layer_name_resolver("DF", inner_pair, layer_names)?); @@ -778,4 +791,49 @@ mod tests { assert!(found_sk, "Input should be parsed as sk_action: {}", input); } } + + #[test] + fn test_osm_osl_alias_grammar() { + // OSM(modifier) parses as osm_action, OSL(n) as osl_action. + let osm_cases = vec!["OSM(LGui)", "OSM(LCtrl | LShift)", "osm(lalt)"]; + let osl_cases = vec!["OSL(1)", "OSL(3)", "osl(2)"]; + + let parses_as = |input: &str, rule: Rule| { + let result = ConfigParser::parse(Rule::key_map, input); + assert!(result.is_ok(), "Failed to parse: {}", input); + let mut found = false; + for pair in result.unwrap() { + if pair.as_rule() == Rule::key_map { + for inner_pair in pair.into_inner() { + if inner_pair.as_rule() == rule { + found = true; + } + } + } + } + assert!(found, "Input {} should be parsed as {:?}", input, rule); + }; + + for input in osm_cases { + parses_as(input, Rule::osm_action); + } + for input in osl_cases { + parses_as(input, Rule::osl_action); + } + } + + #[test] + fn test_osm_osl_alias_parsing() { + let aliases = HashMap::new(); + let layer_names = HashMap::new(); + + // OSM(modifier)/OSL(n) are forwarded as-is (like SK) and desugared to + // SK in the codegen parser. Here we only assert they survive keymap + // parsing intact; codegen byte-identicalness is covered in rmk-macro. + let keymap = "OSM(LGui) OSM(LCtrl | LShift) OSL(1)"; + let result = KeyboardTomlConfig::keymap_parser(keymap, &aliases, &layer_names); + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), vec!["OSM(LGui)", "OSM(LCtrl | LShift)", "OSL(1)"]); + } } diff --git a/rmk-macro/src/codegen/action_parser.rs b/rmk-macro/src/codegen/action_parser.rs index 71618be98..2416df6c3 100644 --- a/rmk-macro/src/codegen/action_parser.rs +++ b/rmk-macro/src/codegen/action_parser.rs @@ -198,6 +198,37 @@ pub(crate) fn parse_key( ::rmk::mo!(#layer) } } + s if s.to_lowercase().starts_with("osl(") => { + // OSL(n) — user-facing alias for the layer sticky key SK(MO(n)). + // Emits the same `sk_layer!` as SK(MO(n)), so the action is byte-identical. + let layer = get_number(s.clone(), s.get(0..4).unwrap(), ")"); + quote! { + ::rmk::sk_layer!(#layer) + } + } + s if s.to_lowercase().starts_with("osm(") => { + // OSM(modifier) — user-facing alias for the pure-mod sticky key SK(modifier). + // Emits the same `sk_mod!` as SK(modifier), so the action is byte-identical. + let prefix = s.get(0..4).unwrap(); + if let Some(internal) = s.trim_start_matches(prefix).strip_suffix(")") { + let modifiers = parse_modifiers(internal); + + if modifiers.is_empty() { + panic!( + "\n\u{274c} keyboard.toml: OSM(modifier) is not valid! \ + OSM is an alias for SK(modifier). Usage: OSM(LGui) | OSM(LCtrl | LShift)" + ); + } + quote! { + ::rmk::sk_mod!(#modifiers) + } + } else { + panic!( + "\n\u{274c} keyboard.toml: OSM(modifier) invalid. \ + OSM is an alias for SK(modifier). Usage: OSM(LGui) | OSM(LCtrl | LShift)" + ); + } + } s if s.to_lowercase().starts_with("sk(") => { let prefix = s.get(0..3).unwrap(); if let Some(internal) = s.trim_start_matches(prefix).strip_suffix(")") { @@ -551,3 +582,37 @@ pub(crate) fn get_key_with_alias(key: String) -> Ident { }; format_ident!("{}", key) } + +#[cfg(test)] +mod tests { + use super::*; + + /// OSM(modifier)/OSL(n) are aliases that must expand to the exact same + /// action tokens as their SK equivalents (sk_mod! / sk_layer!). + #[test] + fn osm_osl_aliases_match_sk_tokens() { + // (alias form, canonical SK form, macro the action must emit) + let cases = [ + ("OSM(LGui)", "SK(LGui)", "sk_mod"), + ("OSM(LCtrl | LShift)", "SK(LCtrl | LShift)", "sk_mod"), + ("osm(lalt)", "sk(lalt)", "sk_mod"), + ("OSL(1)", "SK(MO(1))", "sk_layer"), + ("OSL(3)", "SK(MO(3))", "sk_layer"), + ]; + + for (alias, sk, expected_macro) in cases { + let alias_tokens = parse_key(alias.to_string(), &None).to_string(); + let sk_tokens = parse_key(sk.to_string(), &None).to_string(); + assert_eq!( + alias_tokens, sk_tokens, + "{alias} must expand identically to {sk}" + ); + // ...and the shared expansion is the real sticky-key action, not + // just two strings that happen to match. + assert!( + alias_tokens.contains(expected_macro), + "{alias} should emit {expected_macro}!, got: {alias_tokens}" + ); + } + } +} From 34a65599fff3fb671a267d9d9f4c15b884a406c1 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:49:49 -0500 Subject: [PATCH 070/119] refactor(resolved): group sticky_key fields into StickyKeyConfig struct Per HaoboGu's review of #859: consolidate the five flat sticky_key_timeout_ms/activate_on_keypress/quick_release/max_repeat/ release_on_layer_change fields on Behavior into a StickyKeyConfig struct, matching the runtime StickyKeyConfig already in rmk/src/config/behavior.rs. --- rmk-config/src/resolved/behavior.rs | 33 +++++++++++++++-------------- rmk-macro/src/codegen/behavior.rs | 19 +++++++++-------- 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/rmk-config/src/resolved/behavior.rs b/rmk-config/src/resolved/behavior.rs index f9fa30cd8..42b2e3c93 100644 --- a/rmk-config/src/resolved/behavior.rs +++ b/rmk-config/src/resolved/behavior.rs @@ -1,5 +1,13 @@ use std::collections::HashMap; +pub struct StickyKeyConfig { + pub timeout_ms: Option, + pub activate_on_keypress: Option, + pub quick_release: Option, + pub max_repeat: Option, + pub release_on_layer_change: Option, +} + /// Resolved behavioral configuration. pub struct Behavior { pub tri_layer: Option<[u8; 3]>, @@ -7,11 +15,7 @@ pub struct Behavior { pub macros: Option, pub forks: Option, pub morse: Option, - pub sticky_key_timeout_ms: Option, - pub sticky_key_activate_on_keypress: Option, - pub sticky_key_quick_release: Option, - pub sticky_key_max_repeat: Option, - pub sticky_key_release_on_layer_change: Option, + pub sticky_key: Option, } pub struct Combos { @@ -192,12 +196,13 @@ impl crate::KeyboardTomlConfig { } }); - let sticky_key = toml_behavior.sticky_key; - let sticky_key_timeout_ms = sticky_key.as_ref().and_then(|s| s.timeout.as_ref().map(|t| t.0)); - let sticky_key_activate_on_keypress = sticky_key.as_ref().and_then(|s| s.activate_on_keypress); - let sticky_key_quick_release = sticky_key.as_ref().and_then(|s| s.quick_release); - let sticky_key_max_repeat = sticky_key.as_ref().and_then(|s| s.max_repeat); - let sticky_key_release_on_layer_change = sticky_key.as_ref().and_then(|s| s.release_on_layer_change); + let sticky_key = toml_behavior.sticky_key.map(|s| StickyKeyConfig { + timeout_ms: s.timeout.as_ref().map(|t| t.0), + activate_on_keypress: s.activate_on_keypress, + quick_release: s.quick_release, + max_repeat: s.max_repeat, + release_on_layer_change: s.release_on_layer_change, + }); Ok(Behavior { tri_layer, @@ -205,11 +210,7 @@ impl crate::KeyboardTomlConfig { macros, forks, morse, - sticky_key_timeout_ms, - sticky_key_activate_on_keypress, - sticky_key_quick_release, - sticky_key_max_repeat, - sticky_key_release_on_layer_change, + sticky_key, }) } } diff --git a/rmk-macro/src/codegen/behavior.rs b/rmk-macro/src/codegen/behavior.rs index 433e12f46..a79560705 100644 --- a/rmk-macro/src/codegen/behavior.rs +++ b/rmk-macro/src/codegen/behavior.rs @@ -3,10 +3,8 @@ use std::collections::HashMap; use quote::quote; +use rmk_config::resolved::behavior::{Combos, Forks, MacroOperation, Macros, Morse, MorseActionPair, MorseKey, MorseProfile}; use rmk_config::resolved::Behavior; -use rmk_config::resolved::behavior::{ - Combos, Forks, MacroOperation, Macros, Morse, MorseActionPair, MorseKey, MorseProfile, -}; use super::action_parser::{expand_profile, expand_profile_name, get_key_with_alias, parse_key}; @@ -23,14 +21,17 @@ fn expand_tri_layer(tri_layer: &Option<[u8; 3]>) -> proc_macro2::TokenStream { } fn expand_sticky_key(behavior: &Behavior) -> proc_macro2::TokenStream { - let timeout = match behavior.sticky_key_timeout_ms { - Some(millis) => quote! { ::rmk::embassy_time::Duration::from_millis(#millis) }, + let timeout = match &behavior.sticky_key { + Some(sk) => match sk.timeout_ms { + Some(millis) => quote! { ::rmk::embassy_time::Duration::from_millis(#millis) }, + None => quote! { ::rmk::embassy_time::Duration::from_secs(1) }, + }, None => quote! { ::rmk::embassy_time::Duration::from_secs(1) }, }; - let activate_on_keypress = behavior.sticky_key_activate_on_keypress.unwrap_or(false); - let quick_release = behavior.sticky_key_quick_release.unwrap_or(false); - let max_repeat = behavior.sticky_key_max_repeat.unwrap_or(0); - let release_on_layer_change = behavior.sticky_key_release_on_layer_change.unwrap_or(false); + let activate_on_keypress = behavior.sticky_key.as_ref().and_then(|sk| sk.activate_on_keypress).unwrap_or(false); + let quick_release = behavior.sticky_key.as_ref().and_then(|sk| sk.quick_release).unwrap_or(false); + let max_repeat = behavior.sticky_key.as_ref().and_then(|sk| sk.max_repeat).unwrap_or(0); + let release_on_layer_change = behavior.sticky_key.as_ref().and_then(|sk| sk.release_on_layer_change).unwrap_or(false); quote! { ::rmk::config::StickyKeyConfig { From d2d35f497d37697d886a3a84aff0776b909dfaf0 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:08:31 -0500 Subject: [PATCH 071/119] feat(sticky-key): add osl!/osm! macros as sk_layer!/sk_mod! aliases Per HaoboGu's review of #859: keep OSM/OSL in user space by adding thin macro aliases that delegate to the SK backend macros. - osm!(M) = sk_mod!(M) - osl!(n) = sk_layer!(n) --- rmk/src/layout_macro.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/rmk/src/layout_macro.rs b/rmk/src/layout_macro.rs index f1f78c13d..891503d6b 100644 --- a/rmk/src/layout_macro.rs +++ b/rmk/src/layout_macro.rs @@ -380,6 +380,32 @@ macro_rules! sk_layer { }; } +/// Create a one-shot modifier action (alias for `sk_mod!`). +/// +/// # Example +/// ```ignore +/// osm!(ModifierCombination::LSHIFT) // equivalent to sk_mod!(ModifierCombination::LSHIFT) +/// ``` +#[macro_export] +macro_rules! osm { + ($m:expr) => { + $crate::sk_mod!($m) + }; +} + +/// Create a one-shot layer action (alias for `sk_layer!`). +/// +/// # Example +/// ```ignore +/// osl!(1) // equivalent to sk_layer!(1) +/// ``` +#[macro_export] +macro_rules! osl { + ($n:literal) => { + $crate::sk_layer!($n) + }; +} + /// Create a layer toggle action. /// /// This macro creates a key that toggles a layer on/off with each press. From 42dec113f2fa6d691c7e302ea8b60782461e5484 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:15:04 -0500 Subject: [PATCH 072/119] chore: remove rusty-fork dev-dependency Per HaoboGu's review of #859: rusty-fork is not needed with cargo-nextest which handles test isolation natively. Removed the dep, the import, and the rusty_fork_test! wrapper from keyboard_sticky_key_test.rs. --- rmk/Cargo.toml | 1 - rmk/tests/keyboard_sticky_key_test.rs | 8 +++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/rmk/Cargo.toml b/rmk/Cargo.toml index 1c7e5c856..c1677d12f 100644 --- a/rmk/Cargo.toml +++ b/rmk/Cargo.toml @@ -94,7 +94,6 @@ critical-section = { version = "1.2", features = ["std"] } env_logger = "0.11" ctor = "1.0" embedded-hal-mock = { version = "0.11.1", features = ["embedded-hal-async"] } -rusty-fork = "0.3" [build-dependencies] crc32fast = "1.3" diff --git a/rmk/tests/keyboard_sticky_key_test.rs b/rmk/tests/keyboard_sticky_key_test.rs index 640157ebe..21e15b53f 100644 --- a/rmk/tests/keyboard_sticky_key_test.rs +++ b/rmk/tests/keyboard_sticky_key_test.rs @@ -6,10 +6,10 @@ use rmk::keyboard::Keyboard; use rmk::types::action::KeyAction; use rmk::types::modifier::ModifierCombination; use rmk::{a, k, mo, sk, sk_layer, sk_mod}; -use rusty_fork::rusty_fork_test; use crate::common::{KC_LALT, KC_LCTRL, KC_LGUI, KC_LSHIFT, wrap_keymap}; + // KEYMAP (release_on_layer_change=true is set in the helper config, not per-key) // Layer 0: A B C MO(1) LShift No // Layer 1: SK(Tab,LAlt) SK(Tab,LCtrl) SK(Tab,LCtrl|LShift) Transparent Transparent No @@ -150,8 +150,7 @@ fn create_test_keyboard_with_behavior_config(config: BehaviorConfig) -> Keyboard Keyboard::new(wrap_keymap(KEYMAP, per_key_config, behavior_config)) } -rusty_fork_test! { - /// StickyKey Test 1: Basic SK flow — press SK twice while MO held +/// StickyKey Test 1: Basic SK flow — press SK twice while MO held /// /// Sequence: /// - Press MO(1) → layer activates, no report @@ -704,5 +703,4 @@ rusty_fork_test! { [0, [0, 0, 0, 0, 0, 0]], // MO release: SK cleaned up ] }; - } -} + } \ No newline at end of file From ed70086dc33988ae1b52107aab3101462f38af96 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:21:47 -0500 Subject: [PATCH 073/119] fix(sticky-key): guard timeout release while held; refresh deadline on pure-mod release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item 5: release_sticky_key_if_active() now skips if phase is Pressed (the key is still physically held) — defers cleanup to the physical release handler so the modifier doesn't disappear mid-hold. Item 6: pure-mod deadline refreshed on release-to-Latched, matching existing layer behavior. Previously the deadline was set on press and never updated, so holding OSM for 5s then releasing caused a near- immediate timeout. --- rmk/src/keyboard/sticky_key.rs | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/rmk/src/keyboard/sticky_key.rs b/rmk/src/keyboard/sticky_key.rs index 8148858a6..72b81bbc9 100644 --- a/rmk/src/keyboard/sticky_key.rs +++ b/rmk/src/keyboard/sticky_key.rs @@ -146,9 +146,12 @@ impl Keyboard<'_> { phase: SkPhase::Pressed, .. } => { - // Released before any other key → arm it for the next key. - if let StickyKeyState::Active { phase, .. } = &mut self.sticky_key_state { + // Released before any other key → arm it for the next key. Refresh the + // deadline on release-to-Latched so the timeout is measured from release + // time (not press time). Mirrors the layer shape behavior at line 214. + if let StickyKeyState::Active { phase, deadline: d, .. } = &mut self.sticky_key_state { *phase = SkPhase::Latched; + *d = deadline; } } StickyKeyState::Active { @@ -362,6 +365,24 @@ impl Keyboard<'_> { if !self.sticky_key_state.is_active() { return; } + + // If the SK is still physically held (Pressed phase), the deadline fired but the + // key hasn't been released yet. Don't clear the latch — the physical release + // handler (process_sticky_*) will transition Held→None cleanly. For pure-mod, + // the deadline was set on press (→ Held on any other key press), so this can + // only happen when the key is held and idle. For layer and tap-key shapes, the + // deadline fires in the same scenario. + if matches!( + self.sticky_key_state, + StickyKeyState::Active { + phase: SkPhase::Pressed, + .. + } + ) { + debug!("StickyKey timeout fired while key is still held — deferring to physical release"); + return; + } + debug!("Releasing StickyKey"); // Decide whether the release needs its own HID report. A report is only meaningful From acbe4f35fe9354fbaaaffab34a31bf35d6913f69 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:46:06 -0500 Subject: [PATCH 074/119] fix(sticky-key): guard timeout release while held; refresh deadline on pure-mod release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item 5: release_sticky_key_if_active() now skips if phase is Pressed (the key is still physically held) and clears the deadline to avoid busy-looping — defers cleanup to the physical release handler so the modifier doesn't disappear mid-hold. Item 6: pure-mod deadline refreshed on release-to-Latched, matching existing layer behavior. Previously the deadline was set on press and never updated, so holding OSM for 5s then releasing caused a near- immediate timeout. --- rmk/src/keyboard.rs | 52 +++++++++++++++++++--------------- rmk/src/keyboard/sticky_key.rs | 6 +++- 2 files changed, 34 insertions(+), 24 deletions(-) diff --git a/rmk/src/keyboard.rs b/rmk/src/keyboard.rs index 7da03f92e..a8686b614 100644 --- a/rmk/src/keyboard.rs +++ b/rmk/src/keyboard.rs @@ -145,7 +145,7 @@ impl Runnable for Keyboard<'_> { // (`#[cfg(feature = "split")]`, see below) pushes events here for re-processing. // Do NOT delete the queue or this consumer. // (The OSM/OSL producers were removed once those behaviors moved to the SK engine, - // whose timeout is now driven by the deadline race below rather than an inline select.) + // whose timeout is now driven by the inline race below.) if !self.unprocessed_events.is_empty() { // Process unprocessed events let e = self.unprocessed_events.remove(0); @@ -155,33 +155,36 @@ impl Runnable for Keyboard<'_> { // Process buffered held key self.process_buffered_key(key).await } else { - // Race subscriber against any pending deadlines (mouse repeat, SK timeout) - let sk_deadline = self.sticky_key_state.deadline(); - let mouse_deadline = self.mouse.next_deadline(); - let combined_deadline = [sk_deadline, mouse_deadline] + // Race subscriber against the nearest pending deadline. + let deadline = self.sticky_key_state + .deadline() .into_iter() - .flatten() + .chain(self.mouse.next_deadline()) .reduce(|a, b| a.min(b)); - let event = if let Some(deadline) = combined_deadline { - match with_deadline(deadline, self.keyboard_event_subscriber.next_message_pure()).await { - Ok(event) => event, - Err(_) => { - let now = Instant::now(); - if sk_deadline.is_some_and(|d| now >= d) { - self.release_sticky_key_if_active().await; - } - if mouse_deadline.is_some_and(|d| now >= d) { - self.fire_mouse_repeat().await; - } - continue; - } + if let Some(deadline) = deadline { + if with_deadline(deadline, async { + let event = self.keyboard_event_subscriber.next_message_pure().await; + self.process_inner(event).await + }) + .await + .is_err() + { + // timeout only, handled by post-check below } } else { - // No deadlines pending, wait indefinitely - self.keyboard_event_subscriber.next_message_pure().await - }; - self.process_inner(event).await + let event = self.keyboard_event_subscriber.next_message_pure().await; + self.process_inner(event).await + } }; + + // Check deadlines after processing / timeout. + let now = Instant::now(); + if self.sticky_key_state.deadline().is_some_and(|d| now >= d) { + self.release_sticky_key_if_active().await; + } + if self.mouse.next_deadline().is_some_and(|d| now >= d) { + self.fire_mouse_repeat().await; + } } } } @@ -1596,6 +1599,9 @@ impl<'a> Keyboard<'a> { // Change layer state only when the key's state is changed if event.pressed { self.keymap.activate_layer(layer_num); + if self.keymap.sticky_key_config().release_on_layer_change { + self.release_sticky_key_if_active().await; + } } else { self.keymap.deactivate_layer(layer_num); if self.keymap.sticky_key_config().release_on_layer_change { diff --git a/rmk/src/keyboard/sticky_key.rs b/rmk/src/keyboard/sticky_key.rs index 72b81bbc9..9a3268a70 100644 --- a/rmk/src/keyboard/sticky_key.rs +++ b/rmk/src/keyboard/sticky_key.rs @@ -372,6 +372,7 @@ impl Keyboard<'_> { // the deadline was set on press (→ Held on any other key press), so this can // only happen when the key is held and idle. For layer and tap-key shapes, the // deadline fires in the same scenario. + // Clear the deadline to avoid busy-looping on every iteration. if matches!( self.sticky_key_state, StickyKeyState::Active { @@ -379,7 +380,10 @@ impl Keyboard<'_> { .. } ) { - debug!("StickyKey timeout fired while key is still held — deferring to physical release"); + debug!("StickyKey timeout fired while key is still held — clearing deadline, deferring to physical release"); + if let StickyKeyState::Active { deadline, .. } = &mut self.sticky_key_state { + *deadline = None; + } return; } From 2f30a0c2e7020abf27bc571c8a79f296e524172e Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:24:58 -0500 Subject: [PATCH 075/119] fix: trailing newline, with_deadline scope, and add test for timeout-while-held MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix A: Add trailing newline to keyboard_sticky_key_test.rs Fix B: Restructure with_deadline to only wrap subscriber wait, not process_inner, eliminating spurious timeout risk during processing Fix C: Add test_sk_timeout_while_held — exercises the Item 5 guard (timeout fires while SK physically held in Pressed phase) --- rmk/src/keyboard.rs | 18 +++---- rmk/tests/keyboard_sticky_key_test.rs | 68 +++++++++++++++++++++++++-- 2 files changed, 74 insertions(+), 12 deletions(-) diff --git a/rmk/src/keyboard.rs b/rmk/src/keyboard.rs index a8686b614..1fe02895f 100644 --- a/rmk/src/keyboard.rs +++ b/rmk/src/keyboard.rs @@ -162,14 +162,16 @@ impl Runnable for Keyboard<'_> { .chain(self.mouse.next_deadline()) .reduce(|a, b| a.min(b)); if let Some(deadline) = deadline { - if with_deadline(deadline, async { - let event = self.keyboard_event_subscriber.next_message_pure().await; - self.process_inner(event).await - }) - .await - .is_err() - { - // timeout only, handled by post-check below + let event_result = + with_deadline(deadline, self.keyboard_event_subscriber.next_message_pure()) + .await; + match event_result { + Ok(event) => { + self.process_inner(event).await; + } + Err(_) => { + // timeout only, handled by post-check below + } } } else { let event = self.keyboard_event_subscriber.next_message_pure().await; diff --git a/rmk/tests/keyboard_sticky_key_test.rs b/rmk/tests/keyboard_sticky_key_test.rs index 21e15b53f..cae194f4b 100644 --- a/rmk/tests/keyboard_sticky_key_test.rs +++ b/rmk/tests/keyboard_sticky_key_test.rs @@ -245,11 +245,12 @@ fn create_test_keyboard_with_behavior_config(config: BehaviorConfig) -> Keyboard [KC_LCTRL | KC_LSHIFT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Ctrl+Shift+Tab [KC_LCTRL | KC_LSHIFT, [0, 0, 0, 0, 0, 0]], // SK release: Ctrl+Shift held [KC_LCTRL, [0, 0, 0, 0, 0, 0]], // Shift release: Ctrl held - [0, [0, 0, 0, 0, 0, 0]], // MO release: SK cleaned up + [0, [0, 0, 0, 0, 0, 0]], // MO release: SK cleaned up ] }; } + /// StickyKey Test 4: Rapid presses — 3x SK press/release while MO held /// /// Sequence: @@ -304,11 +305,12 @@ fn create_test_keyboard_with_behavior_config(config: BehaviorConfig) -> Keyboard expected_reports: [ [KC_LCTRL | KC_LSHIFT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Ctrl+Shift+Tab [KC_LCTRL | KC_LSHIFT, [0, 0, 0, 0, 0, 0]], // SK release: Ctrl+Shift held - [0, [0, 0, 0, 0, 0, 0]], // MO release: SK cleaned up + [0, [0, 0, 0, 0, 0, 0]], // MO release: SK cleaned up ] }; } + /// StickyKey Test 6: Timeout — modifier auto-releases after inactivity /// /// Config: global timeout = 100ms @@ -669,7 +671,65 @@ fn create_test_keyboard_with_behavior_config(config: BehaviorConfig) -> Keyboard }; } - /// StickyKey Test 16: `quick_release` is IGNORED for tap-key SKs. + /// KEYMAP_PUREMOD_SK: pure-mod SK at col 4, basic keys at cols 0-2, for testing "timeout while held". +const KEYMAP_PUREMOD_SK: [[[KeyAction; 6]; 1]; 1] = [[[ + k!(A), // col 0: A + k!(B), // col 1: B + k!(C), // col 2: C + a!(No), // col 3: No + sk_mod!(ModifierCombination::LSHIFT), // col 4: SK(LShift) + a!(No), // col 5: No +]]]; + +fn create_test_keyboard_puremod_sk() -> Keyboard<'static> { + let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig { + sticky_key: StickyKeyConfig { + timeout: Duration::from_millis(10), + ..StickyKeyConfig::default() + }, + ..BehaviorConfig::default() + })); + let per_key_config: &'static PositionalConfig<1, 6> = Box::leak(Box::new(PositionalConfig::default())); + Keyboard::new(wrap_keymap(KEYMAP_PUREMOD_SK, per_key_config, behavior_config)) +} + +/// StickyKey Test 17: Timeout fires while SK is still physically held (Pressed phase). +/// +/// The guard in `release_sticky_key_if_active()` must prevent the latch from being +/// cleared. After release the SK transitions to Latched (deadline refreshed), so the +/// next key press still gets the modifier applied. Then the next key press after that +/// does NOT have the modifier (latched-only-for-one-key behavior). +/// +/// Sequence: +/// - Press SK(LShift) → Active(Pressed, deadline = t+10ms, no report since activate_on_keypress=false) +/// - Hold 20ms (past 10ms timeout) → timeout fires, guard clears deadline, returns; state stays +/// - Release SK → Pressed→Latched, deadline refreshed +/// - Press A → register A, modifier applied (pressed=true), report: LShift + A +/// - Release A → update_sticky_key consumes Latched (quick_release=false, !pressed), state→None +/// - Press B → no modifier, report: B only +/// - Release B +#[test] +fn test_sk_timeout_while_held() { + key_sequence_test! { + keyboard: create_test_keyboard_puremod_sk(), + sequence: [ + [0, 4, true, 0], // Press SK(LShift) — state=Pressed, deadline=t+10ms + [0, 4, false, 20], // Hold 20ms (>10ms timeout), then release + [0, 0, true, 0], // Press A + [0, 0, false, 0], // Release A + [0, 1, true, 0], // Press B + [0, 1, false, 0], // Release B + ], + expected_reports: [ + [KC_LSHIFT, [kc_to_u8!(A), 0, 0, 0, 0, 0]], // A press: LShift applied through terminating key + [0, [0, 0, 0, 0, 0, 0]], // A release: LShift consumed with the key + [0, [kc_to_u8!(B), 0, 0, 0, 0, 0]], // B press: no modifier (SK already consumed) + [0, [0, 0, 0, 0, 0, 0]], // B release + ] + }; +} + +/// StickyKey Test 16: `quick_release` is IGNORED for tap-key SKs. /// /// Docs: `quick_release` is "honored only for pure-mod SKs" and is "silently /// ignored for tap-key SKs". Its pure-mod semantics (release the modifier on @@ -703,4 +763,4 @@ fn create_test_keyboard_with_behavior_config(config: BehaviorConfig) -> Keyboard [0, [0, 0, 0, 0, 0, 0]], // MO release: SK cleaned up ] }; - } \ No newline at end of file + } From 231df5cc53b11134b561db9708a606420493981c Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:14:48 -0500 Subject: [PATCH 076/119] =?UTF-8?q?feat(sticky-key):=20OSM=20+=20OSL=20coe?= =?UTF-8?q?xistence=20=E2=80=94=20restore=20parallel=20one-shot=20mod=20an?= =?UTF-8?q?d=20layer=20behavior?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous single-latch design forced mutual exclusion: pressing a layer SK (OSL) while a pure-mod SK (OSM) was latched would overwrite the state, dropping the modifier. The old parallel OSM/OSL state machines allowed them to coexist — a common QMK one-shot workflow. Fix allows pure-mod (modifiers) and layer (OSL) shapes to compose: they affect different dimensions and are naturally compatible. Tap-key remains exclusive with both. Changes: - process_sticky_pure_mod: skip release guard when active state is a layer (compatible) - process_sticky_layer: capture existing_mods from prior state and carry them forward when overwriting - update_sticky_key: new quick_release arm deactivates layer before clearing state when both are active - resolve_explicit_modifiers: treat layer shapes same as pure-mod (only add modifiers on press/Held, not release) - test: updated osm_then_osl/osl_then_osm expectations to reflect coexistence behavior --- .vscode/settings.json | 19 ++++++++++++++++++- rmk/src/keyboard.rs | 2 +- rmk/src/keyboard/sticky_key.rs | 25 ++++++++++++++++++++----- rmk/tests/keyboard_one_shot_test.rs | 10 +++++----- 4 files changed, 44 insertions(+), 12 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 90b957f8a..38f7656db 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -57,5 +57,22 @@ // "examples/use_rust/stm32f1/Cargo.toml", // "examples/use_rust/stm32f4/Cargo.toml", // "examples/use_rust/stm32h7/Cargo.toml", - ], +], +"workbench.colorCustomizations": { + "activityBar.activeBackground": "#fdae33", + "activityBar.background": "#fdae33", + "activityBar.foreground": "#15202b", + "activityBar.inactiveForeground": "#15202b99", + "activityBarBadge.background": "#017e4d", + "activityBarBadge.foreground": "#e7e7e7", + "commandCenter.border": "#15202b99", + "panel.border": "#fdae33", + "sash.hoverBorder": "#fdae33", + "sideBar.border": "#fdae33", + "tab.activeBorder": "#fdae33", + "titleBar.activeBackground": "#fb9902", + "titleBar.activeForeground": "#15202b", + "titleBar.inactiveBackground": "#fb990299", + "titleBar.inactiveForeground": "#15202b99" +}, } \ No newline at end of file diff --git a/rmk/src/keyboard.rs b/rmk/src/keyboard.rs index 1fe02895f..d7730b220 100644 --- a/rmk/src/keyboard.rs +++ b/rmk/src/keyboard.rs @@ -1397,7 +1397,7 @@ impl<'a> Keyboard<'a> { // mode (key pressed while SK still physically held), where the modifier behaves // like a normal held modifier and stays applied until the SK itself is released. if let StickyKeyState::Active { mods, phase, .. } = self.sticky_key_state { - if self.sticky_key_state.is_pure_mod() { + if self.sticky_key_state.is_pure_mod() || self.sticky_key_state.is_layer() { if pressed || phase == SkPhase::Held { result |= mods; } diff --git a/rmk/src/keyboard/sticky_key.rs b/rmk/src/keyboard/sticky_key.rs index 9a3268a70..134b0ccba 100644 --- a/rmk/src/keyboard/sticky_key.rs +++ b/rmk/src/keyboard/sticky_key.rs @@ -115,7 +115,7 @@ impl Keyboard<'_> { // existing pure-mod latch, but REPLACES any other shape (layer or tap-key). // Releasing the foreign latch first deactivates a held layer and drops its mods // cleanly, so only a same-shape (pure-mod) latch can reach the accumulate arm below. - if self.sticky_key_state.is_active() && !self.sticky_key_state.is_pure_mod() { + if self.sticky_key_state.is_active() && !self.sticky_key_state.is_pure_mod() && !self.sticky_key_state.is_layer() { self.release_sticky_key_if_active().await; } match &mut self.sticky_key_state { @@ -182,21 +182,27 @@ impl Keyboard<'_> { // drop any latched mods/tap-key. A layer-on-layer press keeps the existing phase // (mirrors old `process_action_osl` lines 51-56); any other shape becomes a fresh // Pressed latch. - let prev_phase = match self.sticky_key_state { + let (prev_phase, existing_mods) = match self.sticky_key_state { StickyKeyState::Active { layer: Some(prev_layer), phase, + mods, .. } => { self.keymap.deactivate_layer(prev_layer); - phase + (phase, mods) } - _ => SkPhase::Pressed, + StickyKeyState::Active { + mods, + phase, + .. + } => (phase, mods), + _ => (SkPhase::Pressed, ModifierCombination::new()), }; self.keymap.activate_layer(layer_num); self.sticky_key_state = StickyKeyState::Active { - mods: params.keep, + mods: existing_mods | params.keep, key: params.key, layer: Some(layer_num), phase: prev_phase, @@ -343,6 +349,15 @@ impl Keyboard<'_> { *deadline = None; false } + StickyKeyState::Active { + phase: SkPhase::Latched, + layer: Some(layer_num), + .. + } if quick_release && event.pressed => { + self.keymap.deactivate_layer(*layer_num); + self.sticky_key_state = StickyKeyState::None; + true + } StickyKeyState::Active { phase: SkPhase::Latched, .. diff --git a/rmk/tests/keyboard_one_shot_test.rs b/rmk/tests/keyboard_one_shot_test.rs index 55f6f49d0..f22d55a2d 100644 --- a/rmk/tests/keyboard_one_shot_test.rs +++ b/rmk/tests/keyboard_one_shot_test.rs @@ -483,7 +483,7 @@ mod one_shot_test { [0, 2, false, 10], // Release key ], expected_reports: [ - [0, [kc_to_u8!(C), 0, 0, 0, 0, 0]], // C from layer 1 with LShift + [KC_LSHIFT, [kc_to_u8!(C), 0, 0, 0, 0, 0]], // C from layer 1 with LShift [0, [0, 0, 0, 0, 0, 0]], // All released ] }; @@ -496,13 +496,13 @@ mod one_shot_test { sequence: [ [0, 1, true, 10], // Press OSL Layer 1 [0, 1, false, 10], // Release OSL Layer 1 - [0, 0, true, 10], // Press OSM LShift (from layer 1, but No action) - [0, 0, false, 10], // Release OSM LShift (gets from layer 0 due to transparent) - [0, 2, true, 10], // Press key at (0,2), should get A from layer 0 with shift + ctrl + [0, 0, true, 10], // Press OSM LShift|LCtrl from layer 1 (layer 1 latched by previous OSL) + [0, 0, false, 10], // Release OSM LShift|LCtrl + [0, 2, true, 10], // Press key at (0,2), should get C from layer 1 with shift + ctrl [0, 2, false, 10], // Release key ], expected_reports: [ - [KC_LSHIFT | KC_LCTRL, [kc_to_u8!(A), 0, 0, 0, 0, 0]], // A from layer 0 with shift + ctrl + [KC_LSHIFT | KC_LCTRL, [kc_to_u8!(C), 0, 0, 0, 0, 0]], // C from layer 1 with LShift+LCtrl [0, [0, 0, 0, 0, 0, 0]], // All released ] }; From d573b64bdf107846abe4e1ccb4844bb897c5f1d4 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:32:48 -0500 Subject: [PATCH 077/119] style: apply CI nightly rustfmt to all changes --- rmk-macro/src/codegen/behavior.rs | 28 +- rmk/src/keyboard.rs | 6 +- rmk/src/keyboard/sticky_key.rs | 15 +- rmk/tests/keyboard_sticky_key_test.rs | 1109 ++++++++++++------------- 4 files changed, 587 insertions(+), 571 deletions(-) diff --git a/rmk-macro/src/codegen/behavior.rs b/rmk-macro/src/codegen/behavior.rs index a79560705..6c7c25d14 100644 --- a/rmk-macro/src/codegen/behavior.rs +++ b/rmk-macro/src/codegen/behavior.rs @@ -3,8 +3,10 @@ use std::collections::HashMap; use quote::quote; -use rmk_config::resolved::behavior::{Combos, Forks, MacroOperation, Macros, Morse, MorseActionPair, MorseKey, MorseProfile}; use rmk_config::resolved::Behavior; +use rmk_config::resolved::behavior::{ + Combos, Forks, MacroOperation, Macros, Morse, MorseActionPair, MorseKey, MorseProfile, +}; use super::action_parser::{expand_profile, expand_profile_name, get_key_with_alias, parse_key}; @@ -28,10 +30,26 @@ fn expand_sticky_key(behavior: &Behavior) -> proc_macro2::TokenStream { }, None => quote! { ::rmk::embassy_time::Duration::from_secs(1) }, }; - let activate_on_keypress = behavior.sticky_key.as_ref().and_then(|sk| sk.activate_on_keypress).unwrap_or(false); - let quick_release = behavior.sticky_key.as_ref().and_then(|sk| sk.quick_release).unwrap_or(false); - let max_repeat = behavior.sticky_key.as_ref().and_then(|sk| sk.max_repeat).unwrap_or(0); - let release_on_layer_change = behavior.sticky_key.as_ref().and_then(|sk| sk.release_on_layer_change).unwrap_or(false); + let activate_on_keypress = behavior + .sticky_key + .as_ref() + .and_then(|sk| sk.activate_on_keypress) + .unwrap_or(false); + let quick_release = behavior + .sticky_key + .as_ref() + .and_then(|sk| sk.quick_release) + .unwrap_or(false); + let max_repeat = behavior + .sticky_key + .as_ref() + .and_then(|sk| sk.max_repeat) + .unwrap_or(0); + let release_on_layer_change = behavior + .sticky_key + .as_ref() + .and_then(|sk| sk.release_on_layer_change) + .unwrap_or(false); quote! { ::rmk::config::StickyKeyConfig { diff --git a/rmk/src/keyboard.rs b/rmk/src/keyboard.rs index d7730b220..6a4375e35 100644 --- a/rmk/src/keyboard.rs +++ b/rmk/src/keyboard.rs @@ -156,15 +156,15 @@ impl Runnable for Keyboard<'_> { self.process_buffered_key(key).await } else { // Race subscriber against the nearest pending deadline. - let deadline = self.sticky_key_state + let deadline = self + .sticky_key_state .deadline() .into_iter() .chain(self.mouse.next_deadline()) .reduce(|a, b| a.min(b)); if let Some(deadline) = deadline { let event_result = - with_deadline(deadline, self.keyboard_event_subscriber.next_message_pure()) - .await; + with_deadline(deadline, self.keyboard_event_subscriber.next_message_pure()).await; match event_result { Ok(event) => { self.process_inner(event).await; diff --git a/rmk/src/keyboard/sticky_key.rs b/rmk/src/keyboard/sticky_key.rs index 134b0ccba..c6bf96641 100644 --- a/rmk/src/keyboard/sticky_key.rs +++ b/rmk/src/keyboard/sticky_key.rs @@ -115,7 +115,10 @@ impl Keyboard<'_> { // existing pure-mod latch, but REPLACES any other shape (layer or tap-key). // Releasing the foreign latch first deactivates a held layer and drops its mods // cleanly, so only a same-shape (pure-mod) latch can reach the accumulate arm below. - if self.sticky_key_state.is_active() && !self.sticky_key_state.is_pure_mod() && !self.sticky_key_state.is_layer() { + if self.sticky_key_state.is_active() + && !self.sticky_key_state.is_pure_mod() + && !self.sticky_key_state.is_layer() + { self.release_sticky_key_if_active().await; } match &mut self.sticky_key_state { @@ -192,11 +195,7 @@ impl Keyboard<'_> { self.keymap.deactivate_layer(prev_layer); (phase, mods) } - StickyKeyState::Active { - mods, - phase, - .. - } => (phase, mods), + StickyKeyState::Active { mods, phase, .. } => (phase, mods), _ => (SkPhase::Pressed, ModifierCombination::new()), }; @@ -395,7 +394,9 @@ impl Keyboard<'_> { .. } ) { - debug!("StickyKey timeout fired while key is still held — clearing deadline, deferring to physical release"); + debug!( + "StickyKey timeout fired while key is still held — clearing deadline, deferring to physical release" + ); if let StickyKeyState::Active { deadline, .. } = &mut self.sticky_key_state { *deadline = None; } diff --git a/rmk/tests/keyboard_sticky_key_test.rs b/rmk/tests/keyboard_sticky_key_test.rs index cae194f4b..37863a199 100644 --- a/rmk/tests/keyboard_sticky_key_test.rs +++ b/rmk/tests/keyboard_sticky_key_test.rs @@ -9,7 +9,6 @@ use rmk::{a, k, mo, sk, sk_layer, sk_mod}; use crate::common::{KC_LALT, KC_LCTRL, KC_LGUI, KC_LSHIFT, wrap_keymap}; - // KEYMAP (release_on_layer_change=true is set in the helper config, not per-key) // Layer 0: A B C MO(1) LShift No // Layer 1: SK(Tab,LAlt) SK(Tab,LCtrl) SK(Tab,LCtrl|LShift) Transparent Transparent No @@ -151,527 +150,525 @@ fn create_test_keyboard_with_behavior_config(config: BehaviorConfig) -> Keyboard } /// StickyKey Test 1: Basic SK flow — press SK twice while MO held - /// - /// Sequence: - /// - Press MO(1) → layer activates, no report - /// - Press SK(Tab,LAlt) → [KC_LALT, [Tab, ...]] - /// - Release SK → [KC_LALT, [0, ...]] (modifier held) - /// - Press SK again → [KC_LALT, [Tab, ...]] - /// - Release SK → [KC_LALT, [0, ...]] - /// - Release MO(1) → [0, [0, ...]] (layer deactivation cleans up SK) - #[test] - fn test_sk_basic_flow_press_twice() { - key_sequence_test! { - keyboard: create_test_keyboard(), - sequence: [ - [0, 3, true, 10], // Press MO(1) - [0, 0, true, 10], // Press SK(Tab, LAlt) - [0, 0, false, 10], // Release SK - [0, 0, true, 10], // Press SK again - [0, 0, false, 10], // Release SK - [0, 3, false, 10], // Release MO(1) - ], - expected_reports: [ - [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Alt+Tab - [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held - [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press again: Alt+Tab - [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held - [0, [0, 0, 0, 0, 0, 0]], // MO release: SK cleaned up - ] - }; - } - - /// StickyKey Test 2: Layer change cleanup (exit_on_layer_change=true) - /// - /// Sequence: - /// - Press MO(1), press SK(Tab,LAlt), release SK, release MO(1) - /// - /// Expected: - /// - SK press: Alt+Tab - /// - SK release: Alt held - /// - MO release: cleans up SK (exit_on_layer_change=true), sends [0, [0,...]] - #[test] - fn test_sk_layer_change_cleanup() { - key_sequence_test! { - keyboard: create_test_keyboard(), - sequence: [ - [0, 3, true, 10], // Press MO(1) - [0, 0, true, 10], // Press SK(Tab, LAlt) - [0, 0, false, 10], // Release SK - [0, 3, false, 10], // Release MO(1) → triggers SK cleanup (exit_on_layer_change=true) - ], - expected_reports: [ - [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Alt+Tab - [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held - [0, [0, 0, 0, 0, 0, 0]], // MO release: SK cleaned up - ] - }; - } - - /// StickyKey Test 3: Shift does NOT release SK - /// - /// Sequence: - /// - Press MO(1), press SK(Tab,LCtrl), release SK - /// - Press LShift (col 4, transparent → LShift) — should NOT release SK - /// - Press SK again, release SK - /// - Release LShift, release MO(1) - /// - /// Expected: - /// - SK press: Ctrl+Tab - /// - SK release: Ctrl held - /// - Shift press: Ctrl+Shift held (SK not released) - /// - SK press: Ctrl+Shift+Tab - /// - SK release: Ctrl+Shift held - /// - Shift release: Ctrl held - /// - MO release: SK cleaned up - #[test] - fn test_sk_shift_does_not_release_sk() { - key_sequence_test! { - keyboard: create_test_keyboard(), - sequence: [ - [0, 3, true, 10], // Press MO(1) - [0, 1, true, 10], // Press SK(Tab, LCtrl) - [0, 1, false, 10], // Release SK - [0, 4, true, 10], // Press LShift (Transparent → LShift on L0) - [0, 1, true, 10], // Press SK again - [0, 1, false, 10], // Release SK - [0, 4, false, 10], // Release LShift - [0, 3, false, 10], // Release MO(1) - ], - expected_reports: [ - [KC_LCTRL, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Ctrl+Tab - [KC_LCTRL, [0, 0, 0, 0, 0, 0]], // SK release: Ctrl held - [KC_LCTRL | KC_LSHIFT, [0, 0, 0, 0, 0, 0]], // Shift press: Ctrl+Shift (SK not released) - [KC_LCTRL | KC_LSHIFT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Ctrl+Shift+Tab - [KC_LCTRL | KC_LSHIFT, [0, 0, 0, 0, 0, 0]], // SK release: Ctrl+Shift held - [KC_LCTRL, [0, 0, 0, 0, 0, 0]], // Shift release: Ctrl held - [0, [0, 0, 0, 0, 0, 0]], // MO release: SK cleaned up - ] - }; - } - - - /// StickyKey Test 4: Rapid presses — 3x SK press/release while MO held - /// - /// Sequence: - /// - Press MO(1), then 3x (press SK, release SK), release MO(1) - /// - /// Expected: Each SK press sends Alt+Tab; each release holds Alt; MO release cleans up. - #[test] - fn test_sk_rapid_three_presses() { - key_sequence_test! { - keyboard: create_test_keyboard(), - sequence: [ - [0, 3, true, 10], // Press MO(1) - [0, 0, true, 10], // Press SK #1 - [0, 0, false, 10], // Release SK #1 - [0, 0, true, 10], // Press SK #2 - [0, 0, false, 10], // Release SK #2 - [0, 0, true, 10], // Press SK #3 - [0, 0, false, 10], // Release SK #3 - [0, 3, false, 10], // Release MO(1) - ], - expected_reports: [ - [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK #1 press - [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK #1 release - [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK #2 press - [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK #2 release - [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK #3 press - [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK #3 release - [0, [0, 0, 0, 0, 0, 0]], // MO release: SK cleaned up - ] - }; - } - - /// StickyKey Test 5: Combined modifiers LCtrl|LShift - /// - /// Sequence: - /// - Press MO(1), press SK(Tab,LCtrl|LShift) at col 2, release SK, release MO(1) - /// - /// Expected: - /// - SK press: Ctrl+Shift+Tab - /// - SK release: Ctrl+Shift held - /// - MO release: SK cleaned up - #[test] - fn test_sk_combined_modifiers() { - key_sequence_test! { - keyboard: create_test_keyboard(), - sequence: [ - [0, 3, true, 10], // Press MO(1) - [0, 2, true, 10], // Press SK(Tab, LCtrl|LShift) - [0, 2, false, 10], // Release SK - [0, 3, false, 10], // Release MO(1) - ], - expected_reports: [ - [KC_LCTRL | KC_LSHIFT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Ctrl+Shift+Tab - [KC_LCTRL | KC_LSHIFT, [0, 0, 0, 0, 0, 0]], // SK release: Ctrl+Shift held - [0, [0, 0, 0, 0, 0, 0]], // MO release: SK cleaned up - ] - }; - } - - - /// StickyKey Test 6: Timeout — modifier auto-releases after inactivity - /// - /// Config: global timeout = 100ms - /// - /// Sequence: - /// - Press MO(1), press SK(Tab,LAlt), release SK → timer starts (100ms) - /// - Wait 150ms → timer fires, Alt auto-released - /// - Release MO(1) (SK already inactive — no cleanup report) - /// - Press C on layer 0 (no modifier), release C - /// - /// Note: MO(1) must be released before pressing the verification key so that - /// col 2 resolves to k!(C) on layer 0 rather than SK(Tab,LCtrl|LShift) on layer 1. - #[test] - fn test_sk_timeout() { - key_sequence_test! { - keyboard: create_test_keyboard_with_behavior_config(BehaviorConfig { - sticky_key: StickyKeyConfig { - timeout: Duration::from_millis(100), - release_on_layer_change: true, - ..StickyKeyConfig::default() - }, - ..BehaviorConfig::default() - }), - sequence: [ - [0, 3, true, 10], // Press MO(1) - [0, 0, true, 10], // Press SK(Tab, LAlt) - [0, 0, false, 10], // Release SK → timer starts (100ms) - [0, 3, false, 150], // Wait 150ms (timer fires!), then release MO(1) - [0, 2, true, 10], // Press C on layer 0 (no modifier) - [0, 2, false, 10], // Release C - ], - expected_reports: [ - [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Alt+Tab - [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held, timer starts - [0, [0, 0, 0, 0, 0, 0]], // Timeout: Alt auto-released - // MO(1) release: SK already inactive, no report - [0, [kc_to_u8!(C), 0, 0, 0, 0, 0]], // C press: no modifier - [0, [0, 0, 0, 0, 0, 0]], // C release - ] - }; - } - - /// StickyKey Test 7: Timeout resets on each SK press - /// - /// Config: global timeout = 100ms - /// - /// Sequence: - /// - Press MO(1), press SK #1, release SK #1 → T1 starts (100ms) - /// - At 50ms: press SK #2 → T1 cancelled, SK #2 processed from unprocessed queue - /// - Release SK #2 → T2 starts (100ms reset) - /// - Wait 150ms → T2 fires, Alt auto-released - /// - Release MO(1) (SK already inactive — no cleanup report) - /// - Press C on layer 0 (no modifier), release C - #[test] - fn test_sk_timeout_resets_on_press() { - key_sequence_test! { - keyboard: create_test_keyboard_with_behavior_config(BehaviorConfig { - sticky_key: StickyKeyConfig { - timeout: Duration::from_millis(100), - release_on_layer_change: true, - ..StickyKeyConfig::default() - }, - ..BehaviorConfig::default() - }), - sequence: [ - [0, 3, true, 10], // Press MO(1) - [0, 0, true, 10], // Press SK #1 - [0, 0, false, 10], // Release SK #1 → T1 starts (100ms) - [0, 0, true, 50], // At 50ms: press SK #2 → T1 cancelled - [0, 0, false, 10], // Release SK #2 → T2 starts (100ms reset) - [0, 3, false, 150], // Wait 150ms (T2 fires!), then release MO(1) - [0, 2, true, 10], // Press C on layer 0 (no modifier) - [0, 2, false, 10], // Release C - ], - expected_reports: [ - [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK #1 press: Alt+Tab - [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK #1 release: Alt held (T1 starts) - [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK #2 press: Alt+Tab (T1 cancelled) - [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK #2 release: Alt held (T2 starts) - [0, [0, 0, 0, 0, 0, 0]], // T2 fires: Alt auto-released - // MO(1) release: SK already inactive, no report - [0, [kc_to_u8!(C), 0, 0, 0, 0, 0]], // C press: no modifier - [0, [0, 0, 0, 0, 0, 0]], // C release - ] - }; - } - - /// StickyKey Test 8: max_repeat — SK releases after N presses - /// - /// Config: KEYMAP_MAX_REPEAT, SK at col 0 has max_repeat=2 - /// - /// Sequence: - /// - Press MO(1), press SK ×3, release MO(1) - /// - /// Expected: - /// - Press 1: fire (Alt+Tab, Alt held) - /// - Press 2: fire (Alt+Tab, Alt held) — this is the max_repeat=2 press - /// - Press 3: max_repeat reached, SK deactivates silently (no new report beyond empty) - #[test] - fn test_sk_max_repeat() { - key_sequence_test! { - keyboard: create_test_keyboard_max_repeat(), - sequence: [ - [0, 3, true, 10], // Press MO(1) - [0, 0, true, 10], // Press SK #1 - [0, 0, false, 10], // Release SK #1 - [0, 0, true, 10], // Press SK #2 - [0, 0, false, 10], // Release SK #2 - [0, 0, true, 10], // Press SK #3 → max_repeat reached, deactivate - [0, 0, false, 10], // Release SK #3 - [0, 3, false, 10], // Release MO(1) - [0, 0, true, 10], // Press A on layer 0 — SK deactivated, no modifier - [0, 0, false, 10], // Release A - ], - expected_reports: [ - [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK #1 press: Alt+Tab - [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK #1 release: Alt held - [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK #2 press: Alt+Tab - [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK #2 release: Alt held - [0, [0, 0, 0, 0, 0, 0]], // SK #3: max_repeat reached, SK deactivated - [0, [kc_to_u8!(A), 0, 0, 0, 0, 0]], // A press: no modifier (SK deactivated cleanly) - [0, [0, 0, 0, 0, 0, 0]], // A release - ] - }; - } - - // per-key timeout removed this round (deferred, spec Section 4); see parity catalogue - - /// StickyKey Test 10: exit_on_layer_change=true — SK exits on MO release - /// - /// This is the same as Test 2 — verifying the explicit exit_on_layer_change=true - /// setting (the default KEYMAP uses exit=true). - /// - /// Sequence: MO↓ SK(exit=true)↓ SK↑ MO↑ - /// Expected: Alt+Tab, Alt, empty. - #[test] - fn test_sk_exits_on_layer_change() { - key_sequence_test! { - keyboard: create_test_keyboard(), - sequence: [ - [0, 3, true, 10], // Press MO(1) - [0, 0, true, 10], // Press SK(Tab, LAlt, exit=true) - [0, 0, false, 10], // Release SK - [0, 3, false, 10], // Release MO(1) → SK exits (exit_on_layer_change=true) - ], - expected_reports: [ - [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Alt+Tab - [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held - [0, [0, 0, 0, 0, 0, 0]], // MO release: SK exits - ] - }; - } - - /// StickyKey Test 11: exit_on_layer_change=false — SK survives layer change - /// - /// Config: KEYMAP_NO_EXIT (exit_on_layer_change=false) - /// - /// Sequence: - /// - Press MO(1), press SK(exit=false), release SK - /// - Release MO(1) — SK does NOT exit (exit_on_layer_change=false) - /// - Press A on layer 0 — A press releases SK first, then sends A - /// - Release A - /// - /// Expected: - /// - SK press: Alt+Tab - /// - SK release: Alt held - /// - (MO release: no report — SK still active) - /// - A press: SK released first → [0, [0, ...]], then A registered → [0, [A, ...]] - /// - A release: [0, [0, ...]] - #[test] - fn test_sk_survives_layer_change() { - key_sequence_test! { - keyboard: create_test_keyboard_no_exit(), - sequence: [ - [0, 3, true, 10], // Press MO(1) - [0, 0, true, 10], // Press SK(Tab, LAlt, exit=false) - [0, 0, false, 10], // Release SK - [0, 3, false, 10], // Release MO(1) — SK does NOT exit - [0, 0, true, 10], // Press A on layer 0 — releases SK, sends A - [0, 0, false, 10], // Release A - ], - expected_reports: [ - [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Alt+Tab - [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held (SK still active after MO release) - [0, [0, 0, 0, 0, 0, 0]], // A press: SK release report (Alt released) - [0, [kc_to_u8!(A), 0, 0, 0, 0, 0]], // A press: A registered - [0, [0, 0, 0, 0, 0, 0]], // A release - ] - }; - } - - /// StickyKey Test 3b (regression): pure-mod SK applies its modifier THROUGH the - /// terminating key, then clears. Mirrors `test_osm_basic_single_behavior` via the - /// unified SK engine. Pins the absorbed OSM terminating-key behavior. - /// - /// Sequence: tap SK(LGui) (col 0), tap P (col 3) - /// Expected: P with LGui, then all released. - #[test] - fn test_sk_puremod_terminating_key() { - key_sequence_test! { - keyboard: create_test_keyboard_puremod(), - sequence: [ - [0, 0, true, 10], // Press SK(LGui) - [0, 0, false, 10], // Release SK(LGui) - [0, 3, true, 10], // Press P - [0, 3, false, 10], // Release P - ], - expected_reports: [ - [KC_LGUI, [kc_to_u8!(P), 0, 0, 0, 0, 0]], // P with LGui - [0, [0, 0, 0, 0, 0, 0]], // All released - ] - }; - } - - /// StickyKey Test 3c (regression): two pure-mod SK taps accumulate onto one - /// terminating key. Mirrors `test_osm_combined_modifiers` via the SK engine. - /// - /// Sequence: tap SK(LCtrl) (col 1), tap SK(LShift) (col 2), tap P (col 3) - /// Expected: P with LCtrl|LShift, then all released. - #[test] - fn test_sk_puremod_cross_tap_accumulation() { - key_sequence_test! { - keyboard: create_test_keyboard_puremod(), - sequence: [ - [0, 1, true, 10], // Press SK(LCtrl) - [0, 1, false, 10], // Release SK(LCtrl) - [0, 2, true, 10], // Press SK(LShift) - [0, 2, false, 10], // Release SK(LShift) - [0, 3, true, 10], // Press P - [0, 3, false, 10], // Release P - ], - expected_reports: [ - [KC_LCTRL | KC_LSHIFT, [kc_to_u8!(P), 0, 0, 0, 0, 0]], // P with LCtrl|LShift - [0, [0, 0, 0, 0, 0, 0]], // All released - ] - }; - } - - /// StickyKey Test 12 (regression): a tap-key SK pressed while a PURE-MOD SK is latched - /// REPLACES it — the latch is mutually exclusive, so the old modifier is dropped, not - /// merged. Without the replacement guard the tap-key press would OR the pure-mod's LGui - /// onto the report, yielding LGui+LAlt+Tab instead of just LAlt+Tab. - /// - /// Sequence: tap SK(LGui) (col 0), press/release SK(Tab,LAlt) (col 1) - /// Expected: LAlt+Tab (LGui dropped), then LAlt held. - #[test] - fn test_sk_tap_key_replaces_pure_mod() { - key_sequence_test! { - keyboard: create_test_keyboard_mixed(), - sequence: [ - [0, 0, true, 10], // Press SK(LGui) - [0, 0, false, 10], // Release SK(LGui) → pure-mod latched (no report) - [0, 1, true, 10], // Press SK(Tab, LAlt) → replaces pure-mod - [0, 1, false, 10], // Release SK - ], - expected_reports: [ - [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // tap-key press: LAlt+Tab (LGui dropped) - [KC_LALT, [0, 0, 0, 0, 0, 0]], // tap-key release: LAlt held - ] - }; - } - - /// StickyKey Test 13 (regression): a pure-mod SK pressed while a TAP-KEY SK is latched - /// REPLACES it. The tap-key's held LAlt is released (its own report) and the next basic - /// key gets the new pure-mod's LGui applied through it — OSM terminating-key behavior — - /// not the stale LAlt. Without the guard the pure-mod's LGui would merge onto the tap-key - /// latch, leaving the shape as tap-key and applying LAlt+LGui. - /// - /// Sequence: press/release SK(Tab,LAlt) (col 1), tap SK(LGui) (col 0), tap P (col 3) - /// Expected: LAlt+Tab, LAlt held, LAlt released, LGui+P, all released. - #[test] - fn test_sk_pure_mod_replaces_tap_key() { - key_sequence_test! { - keyboard: create_test_keyboard_mixed(), - sequence: [ - [0, 1, true, 10], // Press SK(Tab, LAlt) - [0, 1, false, 10], // Release SK → tap-key latched (LAlt held) - [0, 0, true, 10], // Press SK(LGui) → replaces tap-key (drops LAlt) - [0, 0, false, 10], // Release SK(LGui) → pure-mod latched - [0, 3, true, 10], // Press P → LGui applied through it - [0, 3, false, 10], // Release P - ], - expected_reports: [ - [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // tap-key press: LAlt+Tab - [KC_LALT, [0, 0, 0, 0, 0, 0]], // tap-key release: LAlt held - [0, [0, 0, 0, 0, 0, 0]], // pure-mod press: tap-key released (LAlt dropped) - [KC_LGUI, [kc_to_u8!(P), 0, 0, 0, 0, 0]], // P with LGui (terminating key) - [0, [0, 0, 0, 0, 0, 0]], // P release: all clear - ] - }; - } - - /// StickyKey Test 14 (regression): a tap-key SK pressed while a LAYER SK is latched - /// REPLACES it — the orphaned-layer bug. The latched layer must be deactivated, so the - /// later basic key resolves on layer 0 (P), not the leaked layer 1 (Z). Without the guard - /// the tap-key press would bump the layer latch's repeat_count, leaving layer 1 active - /// forever and sending the key with no modifier. - /// - /// Sequence: press/release SK(MO(1)) (col 2), press/release SK(Tab,LAlt) (col 1), tap P (col 3) - /// Expected: LAlt+Tab, LAlt held, then P resolves on LAYER 0 (the tap-key early-releases - /// its LAlt before the foreign key, per the tap-key terminating-key rule, so P is sent - /// clean) — crucially P, not the leaked layer-1 Z. - #[test] - fn test_sk_tap_key_replaces_layer() { - key_sequence_test! { - keyboard: create_test_keyboard_mixed(), - sequence: [ - [0, 2, true, 10], // Press SK(MO(1)) → layer 1 active - [0, 2, false, 10], // Release SK → layer latched - [0, 1, true, 10], // Press SK(Tab, LAlt) (col 1 Trns → layer-0 tap-key) → replaces layer - [0, 1, false, 10], // Release SK → tap-key latched (LAlt held) - [0, 3, true, 10], // Press col 3 → resolves to P on layer 0 (layer 1 deactivated) - [0, 3, false, 10], // Release - ], - expected_reports: [ - [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // tap-key press: LAlt+Tab (layer dropped, no report) - [KC_LALT, [0, 0, 0, 0, 0, 0]], // tap-key release: LAlt held - [0, [0, 0, 0, 0, 0, 0]], // P press: tap-key early-releases LAlt - [0, [kc_to_u8!(P), 0, 0, 0, 0, 0]], // P sent clean on layer 0 (NOT Z) — layer 1 gone - [0, [0, 0, 0, 0, 0, 0]], // P release - ] - }; - } - - /// StickyKey Test 15: `activate_on_keypress` is IGNORED for tap-key SKs. - /// - /// Docs: `activate_on_keypress` is "honored only for pure-mod SKs" and is - /// "silently ignored for tap-key SKs". A tap-key already sends its modifier - /// eagerly on the first press, so the flag has nothing to tune. With - /// activate_on_keypress=true the report stream must be identical to the - /// default tap-key flow (cf. test_sk_basic_flow_press_twice). - #[test] - fn test_sk_tap_key_ignores_activate_on_keypress() { - key_sequence_test! { - keyboard: create_test_keyboard_with_behavior_config(BehaviorConfig { - sticky_key: StickyKeyConfig { - activate_on_keypress: true, // pure-mod-only knob — must be ignored here - release_on_layer_change: true, // match create_test_keyboard so MO release cleans up - ..StickyKeyConfig::default() - }, - ..BehaviorConfig::default() - }), - sequence: [ - [0, 3, true, 10], // Press MO(1) - [0, 0, true, 10], // Press SK(Tab, LAlt) - [0, 0, false, 10], // Release SK - [0, 0, true, 10], // Press SK again - [0, 0, false, 10], // Release SK - [0, 3, false, 10], // Release MO(1) - ], - expected_reports: [ - [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Alt+Tab - [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held - [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press again: Alt+Tab - [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held - [0, [0, 0, 0, 0, 0, 0]], // MO release: SK cleaned up - ] - }; - } - - /// KEYMAP_PUREMOD_SK: pure-mod SK at col 4, basic keys at cols 0-2, for testing "timeout while held". +/// +/// Sequence: +/// - Press MO(1) → layer activates, no report +/// - Press SK(Tab,LAlt) → [KC_LALT, [Tab, ...]] +/// - Release SK → [KC_LALT, [0, ...]] (modifier held) +/// - Press SK again → [KC_LALT, [Tab, ...]] +/// - Release SK → [KC_LALT, [0, ...]] +/// - Release MO(1) → [0, [0, ...]] (layer deactivation cleans up SK) +#[test] +fn test_sk_basic_flow_press_twice() { + key_sequence_test! { + keyboard: create_test_keyboard(), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SK(Tab, LAlt) + [0, 0, false, 10], // Release SK + [0, 0, true, 10], // Press SK again + [0, 0, false, 10], // Release SK + [0, 3, false, 10], // Release MO(1) + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press again: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held + [0, [0, 0, 0, 0, 0, 0]], // MO release: SK cleaned up + ] + }; +} + +/// StickyKey Test 2: Layer change cleanup (exit_on_layer_change=true) +/// +/// Sequence: +/// - Press MO(1), press SK(Tab,LAlt), release SK, release MO(1) +/// +/// Expected: +/// - SK press: Alt+Tab +/// - SK release: Alt held +/// - MO release: cleans up SK (exit_on_layer_change=true), sends [0, [0,...]] +#[test] +fn test_sk_layer_change_cleanup() { + key_sequence_test! { + keyboard: create_test_keyboard(), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SK(Tab, LAlt) + [0, 0, false, 10], // Release SK + [0, 3, false, 10], // Release MO(1) → triggers SK cleanup (exit_on_layer_change=true) + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held + [0, [0, 0, 0, 0, 0, 0]], // MO release: SK cleaned up + ] + }; +} + +/// StickyKey Test 3: Shift does NOT release SK +/// +/// Sequence: +/// - Press MO(1), press SK(Tab,LCtrl), release SK +/// - Press LShift (col 4, transparent → LShift) — should NOT release SK +/// - Press SK again, release SK +/// - Release LShift, release MO(1) +/// +/// Expected: +/// - SK press: Ctrl+Tab +/// - SK release: Ctrl held +/// - Shift press: Ctrl+Shift held (SK not released) +/// - SK press: Ctrl+Shift+Tab +/// - SK release: Ctrl+Shift held +/// - Shift release: Ctrl held +/// - MO release: SK cleaned up +#[test] +fn test_sk_shift_does_not_release_sk() { + key_sequence_test! { + keyboard: create_test_keyboard(), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 1, true, 10], // Press SK(Tab, LCtrl) + [0, 1, false, 10], // Release SK + [0, 4, true, 10], // Press LShift (Transparent → LShift on L0) + [0, 1, true, 10], // Press SK again + [0, 1, false, 10], // Release SK + [0, 4, false, 10], // Release LShift + [0, 3, false, 10], // Release MO(1) + ], + expected_reports: [ + [KC_LCTRL, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Ctrl+Tab + [KC_LCTRL, [0, 0, 0, 0, 0, 0]], // SK release: Ctrl held + [KC_LCTRL | KC_LSHIFT, [0, 0, 0, 0, 0, 0]], // Shift press: Ctrl+Shift (SK not released) + [KC_LCTRL | KC_LSHIFT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Ctrl+Shift+Tab + [KC_LCTRL | KC_LSHIFT, [0, 0, 0, 0, 0, 0]], // SK release: Ctrl+Shift held + [KC_LCTRL, [0, 0, 0, 0, 0, 0]], // Shift release: Ctrl held + [0, [0, 0, 0, 0, 0, 0]], // MO release: SK cleaned up + ] + }; +} + +/// StickyKey Test 4: Rapid presses — 3x SK press/release while MO held +/// +/// Sequence: +/// - Press MO(1), then 3x (press SK, release SK), release MO(1) +/// +/// Expected: Each SK press sends Alt+Tab; each release holds Alt; MO release cleans up. +#[test] +fn test_sk_rapid_three_presses() { + key_sequence_test! { + keyboard: create_test_keyboard(), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SK #1 + [0, 0, false, 10], // Release SK #1 + [0, 0, true, 10], // Press SK #2 + [0, 0, false, 10], // Release SK #2 + [0, 0, true, 10], // Press SK #3 + [0, 0, false, 10], // Release SK #3 + [0, 3, false, 10], // Release MO(1) + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK #1 press + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK #1 release + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK #2 press + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK #2 release + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK #3 press + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK #3 release + [0, [0, 0, 0, 0, 0, 0]], // MO release: SK cleaned up + ] + }; +} + +/// StickyKey Test 5: Combined modifiers LCtrl|LShift +/// +/// Sequence: +/// - Press MO(1), press SK(Tab,LCtrl|LShift) at col 2, release SK, release MO(1) +/// +/// Expected: +/// - SK press: Ctrl+Shift+Tab +/// - SK release: Ctrl+Shift held +/// - MO release: SK cleaned up +#[test] +fn test_sk_combined_modifiers() { + key_sequence_test! { + keyboard: create_test_keyboard(), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 2, true, 10], // Press SK(Tab, LCtrl|LShift) + [0, 2, false, 10], // Release SK + [0, 3, false, 10], // Release MO(1) + ], + expected_reports: [ + [KC_LCTRL | KC_LSHIFT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Ctrl+Shift+Tab + [KC_LCTRL | KC_LSHIFT, [0, 0, 0, 0, 0, 0]], // SK release: Ctrl+Shift held + [0, [0, 0, 0, 0, 0, 0]], // MO release: SK cleaned up + ] + }; +} + +/// StickyKey Test 6: Timeout — modifier auto-releases after inactivity +/// +/// Config: global timeout = 100ms +/// +/// Sequence: +/// - Press MO(1), press SK(Tab,LAlt), release SK → timer starts (100ms) +/// - Wait 150ms → timer fires, Alt auto-released +/// - Release MO(1) (SK already inactive — no cleanup report) +/// - Press C on layer 0 (no modifier), release C +/// +/// Note: MO(1) must be released before pressing the verification key so that +/// col 2 resolves to k!(C) on layer 0 rather than SK(Tab,LCtrl|LShift) on layer 1. +#[test] +fn test_sk_timeout() { + key_sequence_test! { + keyboard: create_test_keyboard_with_behavior_config(BehaviorConfig { + sticky_key: StickyKeyConfig { + timeout: Duration::from_millis(100), + release_on_layer_change: true, + ..StickyKeyConfig::default() + }, + ..BehaviorConfig::default() + }), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SK(Tab, LAlt) + [0, 0, false, 10], // Release SK → timer starts (100ms) + [0, 3, false, 150], // Wait 150ms (timer fires!), then release MO(1) + [0, 2, true, 10], // Press C on layer 0 (no modifier) + [0, 2, false, 10], // Release C + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held, timer starts + [0, [0, 0, 0, 0, 0, 0]], // Timeout: Alt auto-released + // MO(1) release: SK already inactive, no report + [0, [kc_to_u8!(C), 0, 0, 0, 0, 0]], // C press: no modifier + [0, [0, 0, 0, 0, 0, 0]], // C release + ] + }; +} + +/// StickyKey Test 7: Timeout resets on each SK press +/// +/// Config: global timeout = 100ms +/// +/// Sequence: +/// - Press MO(1), press SK #1, release SK #1 → T1 starts (100ms) +/// - At 50ms: press SK #2 → T1 cancelled, SK #2 processed from unprocessed queue +/// - Release SK #2 → T2 starts (100ms reset) +/// - Wait 150ms → T2 fires, Alt auto-released +/// - Release MO(1) (SK already inactive — no cleanup report) +/// - Press C on layer 0 (no modifier), release C +#[test] +fn test_sk_timeout_resets_on_press() { + key_sequence_test! { + keyboard: create_test_keyboard_with_behavior_config(BehaviorConfig { + sticky_key: StickyKeyConfig { + timeout: Duration::from_millis(100), + release_on_layer_change: true, + ..StickyKeyConfig::default() + }, + ..BehaviorConfig::default() + }), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SK #1 + [0, 0, false, 10], // Release SK #1 → T1 starts (100ms) + [0, 0, true, 50], // At 50ms: press SK #2 → T1 cancelled + [0, 0, false, 10], // Release SK #2 → T2 starts (100ms reset) + [0, 3, false, 150], // Wait 150ms (T2 fires!), then release MO(1) + [0, 2, true, 10], // Press C on layer 0 (no modifier) + [0, 2, false, 10], // Release C + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK #1 press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK #1 release: Alt held (T1 starts) + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK #2 press: Alt+Tab (T1 cancelled) + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK #2 release: Alt held (T2 starts) + [0, [0, 0, 0, 0, 0, 0]], // T2 fires: Alt auto-released + // MO(1) release: SK already inactive, no report + [0, [kc_to_u8!(C), 0, 0, 0, 0, 0]], // C press: no modifier + [0, [0, 0, 0, 0, 0, 0]], // C release + ] + }; +} + +/// StickyKey Test 8: max_repeat — SK releases after N presses +/// +/// Config: KEYMAP_MAX_REPEAT, SK at col 0 has max_repeat=2 +/// +/// Sequence: +/// - Press MO(1), press SK ×3, release MO(1) +/// +/// Expected: +/// - Press 1: fire (Alt+Tab, Alt held) +/// - Press 2: fire (Alt+Tab, Alt held) — this is the max_repeat=2 press +/// - Press 3: max_repeat reached, SK deactivates silently (no new report beyond empty) +#[test] +fn test_sk_max_repeat() { + key_sequence_test! { + keyboard: create_test_keyboard_max_repeat(), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SK #1 + [0, 0, false, 10], // Release SK #1 + [0, 0, true, 10], // Press SK #2 + [0, 0, false, 10], // Release SK #2 + [0, 0, true, 10], // Press SK #3 → max_repeat reached, deactivate + [0, 0, false, 10], // Release SK #3 + [0, 3, false, 10], // Release MO(1) + [0, 0, true, 10], // Press A on layer 0 — SK deactivated, no modifier + [0, 0, false, 10], // Release A + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK #1 press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK #1 release: Alt held + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK #2 press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK #2 release: Alt held + [0, [0, 0, 0, 0, 0, 0]], // SK #3: max_repeat reached, SK deactivated + [0, [kc_to_u8!(A), 0, 0, 0, 0, 0]], // A press: no modifier (SK deactivated cleanly) + [0, [0, 0, 0, 0, 0, 0]], // A release + ] + }; +} + +// per-key timeout removed this round (deferred, spec Section 4); see parity catalogue + +/// StickyKey Test 10: exit_on_layer_change=true — SK exits on MO release +/// +/// This is the same as Test 2 — verifying the explicit exit_on_layer_change=true +/// setting (the default KEYMAP uses exit=true). +/// +/// Sequence: MO↓ SK(exit=true)↓ SK↑ MO↑ +/// Expected: Alt+Tab, Alt, empty. +#[test] +fn test_sk_exits_on_layer_change() { + key_sequence_test! { + keyboard: create_test_keyboard(), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SK(Tab, LAlt, exit=true) + [0, 0, false, 10], // Release SK + [0, 3, false, 10], // Release MO(1) → SK exits (exit_on_layer_change=true) + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held + [0, [0, 0, 0, 0, 0, 0]], // MO release: SK exits + ] + }; +} + +/// StickyKey Test 11: exit_on_layer_change=false — SK survives layer change +/// +/// Config: KEYMAP_NO_EXIT (exit_on_layer_change=false) +/// +/// Sequence: +/// - Press MO(1), press SK(exit=false), release SK +/// - Release MO(1) — SK does NOT exit (exit_on_layer_change=false) +/// - Press A on layer 0 — A press releases SK first, then sends A +/// - Release A +/// +/// Expected: +/// - SK press: Alt+Tab +/// - SK release: Alt held +/// - (MO release: no report — SK still active) +/// - A press: SK released first → [0, [0, ...]], then A registered → [0, [A, ...]] +/// - A release: [0, [0, ...]] +#[test] +fn test_sk_survives_layer_change() { + key_sequence_test! { + keyboard: create_test_keyboard_no_exit(), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SK(Tab, LAlt, exit=false) + [0, 0, false, 10], // Release SK + [0, 3, false, 10], // Release MO(1) — SK does NOT exit + [0, 0, true, 10], // Press A on layer 0 — releases SK, sends A + [0, 0, false, 10], // Release A + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held (SK still active after MO release) + [0, [0, 0, 0, 0, 0, 0]], // A press: SK release report (Alt released) + [0, [kc_to_u8!(A), 0, 0, 0, 0, 0]], // A press: A registered + [0, [0, 0, 0, 0, 0, 0]], // A release + ] + }; +} + +/// StickyKey Test 3b (regression): pure-mod SK applies its modifier THROUGH the +/// terminating key, then clears. Mirrors `test_osm_basic_single_behavior` via the +/// unified SK engine. Pins the absorbed OSM terminating-key behavior. +/// +/// Sequence: tap SK(LGui) (col 0), tap P (col 3) +/// Expected: P with LGui, then all released. +#[test] +fn test_sk_puremod_terminating_key() { + key_sequence_test! { + keyboard: create_test_keyboard_puremod(), + sequence: [ + [0, 0, true, 10], // Press SK(LGui) + [0, 0, false, 10], // Release SK(LGui) + [0, 3, true, 10], // Press P + [0, 3, false, 10], // Release P + ], + expected_reports: [ + [KC_LGUI, [kc_to_u8!(P), 0, 0, 0, 0, 0]], // P with LGui + [0, [0, 0, 0, 0, 0, 0]], // All released + ] + }; +} + +/// StickyKey Test 3c (regression): two pure-mod SK taps accumulate onto one +/// terminating key. Mirrors `test_osm_combined_modifiers` via the SK engine. +/// +/// Sequence: tap SK(LCtrl) (col 1), tap SK(LShift) (col 2), tap P (col 3) +/// Expected: P with LCtrl|LShift, then all released. +#[test] +fn test_sk_puremod_cross_tap_accumulation() { + key_sequence_test! { + keyboard: create_test_keyboard_puremod(), + sequence: [ + [0, 1, true, 10], // Press SK(LCtrl) + [0, 1, false, 10], // Release SK(LCtrl) + [0, 2, true, 10], // Press SK(LShift) + [0, 2, false, 10], // Release SK(LShift) + [0, 3, true, 10], // Press P + [0, 3, false, 10], // Release P + ], + expected_reports: [ + [KC_LCTRL | KC_LSHIFT, [kc_to_u8!(P), 0, 0, 0, 0, 0]], // P with LCtrl|LShift + [0, [0, 0, 0, 0, 0, 0]], // All released + ] + }; +} + +/// StickyKey Test 12 (regression): a tap-key SK pressed while a PURE-MOD SK is latched +/// REPLACES it — the latch is mutually exclusive, so the old modifier is dropped, not +/// merged. Without the replacement guard the tap-key press would OR the pure-mod's LGui +/// onto the report, yielding LGui+LAlt+Tab instead of just LAlt+Tab. +/// +/// Sequence: tap SK(LGui) (col 0), press/release SK(Tab,LAlt) (col 1) +/// Expected: LAlt+Tab (LGui dropped), then LAlt held. +#[test] +fn test_sk_tap_key_replaces_pure_mod() { + key_sequence_test! { + keyboard: create_test_keyboard_mixed(), + sequence: [ + [0, 0, true, 10], // Press SK(LGui) + [0, 0, false, 10], // Release SK(LGui) → pure-mod latched (no report) + [0, 1, true, 10], // Press SK(Tab, LAlt) → replaces pure-mod + [0, 1, false, 10], // Release SK + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // tap-key press: LAlt+Tab (LGui dropped) + [KC_LALT, [0, 0, 0, 0, 0, 0]], // tap-key release: LAlt held + ] + }; +} + +/// StickyKey Test 13 (regression): a pure-mod SK pressed while a TAP-KEY SK is latched +/// REPLACES it. The tap-key's held LAlt is released (its own report) and the next basic +/// key gets the new pure-mod's LGui applied through it — OSM terminating-key behavior — +/// not the stale LAlt. Without the guard the pure-mod's LGui would merge onto the tap-key +/// latch, leaving the shape as tap-key and applying LAlt+LGui. +/// +/// Sequence: press/release SK(Tab,LAlt) (col 1), tap SK(LGui) (col 0), tap P (col 3) +/// Expected: LAlt+Tab, LAlt held, LAlt released, LGui+P, all released. +#[test] +fn test_sk_pure_mod_replaces_tap_key() { + key_sequence_test! { + keyboard: create_test_keyboard_mixed(), + sequence: [ + [0, 1, true, 10], // Press SK(Tab, LAlt) + [0, 1, false, 10], // Release SK → tap-key latched (LAlt held) + [0, 0, true, 10], // Press SK(LGui) → replaces tap-key (drops LAlt) + [0, 0, false, 10], // Release SK(LGui) → pure-mod latched + [0, 3, true, 10], // Press P → LGui applied through it + [0, 3, false, 10], // Release P + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // tap-key press: LAlt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // tap-key release: LAlt held + [0, [0, 0, 0, 0, 0, 0]], // pure-mod press: tap-key released (LAlt dropped) + [KC_LGUI, [kc_to_u8!(P), 0, 0, 0, 0, 0]], // P with LGui (terminating key) + [0, [0, 0, 0, 0, 0, 0]], // P release: all clear + ] + }; +} + +/// StickyKey Test 14 (regression): a tap-key SK pressed while a LAYER SK is latched +/// REPLACES it — the orphaned-layer bug. The latched layer must be deactivated, so the +/// later basic key resolves on layer 0 (P), not the leaked layer 1 (Z). Without the guard +/// the tap-key press would bump the layer latch's repeat_count, leaving layer 1 active +/// forever and sending the key with no modifier. +/// +/// Sequence: press/release SK(MO(1)) (col 2), press/release SK(Tab,LAlt) (col 1), tap P (col 3) +/// Expected: LAlt+Tab, LAlt held, then P resolves on LAYER 0 (the tap-key early-releases +/// its LAlt before the foreign key, per the tap-key terminating-key rule, so P is sent +/// clean) — crucially P, not the leaked layer-1 Z. +#[test] +fn test_sk_tap_key_replaces_layer() { + key_sequence_test! { + keyboard: create_test_keyboard_mixed(), + sequence: [ + [0, 2, true, 10], // Press SK(MO(1)) → layer 1 active + [0, 2, false, 10], // Release SK → layer latched + [0, 1, true, 10], // Press SK(Tab, LAlt) (col 1 Trns → layer-0 tap-key) → replaces layer + [0, 1, false, 10], // Release SK → tap-key latched (LAlt held) + [0, 3, true, 10], // Press col 3 → resolves to P on layer 0 (layer 1 deactivated) + [0, 3, false, 10], // Release + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // tap-key press: LAlt+Tab (layer dropped, no report) + [KC_LALT, [0, 0, 0, 0, 0, 0]], // tap-key release: LAlt held + [0, [0, 0, 0, 0, 0, 0]], // P press: tap-key early-releases LAlt + [0, [kc_to_u8!(P), 0, 0, 0, 0, 0]], // P sent clean on layer 0 (NOT Z) — layer 1 gone + [0, [0, 0, 0, 0, 0, 0]], // P release + ] + }; +} + +/// StickyKey Test 15: `activate_on_keypress` is IGNORED for tap-key SKs. +/// +/// Docs: `activate_on_keypress` is "honored only for pure-mod SKs" and is +/// "silently ignored for tap-key SKs". A tap-key already sends its modifier +/// eagerly on the first press, so the flag has nothing to tune. With +/// activate_on_keypress=true the report stream must be identical to the +/// default tap-key flow (cf. test_sk_basic_flow_press_twice). +#[test] +fn test_sk_tap_key_ignores_activate_on_keypress() { + key_sequence_test! { + keyboard: create_test_keyboard_with_behavior_config(BehaviorConfig { + sticky_key: StickyKeyConfig { + activate_on_keypress: true, // pure-mod-only knob — must be ignored here + release_on_layer_change: true, // match create_test_keyboard so MO release cleans up + ..StickyKeyConfig::default() + }, + ..BehaviorConfig::default() + }), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SK(Tab, LAlt) + [0, 0, false, 10], // Release SK + [0, 0, true, 10], // Press SK again + [0, 0, false, 10], // Release SK + [0, 3, false, 10], // Release MO(1) + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press again: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held + [0, [0, 0, 0, 0, 0, 0]], // MO release: SK cleaned up + ] + }; +} + +/// KEYMAP_PUREMOD_SK: pure-mod SK at col 4, basic keys at cols 0-2, for testing "timeout while held". const KEYMAP_PUREMOD_SK: [[[KeyAction; 6]; 1]; 1] = [[[ k!(A), // col 0: A k!(B), // col 1: B @@ -730,37 +727,37 @@ fn test_sk_timeout_while_held() { } /// StickyKey Test 16: `quick_release` is IGNORED for tap-key SKs. - /// - /// Docs: `quick_release` is "honored only for pure-mod SKs" and is "silently - /// ignored for tap-key SKs". Its pure-mod semantics (release the modifier on - /// the next key *press*) have nothing to tune on a tap-key, which deliberately - /// holds its modifier across repeats. With quick_release=true the report stream - /// must be identical to the default tap-key flow (cf. test_sk_basic_flow_press_twice). - #[test] - fn test_sk_tap_key_ignores_quick_release() { - key_sequence_test! { - keyboard: create_test_keyboard_with_behavior_config(BehaviorConfig { - sticky_key: StickyKeyConfig { - quick_release: true, // pure-mod-only knob — must be ignored here - release_on_layer_change: true, // match create_test_keyboard so MO release cleans up - ..StickyKeyConfig::default() - }, - ..BehaviorConfig::default() - }), - sequence: [ - [0, 3, true, 10], // Press MO(1) - [0, 0, true, 10], // Press SK(Tab, LAlt) - [0, 0, false, 10], // Release SK - [0, 0, true, 10], // Press SK again - [0, 0, false, 10], // Release SK - [0, 3, false, 10], // Release MO(1) - ], - expected_reports: [ - [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Alt+Tab - [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held - [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press again: Alt+Tab - [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held - [0, [0, 0, 0, 0, 0, 0]], // MO release: SK cleaned up - ] - }; - } +/// +/// Docs: `quick_release` is "honored only for pure-mod SKs" and is "silently +/// ignored for tap-key SKs". Its pure-mod semantics (release the modifier on +/// the next key *press*) have nothing to tune on a tap-key, which deliberately +/// holds its modifier across repeats. With quick_release=true the report stream +/// must be identical to the default tap-key flow (cf. test_sk_basic_flow_press_twice). +#[test] +fn test_sk_tap_key_ignores_quick_release() { + key_sequence_test! { + keyboard: create_test_keyboard_with_behavior_config(BehaviorConfig { + sticky_key: StickyKeyConfig { + quick_release: true, // pure-mod-only knob — must be ignored here + release_on_layer_change: true, // match create_test_keyboard so MO release cleans up + ..StickyKeyConfig::default() + }, + ..BehaviorConfig::default() + }), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SK(Tab, LAlt) + [0, 0, false, 10], // Release SK + [0, 0, true, 10], // Press SK again + [0, 0, false, 10], // Release SK + [0, 3, false, 10], // Release MO(1) + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press again: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held + [0, [0, 0, 0, 0, 0, 0]], // MO release: SK cleaned up + ] + }; +} From 6385513ae0e8a574d70a08ef7097ad701d92482b Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Mon, 6 Jul 2026 09:21:56 -0500 Subject: [PATCH 078/119] =?UTF-8?q?fix(vial):=20add=20OSM/OSL=20=E2=86=94?= =?UTF-8?q?=20StickyKey=20conversion=20via=20keycode=5Fconvert.rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit (5ac8d57f: feat(sticky-key): restore OSM/OSL as aliases) only added the macro-level aliases but did NOT touch the Vial keycode conversion layer. This completes Item 2 of HaoboGu's review: the Vial keycode_convert.rs now properly maps: to_via_keycode: Action::StickyKey → 0x52A0|bits (OSM) / 0x5280|l (OSL) from_via_keycode: 0x52A0..0x52BF → Action::StickyKey (OSM) 0x5280..0x529F → Action::StickyKey (OSL) Uses VIA-defined ranges 0x5280/0x52A0 (same as old OneShotLayer/ OneShotModifier) rather than QMK's 0x7C02/0x7C03 ranges, because VIA ranges avoid prefix-vs-payload bit overlap and round-trip cleanly through packed bits. Round-trip tests added for OSM(LCtrl), OSM(LShift), OSM(LAlt), OSL(0), and OSL(5). Per HaoboGu: 'Keep Vial OSM/OSL supported, just migrate to SK backend' 522/522 tests pass. --- rmk/src/host/via/keycode_convert.rs | 89 ++++++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 2 deletions(-) diff --git a/rmk/src/host/via/keycode_convert.rs b/rmk/src/host/via/keycode_convert.rs index 277816b3f..f1156f5f3 100644 --- a/rmk/src/host/via/keycode_convert.rs +++ b/rmk/src/host/via/keycode_convert.rs @@ -1,5 +1,5 @@ -use rmk_types::action::{Action, KeyAction, KeyboardAction}; -use rmk_types::keycode::{KeyCode, SpecialKey}; +use rmk_types::action::{Action, KeyAction, KeyboardAction, StickyKeyAction}; +use rmk_types::keycode::{HidKeyCode, KeyCode, SpecialKey}; use rmk_types::modifier::ModifierCombination; pub(crate) fn to_via_keycode(key_action: KeyAction) -> u16 { @@ -87,6 +87,12 @@ pub(crate) fn to_via_keycode(key_action: KeyAction) -> u16 { } }, Action::User(id) => (id as u16 & 0xF) | 0x7E00, + Action::StickyKey(sk) => { + match sk.layer { + Some(l) => 0x5280 | (l as u16), // OSL, VIA range (same as old OneShotLayer) + None => 0x52A0 | ((sk.keep.into_packed_bits() & 0x1F) as u16), // OSM, VIA range (same as old OneShotModifier) + } + } _ => { warn!("Action: {:?} in vial is not supported yet", a); 0 @@ -217,6 +223,24 @@ pub(crate) fn from_via_keycode(via_keycode: u16) -> KeyAction { 0x7C77 => KeyAction::Single(Action::TriLayerLower), 0x7C78 => KeyAction::Single(Action::TriLayerUpper), 0x7C79 => KeyAction::Single(Action::Special(SpecialKey::Repeat)), + // OSL(layer) — one-shot layer (VIA range 0x5280..0x529F, matching old OneShotLayer) + 0x5280..=0x529F => { + let layer = via_keycode as u8 & 0x1F; + KeyAction::Single(Action::StickyKey(StickyKeyAction { + key: KeyCode::Hid(HidKeyCode::No), + keep: ModifierCombination::new(), + layer: Some(layer), + })) + } + // OSM(mod) — one-shot modifier (VIA range 0x52A0..0x52BF, matching old OneShotModifier) + 0x52A0..=0x52BF => { + let m = ModifierCombination::from_packed_bits((via_keycode & 0x1F) as u8); + KeyAction::Single(Action::StickyKey(StickyKeyAction { + key: KeyCode::Hid(HidKeyCode::No), + keep: m, + layer: None, + })) + } 0x7C02..=0x7C5F => { // TODO: Reset/Space Cadet/Haptic/Auto shift(AS)/Dynamic macro // - [Space Cadet](https://docs.qmk.fm/#/feature_space_cadet) @@ -637,4 +661,65 @@ mod test { assert_eq!(to_ascii(keycode, shifted), ascii); assert_eq!(from_ascii(ascii), (keycode, shifted)); } + + #[test] + fn test_vial_osm_round_trip() { + // OSM(LCtrl) — VIA range 0x52A0 + packed_bits + let osm_ctrl = KeyAction::Single(Action::StickyKey(StickyKeyAction { + key: KeyCode::Hid(HidKeyCode::No), + keep: ModifierCombination::LCTRL, + layer: None, + })); + let via = to_via_keycode(osm_ctrl); + assert_eq!(via, 0x52A1); // 0x52A0 | LCtrl packed bits (0x01) + let roundtrip = from_via_keycode(via); + assert_eq!(roundtrip, osm_ctrl); + + // OSM(LShift) + let osm_shift = KeyAction::Single(Action::StickyKey(StickyKeyAction { + key: KeyCode::Hid(HidKeyCode::No), + keep: ModifierCombination::LSHIFT, + layer: None, + })); + let via = to_via_keycode(osm_shift); + assert_eq!(via, 0x52A2); // 0x52A0 | LShift packed bits (0x02) + let roundtrip = from_via_keycode(via); + assert_eq!(roundtrip, osm_shift); + + // OSM(LAlt) — uses VIA range 0x52A0, round-trips through packed bits cleanly now + let osm_alt = KeyAction::Single(Action::StickyKey(StickyKeyAction { + key: KeyCode::Hid(HidKeyCode::No), + keep: ModifierCombination::LALT, + layer: None, + })); + let via = to_via_keycode(osm_alt); + assert_eq!(via, 0x52A4); // 0x52A0 | LAlt packed bits (0x04) + let roundtrip = from_via_keycode(via); + assert_eq!(roundtrip, osm_alt); + } + + #[test] + fn test_vial_osl_round_trip() { + // OSL(0) — VIA range 0x5280 + layer + let osl_0 = KeyAction::Single(Action::StickyKey(StickyKeyAction { + key: KeyCode::Hid(HidKeyCode::No), + keep: ModifierCombination::new(), + layer: Some(0), + })); + let via = to_via_keycode(osl_0); + assert_eq!(via, 0x5280); + let roundtrip = from_via_keycode(via); + assert_eq!(roundtrip, osl_0); + + // OSL(5) + let osl_5 = KeyAction::Single(Action::StickyKey(StickyKeyAction { + key: KeyCode::Hid(HidKeyCode::No), + keep: ModifierCombination::new(), + layer: Some(5), + })); + let via = to_via_keycode(osl_5); + assert_eq!(via, 0x5285); + let roundtrip = from_via_keycode(via); + assert_eq!(roundtrip, osl_5); + } } From 07b2ac6286a211c132f43c51ae46e013de8ce184 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:55:26 -0500 Subject: [PATCH 079/119] fix(keyboard): use per-check Instant::now() instead of shared now to avoid stale-read race The shared captured at loop iteration start can go stale when the first async post-check (release_sticky_key_if_active) sends HID reports and takes real time. The second post-check (fire_mouse_repeat) then compares against a stale timestamp, potentially missing its deadline by one iteration. Using inline Instant::now() in each closure ensures every deadline check uses current wall-clock time. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- rmk/src/keyboard.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/rmk/src/keyboard.rs b/rmk/src/keyboard.rs index 6a4375e35..388d0d747 100644 --- a/rmk/src/keyboard.rs +++ b/rmk/src/keyboard.rs @@ -180,11 +180,10 @@ impl Runnable for Keyboard<'_> { }; // Check deadlines after processing / timeout. - let now = Instant::now(); - if self.sticky_key_state.deadline().is_some_and(|d| now >= d) { + if self.sticky_key_state.deadline().is_some_and(|d| Instant::now() >= d) { self.release_sticky_key_if_active().await; } - if self.mouse.next_deadline().is_some_and(|d| now >= d) { + if self.mouse.next_deadline().is_some_and(|d| Instant::now() >= d) { self.fire_mouse_repeat().await; } } From bb3f83ea55bae431148305b2cb3bec18269cc7a1 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:59:22 -0500 Subject: [PATCH 080/119] fix: remove extra closing brace and apply formatting after merge --- rmk-config/src/lib.rs | 1 - rmk-macro/src/codegen/behavior.rs | 5 +++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/rmk-config/src/lib.rs b/rmk-config/src/lib.rs index 88368c738..d1ceed453 100644 --- a/rmk-config/src/lib.rs +++ b/rmk-config/src/lib.rs @@ -642,7 +642,6 @@ pub(crate) struct AutoMouseLayerConfig { /// Defaults to `1` (any motion). Helpful to filter out sensor noise. pub threshold: Option, } -} /// Per Key configurations profiles for morse, tap-hold, etc. /// overrides the defaults given in TapHoldConfig diff --git a/rmk-macro/src/codegen/behavior.rs b/rmk-macro/src/codegen/behavior.rs index 6d08bc4f1..de5e445bc 100644 --- a/rmk-macro/src/codegen/behavior.rs +++ b/rmk-macro/src/codegen/behavior.rs @@ -3,10 +3,11 @@ use std::collections::HashMap; use quote::quote; -use rmk_config::resolved::Behavior; use rmk_config::resolved::behavior::{ - AutoMouseLayer, Combos, Forks, MacroOperation, Macros, Morse, MorseActionPair, MorseKey, MorseProfile, + AutoMouseLayer, Combos, Forks, MacroOperation, Macros, Morse, MorseActionPair, MorseKey, + MorseProfile, }; +use rmk_config::resolved::Behavior; use super::action_parser::{expand_profile, expand_profile_name, get_key_with_alias, parse_key}; From 63f52cb8b6211fda603bd291cf1dfe2791f529d4 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:25:22 -0500 Subject: [PATCH 081/119] fix: apply rustfmt to via keycode_convert.rs comment spacing --- rmk/src/host/via/keycode_convert.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rmk/src/host/via/keycode_convert.rs b/rmk/src/host/via/keycode_convert.rs index f1156f5f3..1034126d2 100644 --- a/rmk/src/host/via/keycode_convert.rs +++ b/rmk/src/host/via/keycode_convert.rs @@ -89,8 +89,8 @@ pub(crate) fn to_via_keycode(key_action: KeyAction) -> u16 { Action::User(id) => (id as u16 & 0xF) | 0x7E00, Action::StickyKey(sk) => { match sk.layer { - Some(l) => 0x5280 | (l as u16), // OSL, VIA range (same as old OneShotLayer) - None => 0x52A0 | ((sk.keep.into_packed_bits() & 0x1F) as u16), // OSM, VIA range (same as old OneShotModifier) + Some(l) => 0x5280 | (l as u16), // OSL, VIA range (same as old OneShotLayer) + None => 0x52A0 | ((sk.keep.into_packed_bits() & 0x1F) as u16), // OSM, VIA range (same as old OneShotModifier) } } _ => { From 43bb66c1f10686d855dcd45b2c121dabd8978ae3 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:39:02 -0500 Subject: [PATCH 082/119] fix: apply rustfmt to behavior.rs import ordering --- rmk-macro/src/codegen/behavior.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rmk-macro/src/codegen/behavior.rs b/rmk-macro/src/codegen/behavior.rs index de5e445bc..88b7d94d4 100644 --- a/rmk-macro/src/codegen/behavior.rs +++ b/rmk-macro/src/codegen/behavior.rs @@ -3,11 +3,11 @@ use std::collections::HashMap; use quote::quote; +use rmk_config::resolved::Behavior; use rmk_config::resolved::behavior::{ AutoMouseLayer, Combos, Forks, MacroOperation, Macros, Morse, MorseActionPair, MorseKey, MorseProfile, }; -use rmk_config::resolved::Behavior; use super::action_parser::{expand_profile, expand_profile_name, get_key_with_alias, parse_key}; From 33861467ef57091add8de8f59af8174309900802 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:23:01 -0500 Subject: [PATCH 083/119] fix: revert user_build.yml and .vscode/settings.json to upstream/main Addresses HaoboGu's review comments from PR #859: - Restore CI artifact upload steps accidentally deleted during merge conflict resolution (comment 1) - Remove personal VS Code theme customizations accidentally committed (comment 2) --- .github/workflows/user_build.yml | 57 ++++++++++++++++++++++++++++++++ .vscode/settings.json | 19 +---------- 2 files changed, 58 insertions(+), 18 deletions(-) diff --git a/.github/workflows/user_build.yml b/.github/workflows/user_build.yml index 8a25fef07..4e3b0b70d 100644 --- a/.github/workflows/user_build.yml +++ b/.github/workflows/user_build.yml @@ -81,6 +81,8 @@ jobs: build: runs-on: ubuntu-latest needs: get_chip_name + # Only esp32s3 (Xtensa) needs the espup toolchain in build_esp; + # RISC-V esp32 chips build here with the standard toolchain. if: needs.get_chip_name.outputs.chip_name != 'esp32s3' steps: - uses: cargo-bins/cargo-binstall@main @@ -111,15 +113,47 @@ jobs: working-directory: ./rmk run: cargo make uf2 --release - name: Upload uf2 artifacts + if: ${{ !startsWith(needs.get_chip_name.outputs.chip_name, 'esp32') }} uses: actions/upload-artifact@v7 with: name: ${{ needs.get_chip_name.outputs.artifact_prefix }}${{ needs.get_chip_name.outputs.project_name }}-firmware_uf2 path: rmk/*.uf2 - name: Upload hex artifacts + if: ${{ !startsWith(needs.get_chip_name.outputs.chip_name, 'esp32') }} uses: actions/upload-artifact@v7 with: name: ${{ needs.get_chip_name.outputs.artifact_prefix }}${{ needs.get_chip_name.outputs.project_name }}-firmware_hex path: rmk/*.hex + - name: Upload bin artifacts + # espflash produces .bin images for esp32 chips instead of uf2/hex + if: ${{ startsWith(needs.get_chip_name.outputs.chip_name, 'esp32') }} + uses: actions/upload-artifact@v7 + with: + name: ${{ needs.get_chip_name.outputs.artifact_prefix }}${{ needs.get_chip_name.outputs.project_name }}-firmware_bin + path: rmk/*.bin + - name: Stage ELF + # Expose the raw ELF(s) for debugging (probe-rs/gdb); esp32 flashes it directly. + # Name them like the other firmware: .elf, or -central/-peripheral.elf + # for splits (whose bin targets are named central/peripheral, not ). + working-directory: ./rmk + env: + PROJECT_NAME: ${{ needs.get_chip_name.outputs.project_name }} + run: | + for f in target/*/release/*; do + if [ -f "$f" ] && [ -x "$f" ]; then + bin=$(basename "$f") + case "$bin" in + central|peripheral) name="${PROJECT_NAME}-${bin}" ;; + *) name="$bin" ;; + esac + cp "$f" "${name}.elf" + fi + done + - name: Upload elf artifacts + uses: actions/upload-artifact@v7 + with: + name: ${{ needs.get_chip_name.outputs.artifact_prefix }}${{ needs.get_chip_name.outputs.project_name }}-firmware_elf + path: rmk/*.elf build_esp: runs-on: ubuntu-latest needs: get_chip_name @@ -165,3 +199,26 @@ jobs: with: name: ${{ needs.get_chip_name.outputs.artifact_prefix }}${{ needs.get_chip_name.outputs.project_name }}-firmware_bin path: rmk/*.bin + - name: Stage esp32 ELF + # espflash can flash the raw ELF directly, so expose it as an artifact. + # Name them like the other firmware: .elf, or -central/-peripheral.elf + # for splits (whose bin targets are named central/peripheral, not ). + working-directory: ./rmk + env: + PROJECT_NAME: ${{ needs.get_chip_name.outputs.project_name }} + run: | + for f in target/*/release/*; do + if [ -f "$f" ] && [ -x "$f" ]; then + bin=$(basename "$f") + case "$bin" in + central|peripheral) name="${PROJECT_NAME}-${bin}" ;; + *) name="$bin" ;; + esac + cp "$f" "${name}.elf" + fi + done + - name: Upload elf artifacts + uses: actions/upload-artifact@v7 + with: + name: ${{ needs.get_chip_name.outputs.artifact_prefix }}${{ needs.get_chip_name.outputs.project_name }}-firmware_elf + path: rmk/*.elf diff --git a/.vscode/settings.json b/.vscode/settings.json index 38f7656db..90b957f8a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -57,22 +57,5 @@ // "examples/use_rust/stm32f1/Cargo.toml", // "examples/use_rust/stm32f4/Cargo.toml", // "examples/use_rust/stm32h7/Cargo.toml", -], -"workbench.colorCustomizations": { - "activityBar.activeBackground": "#fdae33", - "activityBar.background": "#fdae33", - "activityBar.foreground": "#15202b", - "activityBar.inactiveForeground": "#15202b99", - "activityBarBadge.background": "#017e4d", - "activityBarBadge.foreground": "#e7e7e7", - "commandCenter.border": "#15202b99", - "panel.border": "#fdae33", - "sash.hoverBorder": "#fdae33", - "sideBar.border": "#fdae33", - "tab.activeBorder": "#fdae33", - "titleBar.activeBackground": "#fb9902", - "titleBar.activeForeground": "#15202b", - "titleBar.inactiveBackground": "#fb990299", - "titleBar.inactiveForeground": "#15202b99" -}, + ], } \ No newline at end of file From 1a46b7918f76cc8e3507a642b8cec2dd8cee70ee Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:26:01 -0500 Subject: [PATCH 084/119] fix: preserve StickyKey shape in VIA conversion --- rmk/src/host/via/keycode_convert.rs | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/rmk/src/host/via/keycode_convert.rs b/rmk/src/host/via/keycode_convert.rs index 1034126d2..3983bb982 100644 --- a/rmk/src/host/via/keycode_convert.rs +++ b/rmk/src/host/via/keycode_convert.rs @@ -87,12 +87,16 @@ pub(crate) fn to_via_keycode(key_action: KeyAction) -> u16 { } }, Action::User(id) => (id as u16 & 0xF) | 0x7E00, - Action::StickyKey(sk) => { - match sk.layer { - Some(l) => 0x5280 | (l as u16), // OSL, VIA range (same as old OneShotLayer) - None => 0x52A0 | ((sk.keep.into_packed_bits() & 0x1F) as u16), // OSM, VIA range (same as old OneShotModifier) + Action::StickyKey(sk) => match (sk.key, sk.layer) { + // OSL, VIA range (same as old OneShotLayer) + (_, Some(layer)) if layer < 32 => 0x5280 | layer as u16, + // OSM, VIA range (same as old OneShotModifier) + (KeyCode::Hid(HidKeyCode::No), None) => 0x52A0 | ((sk.keep.into_packed_bits() & 0x1F) as u16), + _ => { + warn!("StickyKey {:?} is not supported by VIA", sk); + 0 } - } + }, _ => { warn!("Action: {:?} in vial is not supported yet", a); 0 @@ -722,4 +726,15 @@ mod test { let roundtrip = from_via_keycode(via); assert_eq!(roundtrip, osl_5); } + + #[test] + fn test_vial_does_not_convert_tap_key_sticky_key_to_osm() { + let tap_key_sticky_key = KeyAction::Single(Action::StickyKey(StickyKeyAction { + key: KeyCode::Hid(HidKeyCode::Tab), + keep: ModifierCombination::LALT, + layer: None, + })); + + assert_eq!(to_via_keycode(tap_key_sticky_key), 0); + } } From 80d6425a6d8c7600a8c2a03c4a520ffa086ee6b7 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:16:07 -0500 Subject: [PATCH 085/119] fix: defer StickyKey timeout while held --- rmk/src/keyboard/sticky_key.rs | 38 ++++++++++++++++++++------- rmk/tests/keyboard_sticky_key_test.rs | 35 ++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/rmk/src/keyboard/sticky_key.rs b/rmk/src/keyboard/sticky_key.rs index c6bf96641..00f98c709 100644 --- a/rmk/src/keyboard/sticky_key.rs +++ b/rmk/src/keyboard/sticky_key.rs @@ -46,6 +46,8 @@ pub(crate) enum StickyKeyState { /// `Some(n)` = OSL shape; `None` = pure-mod or tap-key shape. layer: Option, phase: SkPhase, + /// Whether the physical StickyKey switch is currently held down. + pressed: bool, repeat_count: u16, deadline: Option, }, @@ -128,13 +130,20 @@ impl Keyboard<'_> { key: params.key, layer: None, phase: SkPhase::Pressed, + pressed: true, repeat_count: 1, deadline, }; } - StickyKeyState::Active { mods, deadline: d, .. } => { + StickyKeyState::Active { + mods, + pressed, + deadline: d, + .. + } => { // Same-shape pure-mod re-press: accumulate (3c) and refresh the deadline. *mods |= params.keep; + *pressed = true; *d = deadline; } } @@ -144,6 +153,9 @@ impl Keyboard<'_> { } } else { // SK released. + if let StickyKeyState::Active { pressed, .. } = &mut self.sticky_key_state { + *pressed = false; + } match self.sticky_key_state { StickyKeyState::Active { phase: SkPhase::Pressed, @@ -205,6 +217,7 @@ impl Keyboard<'_> { key: params.key, layer: Some(layer_num), phase: prev_phase, + pressed: true, repeat_count: 1, deadline, }; @@ -217,8 +230,15 @@ impl Keyboard<'_> { } => { // Released before any other key → arm it for the next key and (re)arm the // deadline so the run-loop race covers expiry. - if let StickyKeyState::Active { phase, deadline: d, .. } = &mut self.sticky_key_state { + if let StickyKeyState::Active { + phase, + pressed, + deadline: d, + .. + } = &mut self.sticky_key_state + { *phase = SkPhase::Latched; + *pressed = false; *d = deadline; } } @@ -259,11 +279,13 @@ impl Keyboard<'_> { key: params.key, layer: None, phase: SkPhase::Latched, + pressed: true, repeat_count: 1, deadline, }; } StickyKeyState::Active { + pressed, repeat_count, deadline: d, .. @@ -274,6 +296,7 @@ impl Keyboard<'_> { if config.max_repeat > 0 && *repeat_count > config.max_repeat { should_deactivate = true; } else { + *pressed = true; *d = deadline; } } @@ -292,7 +315,8 @@ impl Keyboard<'_> { // Only unregister and report if SK was active (key was registered on press). // If max_repeat deactivated SK silently on the press event, the key was never // registered, so the release is a no-op. - if self.sticky_key_state.is_active() { + if let StickyKeyState::Active { pressed, .. } = &mut self.sticky_key_state { + *pressed = false; if let KeyCode::Hid(hid_key) = params.key { self.unregister_key(hid_key, event); } @@ -387,13 +411,7 @@ impl Keyboard<'_> { // only happen when the key is held and idle. For layer and tap-key shapes, the // deadline fires in the same scenario. // Clear the deadline to avoid busy-looping on every iteration. - if matches!( - self.sticky_key_state, - StickyKeyState::Active { - phase: SkPhase::Pressed, - .. - } - ) { + if matches!(self.sticky_key_state, StickyKeyState::Active { pressed: true, .. }) { debug!( "StickyKey timeout fired while key is still held — clearing deadline, deferring to physical release" ); diff --git a/rmk/tests/keyboard_sticky_key_test.rs b/rmk/tests/keyboard_sticky_key_test.rs index 37863a199..1ccf50859 100644 --- a/rmk/tests/keyboard_sticky_key_test.rs +++ b/rmk/tests/keyboard_sticky_key_test.rs @@ -690,6 +690,21 @@ fn create_test_keyboard_puremod_sk() -> Keyboard<'static> { Keyboard::new(wrap_keymap(KEYMAP_PUREMOD_SK, per_key_config, behavior_config)) } +// KEYMAP_TAP_SK: tap-key SK at col 0 and a basic key at col 1, for testing a timeout while held. +const KEYMAP_TAP_SK: [[[KeyAction; 2]; 1]; 1] = [[[sk!(Tab, ModifierCombination::LALT), k!(A)]]]; + +fn create_test_keyboard_tap_sk() -> Keyboard<'static> { + let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig { + sticky_key: StickyKeyConfig { + timeout: Duration::from_millis(10), + ..StickyKeyConfig::default() + }, + ..BehaviorConfig::default() + })); + let per_key_config: &'static PositionalConfig<1, 2> = Box::leak(Box::new(PositionalConfig::default())); + Keyboard::new(wrap_keymap(KEYMAP_TAP_SK, per_key_config, behavior_config)) +} + /// StickyKey Test 17: Timeout fires while SK is still physically held (Pressed phase). /// /// The guard in `release_sticky_key_if_active()` must prevent the latch from being @@ -726,6 +741,26 @@ fn test_sk_timeout_while_held() { }; } +/// StickyKey Test 17b: Timeout fires while a tap-key SK is still physically held. +/// +/// Tap-key SKs use the `Latched` phase while their physical key is down, so phase alone cannot +/// tell the timeout handler whether clearing the state is safe. The physical-press flag must keep +/// the state alive until release so that release unregisters Tab and retains the latched Alt. +#[test] +fn test_tap_sk_timeout_while_held() { + key_sequence_test! { + keyboard: create_test_keyboard_tap_sk(), + sequence: [ + [0, 0, true, 0], // Press SK(Tab, LAlt) + [0, 0, false, 20], // Hold past timeout, then release + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], + [KC_LALT, [0, 0, 0, 0, 0, 0]], + ] + }; +} + /// StickyKey Test 16: `quick_release` is IGNORED for tap-key SKs. /// /// Docs: `quick_release` is "honored only for pure-mod SKs" and is "silently From a05a99421c0e39a000d13d21c82d7fa69c910cac Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:01:31 -0500 Subject: [PATCH 086/119] fix: isolate concurrent StickyKey presses --- rmk/src/keyboard.rs | 2 +- rmk/src/keyboard/sticky_key.rs | 69 ++++++++++++++++++++++++--- rmk/tests/keyboard_sticky_key_test.rs | 36 ++++++++++++++ 3 files changed, 99 insertions(+), 8 deletions(-) diff --git a/rmk/src/keyboard.rs b/rmk/src/keyboard.rs index 8cff3b1a1..e21282ae8 100644 --- a/rmk/src/keyboard.rs +++ b/rmk/src/keyboard.rs @@ -182,7 +182,7 @@ impl Runnable for Keyboard<'_> { // Check deadlines after processing / timeout. if self.sticky_key_state.deadline().is_some_and(|d| Instant::now() >= d) { - self.release_sticky_key_if_active().await; + self.release_sticky_key_if_active_on_timeout().await; } if self.mouse.next_deadline().is_some_and(|d| Instant::now() >= d) { self.fire_mouse_repeat().await; diff --git a/rmk/src/keyboard/sticky_key.rs b/rmk/src/keyboard/sticky_key.rs index 00f98c709..a5d349730 100644 --- a/rmk/src/keyboard/sticky_key.rs +++ b/rmk/src/keyboard/sticky_key.rs @@ -13,7 +13,7 @@ use rmk_types::action::StickyKeyAction; use rmk_types::keycode::{HidKeyCode, KeyCode}; use rmk_types::modifier::ModifierCombination; -use crate::event::KeyboardEvent; +use crate::event::{KeyboardEvent, KeyboardEventPos}; use crate::keyboard::Keyboard; /// Latch phase of a sticky key. @@ -40,6 +40,8 @@ pub(crate) enum StickyKeyState { None, /// StickyKey is active — carries all latch state the engine needs. Active { + /// Physical key that owns this latch. + source: KeyboardEventPos, mods: ModifierCombination, /// `KeyCode::Hid(HidKeyCode::No)` = pure-mod or layer shape; any other key = tap-key shape. key: KeyCode, @@ -126,6 +128,7 @@ impl Keyboard<'_> { match &mut self.sticky_key_state { StickyKeyState::None => { self.sticky_key_state = StickyKeyState::Active { + source: event.pos, mods: params.keep, key: params.key, layer: None, @@ -153,6 +156,12 @@ impl Keyboard<'_> { } } else { // SK released. + if !matches!( + self.sticky_key_state, + StickyKeyState::Active { source, .. } if source == event.pos + ) { + return; + } if let StickyKeyState::Active { pressed, .. } = &mut self.sticky_key_state { *pressed = false; } @@ -213,6 +222,7 @@ impl Keyboard<'_> { self.keymap.activate_layer(layer_num); self.sticky_key_state = StickyKeyState::Active { + source: event.pos, mods: existing_mods | params.keep, key: params.key, layer: Some(layer_num), @@ -223,6 +233,12 @@ impl Keyboard<'_> { }; } else { // SK released. + if !matches!( + self.sticky_key_state, + StickyKeyState::Active { source, .. } if source == event.pos + ) { + return; + } match self.sticky_key_state { StickyKeyState::Active { phase: SkPhase::Pressed | SkPhase::Latched, @@ -263,10 +279,14 @@ impl Keyboard<'_> { let deadline = (config.timeout != Duration::MAX).then(|| Instant::now() + config.timeout); if event.pressed { - // Single mutually-exclusive latch: a tap-key press cycles (repeat_count) an existing - // tap-key latch, but REPLACES any other shape. Release the foreign latch first so only - // a same-shape tap-key latch can reach the cycle arm below. - if self.sticky_key_state.is_active() && !self.sticky_key_state.is_tap_key() { + // A repeated press of the same physical tap-key cycles it. A different tap-key, like + // any foreign StickyKey shape, replaces the active latch so it gets its own key and + // modifiers rather than reusing the first key's state. + let is_different_tap_key = matches!( + self.sticky_key_state, + StickyKeyState::Active { source, .. } if source != event.pos + ); + if self.sticky_key_state.is_active() && (!self.sticky_key_state.is_tap_key() || is_different_tap_key) { self.release_sticky_key_if_active().await; } @@ -275,6 +295,7 @@ impl Keyboard<'_> { match &mut self.sticky_key_state { StickyKeyState::None => { self.sticky_key_state = StickyKeyState::Active { + source: event.pos, mods: params.keep, key: params.key, layer: None, @@ -315,7 +336,9 @@ impl Keyboard<'_> { // Only unregister and report if SK was active (key was registered on press). // If max_repeat deactivated SK silently on the press event, the key was never // registered, so the release is a no-op. - if let StickyKeyState::Active { pressed, .. } = &mut self.sticky_key_state { + if let StickyKeyState::Active { source, pressed, .. } = &mut self.sticky_key_state + && *source == event.pos + { *pressed = false; if let KeyCode::Hid(hid_key) = params.key { self.unregister_key(hid_key, event); @@ -399,7 +422,12 @@ impl Keyboard<'_> { } } - pub(crate) async fn release_sticky_key_if_active(&mut self) { + /// Release a StickyKey whose timeout has elapsed. + /// + /// A physical key release must still be able to observe the active state, so a timeout that + /// fires while the key is held only clears its deadline. Explicit cleanup (for a replacement + /// key or layer change) uses `release_sticky_key_if_active` and must not be deferred. + pub(crate) async fn release_sticky_key_if_active_on_timeout(&mut self) { if !self.sticky_key_state.is_active() { return; } @@ -421,6 +449,14 @@ impl Keyboard<'_> { return; } + self.release_sticky_key_if_active().await; + } + + pub(crate) async fn release_sticky_key_if_active(&mut self) { + if !self.sticky_key_state.is_active() { + return; + } + debug!("Releasing StickyKey"); // Decide whether the release needs its own HID report. A report is only meaningful @@ -445,6 +481,25 @@ impl Keyboard<'_> { !self.sticky_key_state.is_layer() }; + // A tap-key may still have its HID key registered when it is displaced by a different + // StickyKey while physically held. Unregister it before clearing the latch so it cannot + // remain stuck in the report. + if let StickyKeyState::Active { + key: KeyCode::Hid(hid_key), + layer: None, + source, + .. + } = self.sticky_key_state + { + self.unregister_key( + hid_key, + KeyboardEvent { + pressed: false, + pos: source, + }, + ); + } + // For the layer shape, deactivate the active layer before clearing the latch. if let StickyKeyState::Active { layer: Some(layer_num), .. diff --git a/rmk/tests/keyboard_sticky_key_test.rs b/rmk/tests/keyboard_sticky_key_test.rs index 1ccf50859..6674bbe88 100644 --- a/rmk/tests/keyboard_sticky_key_test.rs +++ b/rmk/tests/keyboard_sticky_key_test.rs @@ -705,6 +705,19 @@ fn create_test_keyboard_tap_sk() -> Keyboard<'static> { Keyboard::new(wrap_keymap(KEYMAP_TAP_SK, per_key_config, behavior_config)) } +// KEYMAP_TWO_TAP_SK: two tap-key SKs for verifying that a second physical key replaces the +// first latch instead of reusing its key and modifiers. +const KEYMAP_TWO_TAP_SK: [[[KeyAction; 2]; 1]; 1] = [[[ + sk!(Tab, ModifierCombination::LALT), + sk!(Enter, ModifierCombination::LCTRL), +]]]; + +fn create_test_keyboard_two_tap_sk() -> Keyboard<'static> { + let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig::default())); + let per_key_config: &'static PositionalConfig<1, 2> = Box::leak(Box::new(PositionalConfig::default())); + Keyboard::new(wrap_keymap(KEYMAP_TWO_TAP_SK, per_key_config, behavior_config)) +} + /// StickyKey Test 17: Timeout fires while SK is still physically held (Pressed phase). /// /// The guard in `release_sticky_key_if_active()` must prevent the latch from being @@ -761,6 +774,29 @@ fn test_tap_sk_timeout_while_held() { }; } +/// StickyKey Test 17c: A second physical tap-key gets its own state. +/// +/// The second key replaces the first active latch, so it uses LCtrl+Enter rather than the +/// first key's LAlt+Tab state. Releasing the displaced first key must not affect the second. +#[test] +fn test_second_tap_sk_replaces_first_while_held() { + key_sequence_test! { + keyboard: create_test_keyboard_two_tap_sk(), + sequence: [ + [0, 0, true, 0], // Press SK(Tab, LAlt) + [0, 1, true, 0], // Press SK(Enter, LCtrl) while first is held + [0, 0, false, 0], // Release displaced first SK + [0, 1, false, 0], // Release active second SK + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + [KC_LCTRL, [kc_to_u8!(Enter), 0, 0, 0, 0, 0]], + [KC_LCTRL, [0, 0, 0, 0, 0, 0]], + ] + }; +} + /// StickyKey Test 16: `quick_release` is IGNORED for tap-key SKs. /// /// Docs: `quick_release` is "honored only for pure-mod SKs" and is "silently From 2088d64f863db56735a728998c4604f1a194e202 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:00:38 -0500 Subject: [PATCH 087/119] perf(sticky-key): compact timeout state --- rmk/src/keyboard/sticky_key.rs | 73 ++++++++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 7 deletions(-) diff --git a/rmk/src/keyboard/sticky_key.rs b/rmk/src/keyboard/sticky_key.rs index 3e556f351..2e1c6ae65 100644 --- a/rmk/src/keyboard/sticky_key.rs +++ b/rmk/src/keyboard/sticky_key.rs @@ -32,6 +32,37 @@ pub(crate) enum SkPhase { Held, } +/// An optional deadline stored without `Option`'s extra discriminant. +/// +/// `Duration::MAX` already means "no timeout" in sticky-key configuration, so +/// `Instant::MAX` is reserved as the inactive sentinel here. +#[derive(Clone, Copy, Debug)] +pub(crate) struct StickyKeyDeadline(Instant); + +impl StickyKeyDeadline { + const NONE: Self = Self(Instant::MAX); + + fn from_timeout(timeout: Duration) -> Self { + if timeout == Duration::MAX { + Self::NONE + } else { + Self(Instant::now() + timeout) + } + } + + const fn get(self) -> Option { + if self.0.as_ticks() == Instant::MAX.as_ticks() { + None + } else { + Some(self.0) + } + } + + fn clear(&mut self) { + *self = Self::NONE; + } +} + /// State for the StickyKey action. #[derive(Clone, Copy, Default, Debug)] pub(crate) enum StickyKeyState { @@ -51,10 +82,38 @@ pub(crate) enum StickyKeyState { /// Whether the physical StickyKey switch is currently held down. pressed: bool, repeat_count: u16, - deadline: Option, + deadline: StickyKeyDeadline, }, } +#[cfg(test)] +mod size_tests { + use core::mem::size_of; + + use super::*; + + #[allow(dead_code)] + enum StateWithOptionDeadline { + None, + Active { + source: KeyboardEventPos, + mods: ModifierCombination, + key: KeyCode, + layer: Option, + phase: SkPhase, + pressed: bool, + repeat_count: u16, + deadline: Option, + }, + } + + #[test] + fn sentinel_deadline_reduces_sticky_key_state_size() { + assert_eq!(size_of::(), size_of::()); + assert!(size_of::() < size_of::()); + } +} + impl StickyKeyState { pub fn value(&self) -> Option<&ModifierCombination> { match self { @@ -69,7 +128,7 @@ impl StickyKeyState { pub fn deadline(&self) -> Option { match self { - StickyKeyState::Active { deadline, .. } => *deadline, + StickyKeyState::Active { deadline, .. } => deadline.get(), StickyKeyState::None => None, } } @@ -112,7 +171,7 @@ impl Keyboard<'_> { /// terminating key, honor `activate_on_keypress`/`quick_release`. async fn process_sticky_pure_mod(&mut self, params: StickyKeyAction, event: KeyboardEvent) { let config = self.keymap.sticky_key_config(); - let deadline = (config.timeout != Duration::MAX).then(|| Instant::now() + config.timeout); + let deadline = StickyKeyDeadline::from_timeout(config.timeout); if event.pressed { // Single mutually-exclusive latch: a pure-mod press accumulates (3c) onto an @@ -192,7 +251,7 @@ impl Keyboard<'_> { async fn process_sticky_layer(&mut self, params: StickyKeyAction, event: KeyboardEvent) { let layer_num = params.layer.expect("layer shape requires a layer"); let config = self.keymap.sticky_key_config(); - let deadline = (config.timeout != Duration::MAX).then(|| Instant::now() + config.timeout); + let deadline = StickyKeyDeadline::from_timeout(config.timeout); if event.pressed { // Latch-replacement rule on a single mutually-exclusive latch: a layer SK press @@ -270,7 +329,7 @@ impl Keyboard<'_> { /// `activate_on_keypress`/`quick_release`. async fn process_sticky_tap_key(&mut self, params: StickyKeyAction, event: KeyboardEvent) { let config = self.keymap.sticky_key_config(); - let deadline = (config.timeout != Duration::MAX).then(|| Instant::now() + config.timeout); + let deadline = StickyKeyDeadline::from_timeout(config.timeout); if event.pressed { // A repeated press of the same physical tap-key cycles it. A different tap-key, like @@ -386,7 +445,7 @@ impl Keyboard<'_> { // released (held-alt-tab use case). Clear the run-loop deadline so it does not // spuriously time-out while held. *phase = SkPhase::Held; - *deadline = None; + deadline.clear(); false } StickyKeyState::Active { @@ -438,7 +497,7 @@ impl Keyboard<'_> { "StickyKey timeout fired while key is still held — clearing deadline, deferring to physical release" ); if let StickyKeyState::Active { deadline, .. } = &mut self.sticky_key_state { - *deadline = None; + deadline.clear(); } return; } From 5b3398598d866d06665325e2824bf8935faf640b Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:09:20 -0500 Subject: [PATCH 088/119] perf(sticky-key): share action setup --- rmk/src/keyboard/sticky_key.rs | 40 ++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/rmk/src/keyboard/sticky_key.rs b/rmk/src/keyboard/sticky_key.rs index 2e1c6ae65..6aaa41037 100644 --- a/rmk/src/keyboard/sticky_key.rs +++ b/rmk/src/keyboard/sticky_key.rs @@ -158,21 +158,27 @@ impl StickyKeyState { impl Keyboard<'_> { pub(crate) async fn process_action_sticky_key(&mut self, params: StickyKeyAction, event: KeyboardEvent) { + let config = self.keymap.sticky_key_config(); + let deadline = StickyKeyDeadline::from_timeout(config.timeout); + if params.layer.is_some() { - self.process_sticky_layer(params, event).await; + self.process_sticky_layer(params, event, deadline).await; } else if params.key == KeyCode::Hid(HidKeyCode::No) { - self.process_sticky_pure_mod(params, event).await; + self.process_sticky_pure_mod(params, event, config, deadline).await; } else { - self.process_sticky_tap_key(params, event).await; + self.process_sticky_tap_key(params, event, config, deadline).await; } } /// Pure-mod (OSM) shape: accumulate the modifier across taps, apply it through the /// terminating key, honor `activate_on_keypress`/`quick_release`. - async fn process_sticky_pure_mod(&mut self, params: StickyKeyAction, event: KeyboardEvent) { - let config = self.keymap.sticky_key_config(); - let deadline = StickyKeyDeadline::from_timeout(config.timeout); - + async fn process_sticky_pure_mod( + &mut self, + params: StickyKeyAction, + event: KeyboardEvent, + config: crate::config::StickyKeyConfig, + deadline: StickyKeyDeadline, + ) { if event.pressed { // Single mutually-exclusive latch: a pure-mod press accumulates (3c) onto an // existing pure-mod latch, but REPLACES any other shape (layer or tap-key). @@ -248,10 +254,13 @@ impl Keyboard<'_> { /// `process_action_osl`. The layer carries no modifier, so consuming it emits no HID /// report — the foreign key resolves on the active layer in `process_action_key` before /// the latch is consumed. - async fn process_sticky_layer(&mut self, params: StickyKeyAction, event: KeyboardEvent) { + async fn process_sticky_layer( + &mut self, + params: StickyKeyAction, + event: KeyboardEvent, + deadline: StickyKeyDeadline, + ) { let layer_num = params.layer.expect("layer shape requires a layer"); - let config = self.keymap.sticky_key_config(); - let deadline = StickyKeyDeadline::from_timeout(config.timeout); if event.pressed { // Latch-replacement rule on a single mutually-exclusive latch: a layer SK press @@ -327,10 +336,13 @@ impl Keyboard<'_> { /// Tap-key (alt-tab) shape: send `keep` mods + `key` on every press, hold the mods /// between presses, cycle on each press (`max_repeat`). Ignores /// `activate_on_keypress`/`quick_release`. - async fn process_sticky_tap_key(&mut self, params: StickyKeyAction, event: KeyboardEvent) { - let config = self.keymap.sticky_key_config(); - let deadline = StickyKeyDeadline::from_timeout(config.timeout); - + async fn process_sticky_tap_key( + &mut self, + params: StickyKeyAction, + event: KeyboardEvent, + config: crate::config::StickyKeyConfig, + deadline: StickyKeyDeadline, + ) { if event.pressed { // A repeated press of the same physical tap-key cycles it. A different tap-key, like // any foreign StickyKey shape, replaces the active latch so it gets its own key and From 1f12fe63406fec8604ba7587681dbcf61b5791c0 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:26:01 -0500 Subject: [PATCH 089/119] Revert "perf(sticky-key): share action setup" This reverts commit 5b3398598d866d06665325e2824bf8935faf640b. --- rmk/src/keyboard/sticky_key.rs | 40 ++++++++++++---------------------- 1 file changed, 14 insertions(+), 26 deletions(-) diff --git a/rmk/src/keyboard/sticky_key.rs b/rmk/src/keyboard/sticky_key.rs index 6aaa41037..2e1c6ae65 100644 --- a/rmk/src/keyboard/sticky_key.rs +++ b/rmk/src/keyboard/sticky_key.rs @@ -158,27 +158,21 @@ impl StickyKeyState { impl Keyboard<'_> { pub(crate) async fn process_action_sticky_key(&mut self, params: StickyKeyAction, event: KeyboardEvent) { - let config = self.keymap.sticky_key_config(); - let deadline = StickyKeyDeadline::from_timeout(config.timeout); - if params.layer.is_some() { - self.process_sticky_layer(params, event, deadline).await; + self.process_sticky_layer(params, event).await; } else if params.key == KeyCode::Hid(HidKeyCode::No) { - self.process_sticky_pure_mod(params, event, config, deadline).await; + self.process_sticky_pure_mod(params, event).await; } else { - self.process_sticky_tap_key(params, event, config, deadline).await; + self.process_sticky_tap_key(params, event).await; } } /// Pure-mod (OSM) shape: accumulate the modifier across taps, apply it through the /// terminating key, honor `activate_on_keypress`/`quick_release`. - async fn process_sticky_pure_mod( - &mut self, - params: StickyKeyAction, - event: KeyboardEvent, - config: crate::config::StickyKeyConfig, - deadline: StickyKeyDeadline, - ) { + async fn process_sticky_pure_mod(&mut self, params: StickyKeyAction, event: KeyboardEvent) { + let config = self.keymap.sticky_key_config(); + let deadline = StickyKeyDeadline::from_timeout(config.timeout); + if event.pressed { // Single mutually-exclusive latch: a pure-mod press accumulates (3c) onto an // existing pure-mod latch, but REPLACES any other shape (layer or tap-key). @@ -254,13 +248,10 @@ impl Keyboard<'_> { /// `process_action_osl`. The layer carries no modifier, so consuming it emits no HID /// report — the foreign key resolves on the active layer in `process_action_key` before /// the latch is consumed. - async fn process_sticky_layer( - &mut self, - params: StickyKeyAction, - event: KeyboardEvent, - deadline: StickyKeyDeadline, - ) { + async fn process_sticky_layer(&mut self, params: StickyKeyAction, event: KeyboardEvent) { let layer_num = params.layer.expect("layer shape requires a layer"); + let config = self.keymap.sticky_key_config(); + let deadline = StickyKeyDeadline::from_timeout(config.timeout); if event.pressed { // Latch-replacement rule on a single mutually-exclusive latch: a layer SK press @@ -336,13 +327,10 @@ impl Keyboard<'_> { /// Tap-key (alt-tab) shape: send `keep` mods + `key` on every press, hold the mods /// between presses, cycle on each press (`max_repeat`). Ignores /// `activate_on_keypress`/`quick_release`. - async fn process_sticky_tap_key( - &mut self, - params: StickyKeyAction, - event: KeyboardEvent, - config: crate::config::StickyKeyConfig, - deadline: StickyKeyDeadline, - ) { + async fn process_sticky_tap_key(&mut self, params: StickyKeyAction, event: KeyboardEvent) { + let config = self.keymap.sticky_key_config(); + let deadline = StickyKeyDeadline::from_timeout(config.timeout); + if event.pressed { // A repeated press of the same physical tap-key cycles it. A different tap-key, like // any foreign StickyKey shape, replaces the active latch so it gets its own key and From 603ad068efda4e6bc5f74cc1d0546b9091a8cca1 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:34:34 -0500 Subject: [PATCH 090/119] fix(sticky-key): store HID keycodes directly --- rmk-types/src/action/mod.rs | 6 +++--- rmk/src/host/via/keycode_convert.rs | 18 +++++++++--------- rmk/src/keyboard/sticky_key.rs | 22 +++++++++------------- rmk/src/layout_macro.rs | 6 +++--- 4 files changed, 24 insertions(+), 28 deletions(-) diff --git a/rmk-types/src/action/mod.rs b/rmk-types/src/action/mod.rs index ce7c2518c..b064f4d67 100644 --- a/rmk-types/src/action/mod.rs +++ b/rmk-types/src/action/mod.rs @@ -26,7 +26,7 @@ use postcard::experimental::max_size::MaxSize; use postcard_schema::Schema; use serde::{Deserialize, Serialize}; -use crate::keycode::{KeyCode, SpecialKey}; +use crate::keycode::{HidKeyCode, KeyCode, SpecialKey}; use crate::modifier::ModifierCombination; #[cfg(feature = "steno")] use crate::steno::StenoKey; @@ -36,9 +36,9 @@ use crate::steno::StenoKey; #[cfg_attr(feature = "defmt", derive(defmt::Format))] #[cfg_attr(feature = "rmk_protocol", derive(Schema))] pub struct StickyKeyAction { - /// Key sent on each SK press. `KeyCode::Hid(HidKeyCode::No)` selects the pure-mod (OSM) shape + /// HID key sent on each SK press. `HidKeyCode::No` selects the pure-mod (OSM) shape /// when `layer` is `None`; otherwise it's the tap-key (alt-tab) shape. - pub key: KeyCode, + pub key: HidKeyCode, /// Modifiers held between presses (0 = none). Unused for the layer (OSL) shape. pub keep: ModifierCombination, /// `Some(n)` = one-shot-layer (OSL) shape activating layer `n`. diff --git a/rmk/src/host/via/keycode_convert.rs b/rmk/src/host/via/keycode_convert.rs index 3983bb982..4a8ed8a76 100644 --- a/rmk/src/host/via/keycode_convert.rs +++ b/rmk/src/host/via/keycode_convert.rs @@ -91,7 +91,7 @@ pub(crate) fn to_via_keycode(key_action: KeyAction) -> u16 { // OSL, VIA range (same as old OneShotLayer) (_, Some(layer)) if layer < 32 => 0x5280 | layer as u16, // OSM, VIA range (same as old OneShotModifier) - (KeyCode::Hid(HidKeyCode::No), None) => 0x52A0 | ((sk.keep.into_packed_bits() & 0x1F) as u16), + (HidKeyCode::No, None) => 0x52A0 | ((sk.keep.into_packed_bits() & 0x1F) as u16), _ => { warn!("StickyKey {:?} is not supported by VIA", sk); 0 @@ -231,7 +231,7 @@ pub(crate) fn from_via_keycode(via_keycode: u16) -> KeyAction { 0x5280..=0x529F => { let layer = via_keycode as u8 & 0x1F; KeyAction::Single(Action::StickyKey(StickyKeyAction { - key: KeyCode::Hid(HidKeyCode::No), + key: HidKeyCode::No, keep: ModifierCombination::new(), layer: Some(layer), })) @@ -240,7 +240,7 @@ pub(crate) fn from_via_keycode(via_keycode: u16) -> KeyAction { 0x52A0..=0x52BF => { let m = ModifierCombination::from_packed_bits((via_keycode & 0x1F) as u8); KeyAction::Single(Action::StickyKey(StickyKeyAction { - key: KeyCode::Hid(HidKeyCode::No), + key: HidKeyCode::No, keep: m, layer: None, })) @@ -670,7 +670,7 @@ mod test { fn test_vial_osm_round_trip() { // OSM(LCtrl) — VIA range 0x52A0 + packed_bits let osm_ctrl = KeyAction::Single(Action::StickyKey(StickyKeyAction { - key: KeyCode::Hid(HidKeyCode::No), + key: HidKeyCode::No, keep: ModifierCombination::LCTRL, layer: None, })); @@ -681,7 +681,7 @@ mod test { // OSM(LShift) let osm_shift = KeyAction::Single(Action::StickyKey(StickyKeyAction { - key: KeyCode::Hid(HidKeyCode::No), + key: HidKeyCode::No, keep: ModifierCombination::LSHIFT, layer: None, })); @@ -692,7 +692,7 @@ mod test { // OSM(LAlt) — uses VIA range 0x52A0, round-trips through packed bits cleanly now let osm_alt = KeyAction::Single(Action::StickyKey(StickyKeyAction { - key: KeyCode::Hid(HidKeyCode::No), + key: HidKeyCode::No, keep: ModifierCombination::LALT, layer: None, })); @@ -706,7 +706,7 @@ mod test { fn test_vial_osl_round_trip() { // OSL(0) — VIA range 0x5280 + layer let osl_0 = KeyAction::Single(Action::StickyKey(StickyKeyAction { - key: KeyCode::Hid(HidKeyCode::No), + key: HidKeyCode::No, keep: ModifierCombination::new(), layer: Some(0), })); @@ -717,7 +717,7 @@ mod test { // OSL(5) let osl_5 = KeyAction::Single(Action::StickyKey(StickyKeyAction { - key: KeyCode::Hid(HidKeyCode::No), + key: HidKeyCode::No, keep: ModifierCombination::new(), layer: Some(5), })); @@ -730,7 +730,7 @@ mod test { #[test] fn test_vial_does_not_convert_tap_key_sticky_key_to_osm() { let tap_key_sticky_key = KeyAction::Single(Action::StickyKey(StickyKeyAction { - key: KeyCode::Hid(HidKeyCode::Tab), + key: HidKeyCode::Tab, keep: ModifierCombination::LALT, layer: None, })); diff --git a/rmk/src/keyboard/sticky_key.rs b/rmk/src/keyboard/sticky_key.rs index 2e1c6ae65..defa34264 100644 --- a/rmk/src/keyboard/sticky_key.rs +++ b/rmk/src/keyboard/sticky_key.rs @@ -10,7 +10,7 @@ use embassy_time::{Duration, Instant}; use rmk_types::action::StickyKeyAction; -use rmk_types::keycode::{HidKeyCode, KeyCode}; +use rmk_types::keycode::HidKeyCode; use rmk_types::modifier::ModifierCombination; use crate::event::{KeyboardEvent, KeyboardEventPos}; @@ -74,8 +74,8 @@ pub(crate) enum StickyKeyState { /// Physical key that owns this latch. source: KeyboardEventPos, mods: ModifierCombination, - /// `KeyCode::Hid(HidKeyCode::No)` = pure-mod or layer shape; any other key = tap-key shape. - key: KeyCode, + /// `HidKeyCode::No` = pure-mod or layer shape; any other key = tap-key shape. + key: HidKeyCode, /// `Some(n)` = OSL shape; `None` = pure-mod or tap-key shape. layer: Option, phase: SkPhase, @@ -98,7 +98,7 @@ mod size_tests { Active { source: KeyboardEventPos, mods: ModifierCombination, - key: KeyCode, + key: HidKeyCode, layer: Option, phase: SkPhase, pressed: bool, @@ -138,7 +138,7 @@ impl StickyKeyState { matches!( self, StickyKeyState::Active { - key: KeyCode::Hid(HidKeyCode::No), + key: HidKeyCode::No, layer: None, .. } @@ -160,7 +160,7 @@ impl Keyboard<'_> { pub(crate) async fn process_action_sticky_key(&mut self, params: StickyKeyAction, event: KeyboardEvent) { if params.layer.is_some() { self.process_sticky_layer(params, event).await; - } else if params.key == KeyCode::Hid(HidKeyCode::No) { + } else if params.key == HidKeyCode::No { self.process_sticky_pure_mod(params, event).await; } else { self.process_sticky_tap_key(params, event).await; @@ -380,9 +380,7 @@ impl Keyboard<'_> { self.sticky_key_state = StickyKeyState::None; self.send_keyboard_report_with_resolved_modifiers(false).await; } else { - if let KeyCode::Hid(hid_key) = params.key { - self.register_key(hid_key, event); - } + self.register_key(params.key, event); self.send_keyboard_report_with_resolved_modifiers(true).await; } } else { @@ -393,9 +391,7 @@ impl Keyboard<'_> { && *source == event.pos { *pressed = false; - if let KeyCode::Hid(hid_key) = params.key { - self.unregister_key(hid_key, event); - } + self.unregister_key(params.key, event); self.send_keyboard_report_with_resolved_modifiers(false).await; } } @@ -538,7 +534,7 @@ impl Keyboard<'_> { // StickyKey while physically held. Unregister it before clearing the latch so it cannot // remain stuck in the report. if let StickyKeyState::Active { - key: KeyCode::Hid(hid_key), + key: hid_key, layer: None, source, .. diff --git a/rmk/src/layout_macro.rs b/rmk/src/layout_macro.rs index 891503d6b..5596f202c 100644 --- a/rmk/src/layout_macro.rs +++ b/rmk/src/layout_macro.rs @@ -326,7 +326,7 @@ macro_rules! sk { ($key:ident, $keep:expr) => { $crate::types::action::KeyAction::Single($crate::types::action::Action::StickyKey( $crate::types::action::StickyKeyAction { - key: $crate::types::keycode::KeyCode::Hid($crate::types::keycode::HidKeyCode::$key), + key: $crate::types::keycode::HidKeyCode::$key, keep: $keep, layer: None, }, @@ -350,7 +350,7 @@ macro_rules! sk_mod { ($m:expr) => { $crate::types::action::KeyAction::Single($crate::types::action::Action::StickyKey( $crate::types::action::StickyKeyAction { - key: $crate::types::keycode::KeyCode::Hid($crate::types::keycode::HidKeyCode::No), + key: $crate::types::keycode::HidKeyCode::No, keep: $m, layer: None, }, @@ -372,7 +372,7 @@ macro_rules! sk_layer { ($n:literal) => { $crate::types::action::KeyAction::Single($crate::types::action::Action::StickyKey( $crate::types::action::StickyKeyAction { - key: $crate::types::keycode::KeyCode::Hid($crate::types::keycode::HidKeyCode::No), + key: $crate::types::keycode::HidKeyCode::No, keep: $crate::types::modifier::ModifierCombination::new(), layer: Some($n), }, From 4f0e905850623a5ab40ea7c38c473ed77fc0b8e2 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:34:37 -0500 Subject: [PATCH 091/119] feat(sticky-key): add per-shape layer change release --- docs/docs/main/docs/configuration/appendix.md | 4 + docs/docs/main/docs/configuration/behavior.md | 37 +++- rmk-config/src/lib.rs | 6 + rmk-config/src/resolved/behavior.rs | 44 ++++ rmk-macro/src/codegen/behavior.rs | 65 ++++++ rmk/src/config/behavior.rs | 11 +- rmk/src/keyboard.rs | 28 +-- rmk/src/keyboard/sticky_key.rs | 19 ++ rmk/tests/keyboard_sticky_key_test.rs | 193 ++++++++++++++++++ 9 files changed, 383 insertions(+), 24 deletions(-) diff --git a/docs/docs/main/docs/configuration/appendix.md b/docs/docs/main/docs/configuration/appendix.md index bee29d8aa..fe2fbdb7e 100644 --- a/docs/docs/main/docs/configuration/appendix.md +++ b/docs/docs/main/docs/configuration/appendix.md @@ -122,6 +122,10 @@ sticky_key = { quick_release = false, max_repeat = 0, release_on_layer_change = false, + # Optional per-shape overrides; omitted values inherit release_on_layer_change. + # tap_key_release_on_layer_change = true, + # one_shot_mod_release_on_layer_change = false, + # layer_release_on_layer_change = false, } [behavior.morse] diff --git a/docs/docs/main/docs/configuration/behavior.md b/docs/docs/main/docs/configuration/behavior.md index 19cc9f867..e6f7deaf2 100644 --- a/docs/docs/main/docs/configuration/behavior.md +++ b/docs/docs/main/docs/configuration/behavior.md @@ -53,7 +53,36 @@ The `[behavior.sticky_key]` table configures the unified **Sticky Key** (`SK`) f | `activate_on_keypress` | `false` | **Pure-mod SKs only.** When `true`, send the modifier immediately as the SK key itself is pressed, instead of waiting and applying it to the next key. (Also known as One-Shot Sticky Modifiers / OSSM.) | | `quick_release` | `false` | **Pure-mod SKs only.** Release the modifier as soon as the next key is *pressed* (`true`) rather than when it is *released* (`false`, chain mode). | | `max_repeat` | `0` | **Tap-key SKs only.** Caps how many repeated presses of the key keep the modifier held; `0` = unlimited. Pure-mod (`SK(LGui)`) and layer (`SK(MO(n))`) SKs ignore this — they always apply to exactly one following key. | -| `release_on_layer_change` | `false` | Whether a layer change releases the sticky key. `false` = it survives layer changes. | +| `release_on_layer_change` | `false` | Global fallback: whether a layer change releases any sticky-key shape. | +| `tap_key_release_on_layer_change` | unset | Tap-key override for `SK(key, [mods])`. When unset, inherits `release_on_layer_change`. | +| `one_shot_mod_release_on_layer_change` | unset | One-shot-mod override for `SK(mod)` / `OSM(mod)`. When unset, inherits `release_on_layer_change`. | +| `layer_release_on_layer_change` | unset | Layer override for `SK(MO(n))` / `OSL(n)`. When unset, inherits `release_on_layer_change`. | + +The three shape-specific layer-change settings take precedence over the global +`release_on_layer_change` value. This lets a configuration establish one global +default and opt individual shapes in or out. Leaving all three overrides unset +preserves the original global behavior. + +For a layer-shaped sticky key, `layer_release_on_layer_change` applies to a +separate layer transition that occurs while the OSL is active. Activating the +OSL's own one-shot layer does not immediately release itself. + +For example, this releases Alt+Tab-style tap-key sticky keys when a layer changes, +while allowing OSM and OSL actions to survive the same change: + +```toml +[behavior.sticky_key] +release_on_layer_change = false +tap_key_release_on_layer_change = true +``` + +The inverse is also valid. Here every shape releases by default, except pure-mod OSM actions: + +```toml +[behavior.sticky_key] +release_on_layer_change = true +one_shot_mod_release_on_layer_change = false +``` The `quick_release` option in detail: @@ -75,6 +104,10 @@ activate_on_keypress = false quick_release = false max_repeat = 0 release_on_layer_change = false +# Shape-specific overrides are optional and inherit the global value when omitted: +# tap_key_release_on_layer_change = true +# one_shot_mod_release_on_layer_change = false +# layer_release_on_layer_change = false ``` OSSM example (pure-mod SK activates on key press): @@ -117,7 +150,7 @@ Accepted breaking changes: - The old 5-positional `SK(key, [mod], max_repeat, timeout_ms, exit_on_layer_change)` form is **removed** → build error. The trailing knobs now live in `[behavior.sticky_key]`. - The `[behavior.one_shot]` and `[behavior.one_shot_modifiers]` config tables are **removed** → use `[behavior.sticky_key]`. -- The old per-key `exit_on_layer_change` is renamed to the global `release_on_layer_change` (default `false`). +- The old per-key `exit_on_layer_change` is renamed to the global `release_on_layer_change` (default `false`). The optional tap-key, pure-mod, and layer overrides can refine that global value per sticky-key shape. - Tap-key (alt-tab) SKs now have a **1s default timeout** (previously they had no timeout). Set `timeout` higher or rely on the default. ## Combo diff --git a/rmk-config/src/lib.rs b/rmk-config/src/lib.rs index d1ceed453..549768df6 100644 --- a/rmk-config/src/lib.rs +++ b/rmk-config/src/lib.rs @@ -688,6 +688,12 @@ pub struct StickyKeyConfig { pub max_repeat: Option, /// Whether a layer change releases the sticky key. Default false (it survives layer changes). pub release_on_layer_change: Option, + /// Tap-key sticky keys only: overrides `release_on_layer_change` when set. + pub tap_key_release_on_layer_change: Option, + /// One-shot-modifier sticky keys only: overrides `release_on_layer_change` when set. + pub one_shot_mod_release_on_layer_change: Option, + /// Layer sticky keys only: overrides `release_on_layer_change` when set. + pub layer_release_on_layer_change: Option, } /// Configurations for combos diff --git a/rmk-config/src/resolved/behavior.rs b/rmk-config/src/resolved/behavior.rs index e858a4fbd..51c660e0e 100644 --- a/rmk-config/src/resolved/behavior.rs +++ b/rmk-config/src/resolved/behavior.rs @@ -6,6 +6,9 @@ pub struct StickyKeyConfig { pub quick_release: Option, pub max_repeat: Option, pub release_on_layer_change: Option, + pub tap_key_release_on_layer_change: Option, + pub one_shot_mod_release_on_layer_change: Option, + pub layer_release_on_layer_change: Option, } /// Resolved behavioral configuration. @@ -225,6 +228,9 @@ impl crate::KeyboardTomlConfig { quick_release: s.quick_release, max_repeat: s.max_repeat, release_on_layer_change: s.release_on_layer_change, + tap_key_release_on_layer_change: s.tap_key_release_on_layer_change, + one_shot_mod_release_on_layer_change: s.one_shot_mod_release_on_layer_change, + layer_release_on_layer_change: s.layer_release_on_layer_change, }); let auto_mouse_layer = toml_behavior @@ -323,4 +329,42 @@ hold_timeout = "200ms" assert_eq!(morse.profiles["flow_off"].enable_flow_tap, Some(false)); assert_eq!(morse.profiles["inherit"].enable_flow_tap, None); } + + #[test] + fn sticky_key_layer_change_overrides_are_preserved() { + let toml = r#" +[layout] +rows = 1 +cols = 1 +layers = 1 +keymap = [ + [ + ["A"], + ], +] + +[behavior.sticky_key] +release_on_layer_change = true +tap_key_release_on_layer_change = true +one_shot_mod_release_on_layer_change = false +layer_release_on_layer_change = false +"#; + + let unique = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let path = std::env::temp_dir().join(format!( + "rmk-config-sticky-layer-change-{}-{}.toml", + std::process::id(), + unique + )); + + fs::write(&path, toml).unwrap(); + let config = KeyboardTomlConfig::new_from_toml_path_with_event_defaults(&path); + let _ = fs::remove_file(&path); + + let sticky_key = config.behavior().unwrap().sticky_key.unwrap(); + assert_eq!(sticky_key.release_on_layer_change, Some(true)); + assert_eq!(sticky_key.tap_key_release_on_layer_change, Some(true)); + assert_eq!(sticky_key.one_shot_mod_release_on_layer_change, Some(false)); + assert_eq!(sticky_key.layer_release_on_layer_change, Some(false)); + } } diff --git a/rmk-macro/src/codegen/behavior.rs b/rmk-macro/src/codegen/behavior.rs index 88b7d94d4..d7690176d 100644 --- a/rmk-macro/src/codegen/behavior.rs +++ b/rmk-macro/src/codegen/behavior.rs @@ -51,6 +51,28 @@ fn expand_sticky_key(behavior: &Behavior) -> proc_macro2::TokenStream { .as_ref() .and_then(|sk| sk.release_on_layer_change) .unwrap_or(false); + let option_bool = |value: Option| match value { + Some(value) => quote! { ::core::option::Option::Some(#value) }, + None => quote! { ::core::option::Option::None }, + }; + let tap_key_release_on_layer_change = option_bool( + behavior + .sticky_key + .as_ref() + .and_then(|sk| sk.tap_key_release_on_layer_change), + ); + let one_shot_mod_release_on_layer_change = option_bool( + behavior + .sticky_key + .as_ref() + .and_then(|sk| sk.one_shot_mod_release_on_layer_change), + ); + let layer_release_on_layer_change = option_bool( + behavior + .sticky_key + .as_ref() + .and_then(|sk| sk.layer_release_on_layer_change), + ); quote! { ::rmk::config::StickyKeyConfig { @@ -59,6 +81,9 @@ fn expand_sticky_key(behavior: &Behavior) -> proc_macro2::TokenStream { quick_release: #quick_release, max_repeat: #max_repeat, release_on_layer_change: #release_on_layer_change, + tap_key_release_on_layer_change: #tap_key_release_on_layer_change, + one_shot_mod_release_on_layer_change: #one_shot_mod_release_on_layer_change, + layer_release_on_layer_change: #layer_release_on_layer_change, } } } @@ -538,3 +563,43 @@ pub(crate) fn expand_behavior_config(behavior: &Behavior) -> proc_macro2::TokenS }; } } + +#[cfg(test)] +mod tests { + use super::*; + use rmk_config::resolved::behavior::StickyKeyConfig; + + #[test] + fn sticky_key_codegen_preserves_shape_overrides() { + let behavior = Behavior { + tri_layer: None, + combos: None, + macros: None, + forks: None, + morse: None, + sticky_key: Some(StickyKeyConfig { + timeout_ms: None, + activate_on_keypress: None, + quick_release: None, + max_repeat: None, + release_on_layer_change: Some(false), + tap_key_release_on_layer_change: Some(true), + one_shot_mod_release_on_layer_change: Some(false), + layer_release_on_layer_change: None, + }), + auto_mouse_layer: Vec::new(), + }; + + let tokens = expand_sticky_key(&behavior).to_string().replace(' ', ""); + assert!(tokens.contains("release_on_layer_change:false")); + assert!( + tokens.contains("tap_key_release_on_layer_change:::core::option::Option::Some(true)") + ); + assert!( + tokens.contains( + "one_shot_mod_release_on_layer_change:::core::option::Option::Some(false)" + ) + ); + assert!(tokens.contains("layer_release_on_layer_change:::core::option::Option::None")); + } +} diff --git a/rmk/src/config/behavior.rs b/rmk/src/config/behavior.rs index 29b80649d..317dd5442 100644 --- a/rmk/src/config/behavior.rs +++ b/rmk/src/config/behavior.rs @@ -115,8 +115,14 @@ pub struct StickyKeyConfig { pub quick_release: bool, /// 0 = infinite; governs tap-key cycling. Default 0. pub max_repeat: u16, - /// true = a layer change releases the SK. Default false (survives). + /// Fallback used when the active SK shape has no layer-change override. pub release_on_layer_change: bool, + /// Tap-key SK override. `None` inherits `release_on_layer_change`. + pub tap_key_release_on_layer_change: Option, + /// One-shot-mod SK override. `None` inherits `release_on_layer_change`. + pub one_shot_mod_release_on_layer_change: Option, + /// Layer SK override. `None` inherits `release_on_layer_change`. + pub layer_release_on_layer_change: Option, } impl Default for StickyKeyConfig { @@ -127,6 +133,9 @@ impl Default for StickyKeyConfig { quick_release: false, max_repeat: 0, release_on_layer_change: false, + tap_key_release_on_layer_change: None, + one_shot_mod_release_on_layer_change: None, + layer_release_on_layer_change: None, } } } diff --git a/rmk/src/keyboard.rs b/rmk/src/keyboard.rs index ae9443e53..d1de32b9e 100644 --- a/rmk/src/keyboard.rs +++ b/rmk/src/keyboard.rs @@ -1256,18 +1256,14 @@ impl<'a> Keyboard<'a> { // Reactivate the layer after the key is released if event.pressed { self.keymap.deactivate_layer(layer_num); - if self.keymap.sticky_key_config().release_on_layer_change { - self.release_sticky_key_if_active().await; - } + self.release_sticky_key_on_layer_change().await; } } Action::LayerToggle(layer_num) => { // Toggle a layer when the key is released if !event.pressed { self.keymap.toggle_layer(layer_num); - if self.keymap.sticky_key_config().release_on_layer_change { - self.release_sticky_key_if_active().await; - } + self.release_sticky_key_on_layer_change().await; } } Action::LayerToggleOnly(layer_num) => { @@ -1283,24 +1279,18 @@ impl<'a> Keyboard<'a> { } // Activate the target layer self.keymap.activate_layer(layer_num); - if self.keymap.sticky_key_config().release_on_layer_change { - self.release_sticky_key_if_active().await; - } + self.release_sticky_key_on_layer_change().await; } } Action::DefaultLayer(layer_num) => { // Set the default layer self.keymap.set_default_layer(layer_num); - if self.keymap.sticky_key_config().release_on_layer_change { - self.release_sticky_key_if_active().await; - } + self.release_sticky_key_on_layer_change().await; } Action::PersistentDefaultLayer(layer_num) => { // Set the default layer and persist it so it survives a reboot self.keymap.set_default_layer(layer_num); - if self.keymap.sticky_key_config().release_on_layer_change { - self.release_sticky_key_if_active().await; - } + self.release_sticky_key_on_layer_change().await; // Persist only if the layer was valid (set_default_layer rejects out-of-range) #[cfg(feature = "storage")] if event.pressed && self.keymap.get_default_layer() == layer_num { @@ -1616,14 +1606,10 @@ impl<'a> Keyboard<'a> { // Change layer state only when the key's state is changed if event.pressed { self.keymap.activate_layer(layer_num); - if self.keymap.sticky_key_config().release_on_layer_change { - self.release_sticky_key_if_active().await; - } + self.release_sticky_key_on_layer_change().await; } else { self.keymap.deactivate_layer(layer_num); - if self.keymap.sticky_key_config().release_on_layer_change { - self.release_sticky_key_if_active().await; - } + self.release_sticky_key_on_layer_change().await; } } diff --git a/rmk/src/keyboard/sticky_key.rs b/rmk/src/keyboard/sticky_key.rs index defa34264..fe9c20d10 100644 --- a/rmk/src/keyboard/sticky_key.rs +++ b/rmk/src/keyboard/sticky_key.rs @@ -157,6 +157,25 @@ impl StickyKeyState { } impl Keyboard<'_> { + /// Release the active StickyKey when its shape-specific layer-change policy says to. + /// A shape override takes precedence over the global fallback. + pub(crate) async fn release_sticky_key_on_layer_change(&mut self) { + let config = self.keymap.sticky_key_config(); + let shape_override = if self.sticky_key_state.is_tap_key() { + config.tap_key_release_on_layer_change + } else if self.sticky_key_state.is_pure_mod() { + config.one_shot_mod_release_on_layer_change + } else if self.sticky_key_state.is_layer() { + config.layer_release_on_layer_change + } else { + return; + }; + + if shape_override.unwrap_or(config.release_on_layer_change) { + self.release_sticky_key_if_active().await; + } + } + pub(crate) async fn process_action_sticky_key(&mut self, params: StickyKeyAction, event: KeyboardEvent) { if params.layer.is_some() { self.process_sticky_layer(params, event).await; diff --git a/rmk/tests/keyboard_sticky_key_test.rs b/rmk/tests/keyboard_sticky_key_test.rs index 6674bbe88..0e375eb88 100644 --- a/rmk/tests/keyboard_sticky_key_test.rs +++ b/rmk/tests/keyboard_sticky_key_test.rs @@ -111,6 +111,42 @@ fn create_test_keyboard_mixed() -> Keyboard<'static> { Keyboard::new(wrap_keymap(KEYMAP_MIXED, per_key_config, behavior_config)) } +// Layer-change policy keymaps. The transparent layer positions keep each action +// reachable while another layer is active, and the A/B/C keys reveal which +// sticky layer remains selected after a momentary layer change. +const KEYMAP_PURE_MOD_LAYER_CHANGE: [[[KeyAction; 3]; 1]; 2] = [ + [[sk_mod!(ModifierCombination::LSHIFT), mo!(1), k!(A)]], + [[a!(Transparent), a!(Transparent), a!(Transparent)]], +]; + +const KEYMAP_OSL_LAYER_CHANGE: [[[KeyAction; 3]; 1]; 3] = [ + [[sk_layer!(1), mo!(2), k!(A)]], + [[a!(Transparent), a!(Transparent), k!(B)]], + [[a!(Transparent), a!(Transparent), k!(C)]], +]; + +fn create_pure_mod_layer_change_keyboard(sticky_key: StickyKeyConfig) -> Keyboard<'static> { + let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig { + sticky_key, + ..BehaviorConfig::default() + })); + let per_key_config: &'static PositionalConfig<1, 3> = Box::leak(Box::new(PositionalConfig::default())); + Keyboard::new(wrap_keymap( + KEYMAP_PURE_MOD_LAYER_CHANGE, + per_key_config, + behavior_config, + )) +} + +fn create_osl_layer_change_keyboard(sticky_key: StickyKeyConfig) -> Keyboard<'static> { + let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig { + sticky_key, + ..BehaviorConfig::default() + })); + let per_key_config: &'static PositionalConfig<1, 3> = Box::leak(Box::new(PositionalConfig::default())); + Keyboard::new(wrap_keymap(KEYMAP_OSL_LAYER_CHANGE, per_key_config, behavior_config)) +} + fn create_test_keyboard() -> Keyboard<'static> { static BEHAVIOR_CONFIG: static_cell::StaticCell = static_cell::StaticCell::new(); let behavior_config = BEHAVIOR_CONFIG.init(BehaviorConfig { @@ -149,6 +185,163 @@ fn create_test_keyboard_with_behavior_config(config: BehaviorConfig) -> Keyboard Keyboard::new(wrap_keymap(KEYMAP, per_key_config, behavior_config)) } +#[test] +fn sticky_key_config_layer_change_overrides_do_not_increase_struct_size() { + assert_eq!(core::mem::size_of::(), 16); +} + +/// A tap-key override can enable layer-change release while the global fallback is disabled. +#[test] +fn tap_key_layer_change_override_enables_release() { + key_sequence_test! { + keyboard: create_test_keyboard_with_behavior_config(BehaviorConfig { + sticky_key: StickyKeyConfig { + release_on_layer_change: false, + tap_key_release_on_layer_change: Some(true), + ..StickyKeyConfig::default() + }, + ..BehaviorConfig::default() + }), + sequence: [ + [0, 3, true, 10], + [0, 0, true, 10], + [0, 0, false, 10], + [0, 3, false, 10], + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], + [KC_LALT, [0, 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + ] + }; +} + +/// A tap-key override can disable layer-change release while the global fallback is enabled. +#[test] +fn tap_key_layer_change_override_disables_release() { + key_sequence_test! { + keyboard: create_test_keyboard_with_behavior_config(BehaviorConfig { + sticky_key: StickyKeyConfig { + release_on_layer_change: true, + tap_key_release_on_layer_change: Some(false), + ..StickyKeyConfig::default() + }, + ..BehaviorConfig::default() + }), + sequence: [ + [0, 3, true, 10], + [0, 0, true, 10], + [0, 0, false, 10], + [0, 3, false, 10], + [0, 0, true, 10], + [0, 0, false, 10], + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], + [KC_LALT, [0, 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + [0, [kc_to_u8!(A), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + ] + }; +} + +/// The one-shot-mod override wins over an enabled global fallback. +#[test] +fn pure_mod_layer_change_override_disables_release() { + key_sequence_test! { + keyboard: create_pure_mod_layer_change_keyboard(StickyKeyConfig { + release_on_layer_change: true, + one_shot_mod_release_on_layer_change: Some(false), + ..StickyKeyConfig::default() + }), + sequence: [ + [0, 0, true, 10], + [0, 0, false, 10], + [0, 1, true, 10], + [0, 1, false, 10], + [0, 2, true, 10], + [0, 2, false, 10], + ], + expected_reports: [ + [KC_LSHIFT, [kc_to_u8!(A), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + ] + }; +} + +/// The one-shot-mod override can enable release over a disabled global fallback. +#[test] +fn pure_mod_layer_change_override_enables_release() { + key_sequence_test! { + keyboard: create_pure_mod_layer_change_keyboard(StickyKeyConfig { + release_on_layer_change: false, + one_shot_mod_release_on_layer_change: Some(true), + ..StickyKeyConfig::default() + }), + sequence: [ + [0, 0, true, 10], + [0, 0, false, 10], + [0, 1, true, 10], + [0, 1, false, 10], + [0, 2, true, 10], + [0, 2, false, 10], + ], + expected_reports: [ + [0, [kc_to_u8!(A), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + ] + }; +} + +/// The layer-shape override wins over an enabled global fallback. +#[test] +fn osl_layer_change_override_disables_release() { + key_sequence_test! { + keyboard: create_osl_layer_change_keyboard(StickyKeyConfig { + release_on_layer_change: true, + layer_release_on_layer_change: Some(false), + ..StickyKeyConfig::default() + }), + sequence: [ + [0, 0, true, 10], + [0, 0, false, 10], + [0, 1, true, 10], + [0, 1, false, 10], + [0, 2, true, 10], + [0, 2, false, 10], + ], + expected_reports: [ + [0, [kc_to_u8!(B), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + ] + }; +} + +/// The layer-shape override can enable release over a disabled global fallback. +#[test] +fn osl_layer_change_override_enables_release() { + key_sequence_test! { + keyboard: create_osl_layer_change_keyboard(StickyKeyConfig { + release_on_layer_change: false, + layer_release_on_layer_change: Some(true), + ..StickyKeyConfig::default() + }), + sequence: [ + [0, 0, true, 10], + [0, 0, false, 10], + [0, 1, true, 10], + [0, 1, false, 10], + [0, 2, true, 10], + [0, 2, false, 10], + ], + expected_reports: [ + [0, [kc_to_u8!(A), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + ] + }; +} + /// StickyKey Test 1: Basic SK flow — press SK twice while MO held /// /// Sequence: From 9409c7175d94263f3613c2f98e092aa91ada4c18 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:55:34 -0500 Subject: [PATCH 092/119] feat(sticky-key): clarify one-shot-layer release setting --- docs/docs/main/docs/configuration/appendix.md | 2 +- docs/docs/main/docs/configuration/behavior.md | 6 +++--- rmk-config/src/lib.rs | 4 ++-- rmk-config/src/resolved/behavior.rs | 8 ++++---- rmk-macro/src/codegen/behavior.rs | 10 +++++----- rmk/src/config/behavior.rs | 6 +++--- rmk/src/keyboard/sticky_key.rs | 2 +- rmk/tests/keyboard_sticky_key_test.rs | 4 ++-- 8 files changed, 21 insertions(+), 21 deletions(-) diff --git a/docs/docs/main/docs/configuration/appendix.md b/docs/docs/main/docs/configuration/appendix.md index fe2fbdb7e..493752ee3 100644 --- a/docs/docs/main/docs/configuration/appendix.md +++ b/docs/docs/main/docs/configuration/appendix.md @@ -125,7 +125,7 @@ sticky_key = { # Optional per-shape overrides; omitted values inherit release_on_layer_change. # tap_key_release_on_layer_change = true, # one_shot_mod_release_on_layer_change = false, - # layer_release_on_layer_change = false, + # one_shot_layer_release_on_layer_change = false, } [behavior.morse] diff --git a/docs/docs/main/docs/configuration/behavior.md b/docs/docs/main/docs/configuration/behavior.md index e6f7deaf2..eb3bf6277 100644 --- a/docs/docs/main/docs/configuration/behavior.md +++ b/docs/docs/main/docs/configuration/behavior.md @@ -56,14 +56,14 @@ The `[behavior.sticky_key]` table configures the unified **Sticky Key** (`SK`) f | `release_on_layer_change` | `false` | Global fallback: whether a layer change releases any sticky-key shape. | | `tap_key_release_on_layer_change` | unset | Tap-key override for `SK(key, [mods])`. When unset, inherits `release_on_layer_change`. | | `one_shot_mod_release_on_layer_change` | unset | One-shot-mod override for `SK(mod)` / `OSM(mod)`. When unset, inherits `release_on_layer_change`. | -| `layer_release_on_layer_change` | unset | Layer override for `SK(MO(n))` / `OSL(n)`. When unset, inherits `release_on_layer_change`. | +| `one_shot_layer_release_on_layer_change` | unset | One-shot-layer override for `SK(MO(n))` / `OSL(n)`. When unset, inherits `release_on_layer_change`. | The three shape-specific layer-change settings take precedence over the global `release_on_layer_change` value. This lets a configuration establish one global default and opt individual shapes in or out. Leaving all three overrides unset preserves the original global behavior. -For a layer-shaped sticky key, `layer_release_on_layer_change` applies to a +For a layer-shaped sticky key, `one_shot_layer_release_on_layer_change` applies to a separate layer transition that occurs while the OSL is active. Activating the OSL's own one-shot layer does not immediately release itself. @@ -107,7 +107,7 @@ release_on_layer_change = false # Shape-specific overrides are optional and inherit the global value when omitted: # tap_key_release_on_layer_change = true # one_shot_mod_release_on_layer_change = false -# layer_release_on_layer_change = false +# one_shot_layer_release_on_layer_change = false ``` OSSM example (pure-mod SK activates on key press): diff --git a/rmk-config/src/lib.rs b/rmk-config/src/lib.rs index 549768df6..1aace478b 100644 --- a/rmk-config/src/lib.rs +++ b/rmk-config/src/lib.rs @@ -692,8 +692,8 @@ pub struct StickyKeyConfig { pub tap_key_release_on_layer_change: Option, /// One-shot-modifier sticky keys only: overrides `release_on_layer_change` when set. pub one_shot_mod_release_on_layer_change: Option, - /// Layer sticky keys only: overrides `release_on_layer_change` when set. - pub layer_release_on_layer_change: Option, + /// One-shot-layer sticky keys only: overrides `release_on_layer_change` when set. + pub one_shot_layer_release_on_layer_change: Option, } /// Configurations for combos diff --git a/rmk-config/src/resolved/behavior.rs b/rmk-config/src/resolved/behavior.rs index 51c660e0e..b862fc8d4 100644 --- a/rmk-config/src/resolved/behavior.rs +++ b/rmk-config/src/resolved/behavior.rs @@ -8,7 +8,7 @@ pub struct StickyKeyConfig { pub release_on_layer_change: Option, pub tap_key_release_on_layer_change: Option, pub one_shot_mod_release_on_layer_change: Option, - pub layer_release_on_layer_change: Option, + pub one_shot_layer_release_on_layer_change: Option, } /// Resolved behavioral configuration. @@ -230,7 +230,7 @@ impl crate::KeyboardTomlConfig { release_on_layer_change: s.release_on_layer_change, tap_key_release_on_layer_change: s.tap_key_release_on_layer_change, one_shot_mod_release_on_layer_change: s.one_shot_mod_release_on_layer_change, - layer_release_on_layer_change: s.layer_release_on_layer_change, + one_shot_layer_release_on_layer_change: s.one_shot_layer_release_on_layer_change, }); let auto_mouse_layer = toml_behavior @@ -347,7 +347,7 @@ keymap = [ release_on_layer_change = true tap_key_release_on_layer_change = true one_shot_mod_release_on_layer_change = false -layer_release_on_layer_change = false +one_shot_layer_release_on_layer_change = false "#; let unique = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); @@ -365,6 +365,6 @@ layer_release_on_layer_change = false assert_eq!(sticky_key.release_on_layer_change, Some(true)); assert_eq!(sticky_key.tap_key_release_on_layer_change, Some(true)); assert_eq!(sticky_key.one_shot_mod_release_on_layer_change, Some(false)); - assert_eq!(sticky_key.layer_release_on_layer_change, Some(false)); + assert_eq!(sticky_key.one_shot_layer_release_on_layer_change, Some(false)); } } diff --git a/rmk-macro/src/codegen/behavior.rs b/rmk-macro/src/codegen/behavior.rs index d7690176d..0bc33ca98 100644 --- a/rmk-macro/src/codegen/behavior.rs +++ b/rmk-macro/src/codegen/behavior.rs @@ -67,11 +67,11 @@ fn expand_sticky_key(behavior: &Behavior) -> proc_macro2::TokenStream { .as_ref() .and_then(|sk| sk.one_shot_mod_release_on_layer_change), ); - let layer_release_on_layer_change = option_bool( + let one_shot_layer_release_on_layer_change = option_bool( behavior .sticky_key .as_ref() - .and_then(|sk| sk.layer_release_on_layer_change), + .and_then(|sk| sk.one_shot_layer_release_on_layer_change), ); quote! { @@ -83,7 +83,7 @@ fn expand_sticky_key(behavior: &Behavior) -> proc_macro2::TokenStream { release_on_layer_change: #release_on_layer_change, tap_key_release_on_layer_change: #tap_key_release_on_layer_change, one_shot_mod_release_on_layer_change: #one_shot_mod_release_on_layer_change, - layer_release_on_layer_change: #layer_release_on_layer_change, + one_shot_layer_release_on_layer_change: #one_shot_layer_release_on_layer_change, } } } @@ -585,7 +585,7 @@ mod tests { release_on_layer_change: Some(false), tap_key_release_on_layer_change: Some(true), one_shot_mod_release_on_layer_change: Some(false), - layer_release_on_layer_change: None, + one_shot_layer_release_on_layer_change: None, }), auto_mouse_layer: Vec::new(), }; @@ -600,6 +600,6 @@ mod tests { "one_shot_mod_release_on_layer_change:::core::option::Option::Some(false)" ) ); - assert!(tokens.contains("layer_release_on_layer_change:::core::option::Option::None")); + assert!(tokens.contains("one_shot_layer_release_on_layer_change:::core::option::Option::None")); } } diff --git a/rmk/src/config/behavior.rs b/rmk/src/config/behavior.rs index 317dd5442..300b3c3f4 100644 --- a/rmk/src/config/behavior.rs +++ b/rmk/src/config/behavior.rs @@ -121,8 +121,8 @@ pub struct StickyKeyConfig { pub tap_key_release_on_layer_change: Option, /// One-shot-mod SK override. `None` inherits `release_on_layer_change`. pub one_shot_mod_release_on_layer_change: Option, - /// Layer SK override. `None` inherits `release_on_layer_change`. - pub layer_release_on_layer_change: Option, + /// One-shot-layer SK override. `None` inherits `release_on_layer_change`. + pub one_shot_layer_release_on_layer_change: Option, } impl Default for StickyKeyConfig { @@ -135,7 +135,7 @@ impl Default for StickyKeyConfig { release_on_layer_change: false, tap_key_release_on_layer_change: None, one_shot_mod_release_on_layer_change: None, - layer_release_on_layer_change: None, + one_shot_layer_release_on_layer_change: None, } } } diff --git a/rmk/src/keyboard/sticky_key.rs b/rmk/src/keyboard/sticky_key.rs index fe9c20d10..ee8f3cfca 100644 --- a/rmk/src/keyboard/sticky_key.rs +++ b/rmk/src/keyboard/sticky_key.rs @@ -166,7 +166,7 @@ impl Keyboard<'_> { } else if self.sticky_key_state.is_pure_mod() { config.one_shot_mod_release_on_layer_change } else if self.sticky_key_state.is_layer() { - config.layer_release_on_layer_change + config.one_shot_layer_release_on_layer_change } else { return; }; diff --git a/rmk/tests/keyboard_sticky_key_test.rs b/rmk/tests/keyboard_sticky_key_test.rs index 0e375eb88..32a2eab04 100644 --- a/rmk/tests/keyboard_sticky_key_test.rs +++ b/rmk/tests/keyboard_sticky_key_test.rs @@ -300,7 +300,7 @@ fn osl_layer_change_override_disables_release() { key_sequence_test! { keyboard: create_osl_layer_change_keyboard(StickyKeyConfig { release_on_layer_change: true, - layer_release_on_layer_change: Some(false), + one_shot_layer_release_on_layer_change: Some(false), ..StickyKeyConfig::default() }), sequence: [ @@ -324,7 +324,7 @@ fn osl_layer_change_override_enables_release() { key_sequence_test! { keyboard: create_osl_layer_change_keyboard(StickyKeyConfig { release_on_layer_change: false, - layer_release_on_layer_change: Some(true), + one_shot_layer_release_on_layer_change: Some(true), ..StickyKeyConfig::default() }), sequence: [ From 3c2a04480542ffe71df36b9064306c21f302cef4 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:07:50 -0500 Subject: [PATCH 093/119] fix(sticky-key): handle held tap-key replacement and timeout --- rmk/src/keyboard/sticky_key.rs | 20 ++++++++++++++++- rmk/tests/keyboard_sticky_key_test.rs | 31 ++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/rmk/src/keyboard/sticky_key.rs b/rmk/src/keyboard/sticky_key.rs index ee8f3cfca..b15f6f1a4 100644 --- a/rmk/src/keyboard/sticky_key.rs +++ b/rmk/src/keyboard/sticky_key.rs @@ -273,6 +273,13 @@ impl Keyboard<'_> { let deadline = StickyKeyDeadline::from_timeout(config.timeout); if event.pressed { + // A held tap-key owns a registered HID key. Release it before this + // layer shape takes over the shared latch so its later physical + // release cannot leave that HID key stuck in the report. + if self.sticky_key_state.is_tap_key() { + self.release_sticky_key_if_active().await; + } + // Latch-replacement rule on a single mutually-exclusive latch: a layer SK press // takes over the latch. Deactivate any previously-latched OSL layer first, then // drop any latched mods/tap-key. A layer-on-layer press keeps the existing phase @@ -406,10 +413,21 @@ impl Keyboard<'_> { // Only unregister and report if SK was active (key was registered on press). // If max_repeat deactivated SK silently on the press event, the key was never // registered, so the release is a no-op. - if let StickyKeyState::Active { source, pressed, .. } = &mut self.sticky_key_state + if let StickyKeyState::Active { + source, + pressed, + deadline, + .. + } = &mut self.sticky_key_state && *source == event.pos { *pressed = false; + // A timeout that fired while this key was held cleared its deadline so + // this physical release could unregister the tap key safely. Re-arm the + // latched modifier now; otherwise it would remain active indefinitely. + if deadline.get().is_none() { + *deadline = StickyKeyDeadline::from_timeout(config.timeout); + } self.unregister_key(params.key, event); self.send_keyboard_report_with_resolved_modifiers(false).await; } diff --git a/rmk/tests/keyboard_sticky_key_test.rs b/rmk/tests/keyboard_sticky_key_test.rs index 32a2eab04..d09ba1c3c 100644 --- a/rmk/tests/keyboard_sticky_key_test.rs +++ b/rmk/tests/keyboard_sticky_key_test.rs @@ -825,6 +825,29 @@ fn test_sk_tap_key_replaces_layer() { }; } +/// A layer-shaped SK must release a physically held tap-key before replacing the +/// shared latch; otherwise the displaced tap key remains registered indefinitely. +#[test] +fn test_sk_layer_replaces_held_tap_key_without_sticking() { + key_sequence_test! { + keyboard: create_test_keyboard_mixed(), + sequence: [ + [0, 1, true, 0], // Press SK(Tab, LAlt) + [0, 2, true, 0], // Press SK(MO(1)) while Tab is still held → releases Tab first + [0, 1, false, 0], // Release displaced tap-key: must be ignored + [0, 2, false, 0], // Release layer SK → layer 1 is latched + [0, 3, true, 0], // Resolves to Z on the latched layer + [0, 3, false, 0], // Releases Z and consumes the layer latch + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + [0, [kc_to_u8!(Z), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + ] + }; +} + /// StickyKey Test 15: `activate_on_keypress` is IGNORED for tap-key SKs. /// /// Docs: `activate_on_keypress` is "honored only for pure-mod SKs" and is @@ -951,7 +974,8 @@ fn test_sk_timeout_while_held() { /// /// Tap-key SKs use the `Latched` phase while their physical key is down, so phase alone cannot /// tell the timeout handler whether clearing the state is safe. The physical-press flag must keep -/// the state alive until release so that release unregisters Tab and retains the latched Alt. +/// the state alive until release so that release unregisters Tab, retains the latched Alt, and +/// re-arms Alt's timeout. #[test] fn test_tap_sk_timeout_while_held() { key_sequence_test! { @@ -959,10 +983,15 @@ fn test_tap_sk_timeout_while_held() { sequence: [ [0, 0, true, 0], // Press SK(Tab, LAlt) [0, 0, false, 20], // Hold past timeout, then release + [0, 1, true, 20], // Wait past the re-armed timeout, then press A + [0, 1, false, 0], ], expected_reports: [ [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], [KC_LALT, [0, 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + [0, [kc_to_u8!(A), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], ] }; } From a487faaa4473e120f6c77e3feb76e34124643bf0 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:13:55 -0500 Subject: [PATCH 094/119] style: format sticky-key codegen test --- rmk-macro/src/codegen/behavior.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/rmk-macro/src/codegen/behavior.rs b/rmk-macro/src/codegen/behavior.rs index 0bc33ca98..2322794e0 100644 --- a/rmk-macro/src/codegen/behavior.rs +++ b/rmk-macro/src/codegen/behavior.rs @@ -600,6 +600,8 @@ mod tests { "one_shot_mod_release_on_layer_change:::core::option::Option::Some(false)" ) ); - assert!(tokens.contains("one_shot_layer_release_on_layer_change:::core::option::Option::None")); + assert!( + tokens.contains("one_shot_layer_release_on_layer_change:::core::option::Option::None") + ); } } From a54b1dc55541a36d3db346b15a6945be2d3e50fa Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Fri, 17 Jul 2026 07:29:23 -0500 Subject: [PATCH 095/119] docs: clarify sticky key timeout behavior --- docs/docs/main/docs/configuration/behavior.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/docs/main/docs/configuration/behavior.md b/docs/docs/main/docs/configuration/behavior.md index eb3bf6277..c53145668 100644 --- a/docs/docs/main/docs/configuration/behavior.md +++ b/docs/docs/main/docs/configuration/behavior.md @@ -58,6 +58,10 @@ The `[behavior.sticky_key]` table configures the unified **Sticky Key** (`SK`) f | `one_shot_mod_release_on_layer_change` | unset | One-shot-mod override for `SK(mod)` / `OSM(mod)`. When unset, inherits `release_on_layer_change`. | | `one_shot_layer_release_on_layer_change` | unset | One-shot-layer override for `SK(MO(n))` / `OSL(n)`. When unset, inherits `release_on_layer_change`. | +`timeout` applies to the sticky latch, not to a key that is still physically held. +Holding an `SK` key longer than the configured timeout will not synthesize a key +release; releasing the physical key then completes the action normally. + The three shape-specific layer-change settings take precedence over the global `release_on_layer_change` value. This lets a configuration establish one global default and opt individual shapes in or out. Leaving all three overrides unset From 968d94bb0075fe6dd60a129820ee3212ad04baa4 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Fri, 17 Jul 2026 19:58:28 -0500 Subject: [PATCH 096/119] feat(sticky-key): add configurable profiles --- docs/docs/main/docs/configuration/appendix.md | 7 +- docs/docs/main/docs/configuration/behavior.md | 69 +++---- docs/docs/main/docs/configuration/layout.md | 8 +- .../main/docs/configuration/rmk_config.md | 3 + rmk-config/src/keymap.pest | 13 +- rmk-config/src/lib.rs | 41 +++- rmk-config/src/resolved/behavior.rs | 116 ++++++++--- rmk-config/src/resolved/build_constants.rs | 2 + rmk-macro/src/codegen/action_parser.rs | 71 +++++-- rmk-macro/src/codegen/behavior.rs | 187 +++++++++--------- rmk-macro/src/codegen/layout.rs | 26 ++- rmk-types/build.rs | 4 + rmk-types/src/action/mod.rs | 2 + rmk/src/config/behavior.rs | 59 ++++-- rmk/src/config/mod.rs | 2 +- rmk/src/host/via/keycode_convert.rs | 10 +- rmk/src/keyboard.rs | 48 +++-- rmk/src/keyboard/sticky_key.rs | 82 +++++--- rmk/src/keymap.rs | 39 +++- rmk/src/layout_macro.rs | 18 ++ rmk/src/storage/mod.rs | 4 +- rmk/tests/keyboard_sticky_key_test.rs | 4 +- 22 files changed, 543 insertions(+), 272 deletions(-) diff --git a/docs/docs/main/docs/configuration/appendix.md b/docs/docs/main/docs/configuration/appendix.md index 493752ee3..f2fd1c5da 100644 --- a/docs/docs/main/docs/configuration/appendix.md +++ b/docs/docs/main/docs/configuration/appendix.md @@ -119,13 +119,8 @@ tri_layer = { sticky_key = { timeout = "1s", activate_on_keypress = false, - quick_release = false, max_repeat = 0, - release_on_layer_change = false, - # Optional per-shape overrides; omitted values inherit release_on_layer_change. - # tap_key_release_on_layer_change = true, - # one_shot_mod_release_on_layer_change = false, - # one_shot_layer_release_on_layer_change = false, + # release_mode = "other_key_release | layer_exit", } [behavior.morse] diff --git a/docs/docs/main/docs/configuration/behavior.md b/docs/docs/main/docs/configuration/behavior.md index c53145668..ce41842b1 100644 --- a/docs/docs/main/docs/configuration/behavior.md +++ b/docs/docs/main/docs/configuration/behavior.md @@ -51,67 +51,42 @@ The `[behavior.sticky_key]` table configures the unified **Sticky Key** (`SK`) f |-------|---------|---------| | `timeout` | `"1s"` | Auto-release an unused sticky key after this idle time. String suffixed `s` or `ms`. | | `activate_on_keypress` | `false` | **Pure-mod SKs only.** When `true`, send the modifier immediately as the SK key itself is pressed, instead of waiting and applying it to the next key. (Also known as One-Shot Sticky Modifiers / OSSM.) | -| `quick_release` | `false` | **Pure-mod SKs only.** Release the modifier as soon as the next key is *pressed* (`true`) rather than when it is *released* (`false`, chain mode). | | `max_repeat` | `0` | **Tap-key SKs only.** Caps how many repeated presses of the key keep the modifier held; `0` = unlimited. Pure-mod (`SK(LGui)`) and layer (`SK(MO(n))`) SKs ignore this — they always apply to exactly one following key. | -| `release_on_layer_change` | `false` | Global fallback: whether a layer change releases any sticky-key shape. | -| `tap_key_release_on_layer_change` | unset | Tap-key override for `SK(key, [mods])`. When unset, inherits `release_on_layer_change`. | -| `one_shot_mod_release_on_layer_change` | unset | One-shot-mod override for `SK(mod)` / `OSM(mod)`. When unset, inherits `release_on_layer_change`. | -| `one_shot_layer_release_on_layer_change` | unset | One-shot-layer override for `SK(MO(n))` / `OSL(n)`. When unset, inherits `release_on_layer_change`. | +| `release_mode` | unset | Optional `|`-separated release triggers: `other_key_press`, `other_key_release`, `layer_enter`, and `layer_exit`. | + +The default table applies to every Sticky Key. Define named overrides in +`[behavior.sticky_key.profiles]` and select one by adding `@name` as the last +argument: `SK(LGui, @gui)`, `SK(Tab, [LAlt], @alt_tab)`, or `SK(MO(1), @nav)`. +Profile fields omitted from a named profile inherit from the default table. + +When `release_mode` is omitted, RMK preserves the legacy shape-native behavior: +tap-key SKs release on another non-modifier key press; OSM and OSL are consumed +on the terminating key release. An explicit mode overrides that behavior. `timeout` applies to the sticky latch, not to a key that is still physically held. Holding an `SK` key longer than the configured timeout will not synthesize a key release; releasing the physical key then completes the action normally. -The three shape-specific layer-change settings take precedence over the global -`release_on_layer_change` value. This lets a configuration establish one global -default and opt individual shapes in or out. Leaving all three overrides unset -preserves the original global behavior. - -For a layer-shaped sticky key, `one_shot_layer_release_on_layer_change` applies to a -separate layer transition that occurs while the OSL is active. Activating the -OSL's own one-shot layer does not immediately release itself. - -For example, this releases Alt+Tab-style tap-key sticky keys when a layer changes, -while allowing OSM and OSL actions to survive the same change: +For example, an Alt+Tab profile can release on another key press or either +direction of a layer transition: ```toml [behavior.sticky_key] -release_on_layer_change = false -tap_key_release_on_layer_change = true -``` - -The inverse is also valid. Here every shape releases by default, except pure-mod OSM actions: +timeout = "1s" -```toml -[behavior.sticky_key] -release_on_layer_change = true -one_shot_mod_release_on_layer_change = false +[behavior.sticky_key.profiles.alt_tab] +timeout = "5s" +release_mode = "other_key_press | layer_enter | layer_exit" ``` -The `quick_release` option in detail: - -- `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. - -:::warning - -`activate_on_keypress` and `quick_release` are honored **only for pure-mod SKs** (`SK(LGui)`, equivalently `OSM(LGui)`). They are **silently ignored** for tap-key SKs (`SK(Tab, [LAlt])`) and layer SKs (`SK(MO(n))` / `OSL(n)`). Both fields tune *when a one-shot modifier is sent and released*: a tap-key SK sends its modifier eagerly and deliberately holds it across repeats, and a layer SK sends no modifier at all — so neither has anything for these fields to tune. - -::: - Default values: ```toml [behavior.sticky_key] timeout = "1s" activate_on_keypress = false -quick_release = false max_repeat = 0 -release_on_layer_change = false -# Shape-specific overrides are optional and inherit the global value when omitted: -# tap_key_release_on_layer_change = true -# one_shot_mod_release_on_layer_change = false -# one_shot_layer_release_on_layer_change = false +# release_mode = "other_key_release | layer_exit" ``` OSSM example (pure-mod SK activates on key press): @@ -121,11 +96,11 @@ OSSM example (pure-mod SK activates on key press): activate_on_keypress = true ``` -Quick-release example (modifier released when next key is pressed): +Press-release-mode example (modifier released when next key is pressed): ```toml [behavior.sticky_key] -quick_release = true +release_mode = "other_key_press" ``` Longer timeout example: @@ -147,14 +122,14 @@ For keymap usage, see `SK(...)` in the [keymap configuration](./layout#keyboard- | `OSL(1)` | `SK(MO(1))` | `OSL(1)` | | `SK(Tab, [LAlt], 0, 0, false)` (5-positional) | `SK(Tab, [LAlt])` + `[behavior.sticky_key]` | — | | `[behavior.one_shot]` `timeout` | `[behavior.sticky_key]` `timeout` | — | -| `[behavior.one_shot_modifiers]` `activate_on_keypress` / `quick_release` | `[behavior.sticky_key]` `activate_on_keypress` / `quick_release` | — | -| `exit_on_layer_change` | `release_on_layer_change` | — | +| `[behavior.one_shot_modifiers]` `activate_on_keypress` / `quick_release` | `[behavior.sticky_key]` `activate_on_keypress` / `release_mode` | — | +| `exit_on_layer_change` | `release_mode = "layer_enter | layer_exit"` | — | Accepted breaking changes: - The old 5-positional `SK(key, [mod], max_repeat, timeout_ms, exit_on_layer_change)` form is **removed** → build error. The trailing knobs now live in `[behavior.sticky_key]`. - The `[behavior.one_shot]` and `[behavior.one_shot_modifiers]` config tables are **removed** → use `[behavior.sticky_key]`. -- The old per-key `exit_on_layer_change` is renamed to the global `release_on_layer_change` (default `false`). The optional tap-key, pure-mod, and layer overrides can refine that global value per sticky-key shape. +- The former `quick_release` and layer-change settings are replaced by `release_mode`; use one or more of `other_key_press`, `other_key_release`, `layer_enter`, and `layer_exit`. - Tap-key (alt-tab) SKs now have a **1s default timeout** (previously they had no timeout). Set `timeout` higher or rely on the default. ## Combo diff --git a/docs/docs/main/docs/configuration/layout.md b/docs/docs/main/docs/configuration/layout.md index 2b5a0ea80..40c0db2b8 100644 --- a/docs/docs/main/docs/configuration/layout.md +++ b/docs/docs/main/docs/configuration/layout.md @@ -123,11 +123,11 @@ The `layer.keys` string should follow several rules: 3. Use `LM(n, modifier)` to create layer activate with modifier action. The modifier can be chained in the same way as `WM` 4. Use `LT(n, key, )` to create a layer activate action or tap key(tap/hold). The `key` here is the RMK [`KeyCode`](https://docs.rs/rmk/latest/rmk/keycode/enum.KeyCode.html), The `profile_name` is optional, which defines the key's [profile](./behavior#per-key-profiles-for-morse-tapdance-tap-hold-fine-tuning) 5. Use `SK(...)` to create a sticky key action — behavior is selected by argument shape: - - `SK(modifier)` — one-shot modifier (also spelled `OSM(modifier)`, an alias): the modifier is held for the next key press, then released automatically. Modifiers chain like `WM`, e.g. `SK(LCtrl|LShift)`. - - `SK(MO(n))` — one-shot layer (also spelled `OSL(n)`, an alias): layer `n` is active for the next key press, then released. - - `SK(key, [modifier])` — tap-key (Alt+Tab-style cycling): the modifier stays held across repeated presses of `key` until any non-SK, non-modifier key is pressed. The modifier list is in `[ ]` and can be chained, e.g. `SK(Tab, [LCtrl|LShift])`. + - `SK(modifier, @profile)` — one-shot modifier (also spelled `OSM(modifier, @profile)`, an alias). The optional `@profile` selects a `[behavior.sticky_key.profiles]` entry. + - `SK(MO(n), @profile)` — one-shot layer (also spelled `OSL(n, @profile)`, an alias). + - `SK(key, [modifier], @profile)` — tap-key (Alt+Tab-style cycling). The modifier list is in `[ ]`; `@profile` is optional. - See [Sticky Key](./behavior#sticky-key) for global config (`timeout`, `activate_on_keypress`, `quick_release`, etc.). + See [Sticky Key](./behavior#sticky-key) for default and named-profile configuration. 6. Use `TT(n)` to create a layer activate or tap toggle action, `n` is the layer number 7. Use `TG(n)` to create a layer toggle action, `n` is the layer number 8. Use `TO(n)` to create a layer toggle only action (activate layer `n` and deactivate all other layers), `n` is the layer number diff --git a/docs/docs/main/docs/configuration/rmk_config.md b/docs/docs/main/docs/configuration/rmk_config.md index 3ac8344f5..f8317bd13 100644 --- a/docs/docs/main/docs/configuration/rmk_config.md +++ b/docs/docs/main/docs/configuration/rmk_config.md @@ -18,6 +18,8 @@ combo_max_length = 4 fork_max_num = 8 # Maximum number of morse keys keyboard can store (max 256) morse_max_num = 8 +# Maximum number of named Sticky Key profiles (max 255) +sticky_key_profile_max_num = 16 # 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. @@ -57,6 +59,7 @@ Increasing the number of combos, forks, morses (tap dances), and macros will inc - `combo_max_length`: Maximum number of keys that can be pressed simultaneously in a combo, default value is 4. - `fork_max_num`: Maximum number of forks for conditional key actions, default value is 8. This value must be between 0 and 256. - `morse_max_num`: Maximum number of morses that can be stored, default value is 8. This value must be between 0 and 256. +- `sticky_key_profile_max_num`: Capacity of the named Sticky Key profile table, 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. (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. diff --git a/rmk-config/src/keymap.pest b/rmk-config/src/keymap.pest index 87c39b8a1..1a77d75dd 100644 --- a/rmk-config/src/keymap.pest +++ b/rmk-config/src/keymap.pest @@ -56,12 +56,13 @@ wm_action = { ^"WM" ~ "(" ~ keycode_name ~ "," ~ modifier_combination ~ ")" } // Rule 4.6: OSM(modifier) - One-Shot Modifier. User-facing alias for the // pure-mod sticky key SK(modifier); desugared to SK in keymap_parser. -osm_action = { ^"OSM" ~ "(" ~ modifier_combination ~ ")" } +sticky_profile_ref = { "@" ~ profile_name } +osm_action = { ^"OSM" ~ "(" ~ modifier_combination ~ ("," ~ sticky_profile_ref)? ~ ")" } // Rule 4.5: OSL(n) - One-Shot Layer. User-facing alias for the layer sticky // key SK(MO(n)); desugared to SK in keymap_parser. Kept out of layer_action so // nonsense like SK(OSL(n)) stays a grammar error. -osl_action = { ^"OSL" ~ "(" ~ layer_reference ~ ")" } +osl_action = { ^"OSL" ~ "(" ~ layer_reference ~ ("," ~ sticky_profile_ref)? ~ ")" } // Rule 4.1: DF(n) - Switch Default Layer df_action = { ^"DF" ~ "(" ~ layer_reference ~ ")" } @@ -119,12 +120,12 @@ trigger_macro_action = { ^"MACRO" ~ "(" ~ number ~ ")" } // bracketed modifier list for SK keep parameter: [LAlt] or [LAlt|LShift] or [] modifier_keep_list = { "[" ~ modifier_combination ~ "]" | "[" ~ "]" } -// SK(key, [mods]) | SK(modifier) | SK(MO(n)) +// SK(key, [mods], @profile) | SK(modifier, @profile) | SK(MO(n), @profile) sk_action = { ^"SK" ~ "(" ~ ( - layer_action // SK(MO(n)) — layer shape - | (keycode_name ~ "," ~ modifier_keep_list) // SK(key, [mods]) — tap-key shape - | modifier_combination // SK(LGui) — pure-mod shape + layer_action ~ ("," ~ sticky_profile_ref)? + | (keycode_name ~ "," ~ modifier_keep_list ~ ("," ~ sticky_profile_ref)?) + | modifier_combination ~ ("," ~ sticky_profile_ref)? ) ~ ")" } diff --git a/rmk-config/src/lib.rs b/rmk-config/src/lib.rs index 1aace478b..3404e08f8 100644 --- a/rmk-config/src/lib.rs +++ b/rmk-config/src/lib.rs @@ -227,6 +227,10 @@ pub(crate) struct RmkConstantsConfig { #[serde_inline_default(8)] #[serde(deserialize_with = "check_morse_max_num")] pub morse_max_num: usize, + /// Capacity of the named Sticky Key profile table (maximum 255). + #[serde_inline_default(16)] + #[serde(deserialize_with = "check_sticky_key_profile_max_num")] + pub sticky_key_profile_max_num: usize, /// Maximum number of patterns a morse key can handle #[serde_inline_default(8)] #[serde(deserialize_with = "check_max_patterns_per_key")] @@ -287,6 +291,17 @@ where Ok(value) } +fn check_sticky_key_profile_max_num<'de, D>(deserializer: D) -> Result +where + D: de::Deserializer<'de>, +{ + let value = Deserialize::deserialize(deserializer)?; + if value > 255 { + panic!("❌ Parse `keyboard.toml` error: sticky_key_profile_max_num must be between 0 and 255, got {value}"); + } + Ok(value) +} + fn check_max_patterns_per_key<'de, D>(deserializer: D) -> Result where D: de::Deserializer<'de>, @@ -319,6 +334,7 @@ impl Default for RmkConstantsConfig { combo_max_length: 4, fork_max_num: 8, morse_max_num: 8, + sticky_key_profile_max_num: 16, max_patterns_per_key: 8, macro_space_size: 256, debounce_time: 20, @@ -682,18 +698,23 @@ pub struct StickyKeyConfig { pub timeout: Option, /// Pure-modifier sticky keys only: activate on the next key press instead of release. Default false. pub activate_on_keypress: Option, - /// Pure-modifier sticky keys only: release the modifier as soon as the next key is pressed. Default false. - pub quick_release: Option, /// Max number of held keys the sticky modifier applies to; 0 = unlimited. Default 0. pub max_repeat: Option, - /// Whether a layer change releases the sticky key. Default false (it survives layer changes). - pub release_on_layer_change: Option, - /// Tap-key sticky keys only: overrides `release_on_layer_change` when set. - pub tap_key_release_on_layer_change: Option, - /// One-shot-modifier sticky keys only: overrides `release_on_layer_change` when set. - pub one_shot_mod_release_on_layer_change: Option, - /// One-shot-layer sticky keys only: overrides `release_on_layer_change` when set. - pub one_shot_layer_release_on_layer_change: Option, + /// `|`-separated release triggers, e.g. "other_key_press | layer_exit". + pub release_mode: Option, + /// Named profiles overriding this default configuration. + #[serde(default)] + pub profiles: HashMap, +} + +/// Per-profile Sticky Key overrides. Omitted fields inherit the default table. +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct StickyKeyProfile { + pub timeout: Option, + pub activate_on_keypress: Option, + pub max_repeat: Option, + pub release_mode: Option, } /// Configurations for combos diff --git a/rmk-config/src/resolved/behavior.rs b/rmk-config/src/resolved/behavior.rs index b862fc8d4..7a353c0de 100644 --- a/rmk-config/src/resolved/behavior.rs +++ b/rmk-config/src/resolved/behavior.rs @@ -3,12 +3,49 @@ use std::collections::HashMap; pub struct StickyKeyConfig { pub timeout_ms: Option, pub activate_on_keypress: Option, - pub quick_release: Option, pub max_repeat: Option, - pub release_on_layer_change: Option, - pub tap_key_release_on_layer_change: Option, - pub one_shot_mod_release_on_layer_change: Option, - pub one_shot_layer_release_on_layer_change: Option, + pub release_mode: Option, + pub profiles: HashMap, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct StickyKeyReleaseMode(pub u8); + +impl StickyKeyReleaseMode { + pub const OTHER_KEY_PRESS: Self = Self(1 << 0); + pub const OTHER_KEY_RELEASE: Self = Self(1 << 1); + pub const LAYER_ENTER: Self = Self(1 << 2); + pub const LAYER_EXIT: Self = Self(1 << 3); + + pub fn parse(value: &str) -> Result { + let mut result = Self::default(); + for part in value.split('|').map(str::trim).filter(|part| !part.is_empty()) { + let flag = match part { + "other_key_press" => Self::OTHER_KEY_PRESS, + "other_key_release" => Self::OTHER_KEY_RELEASE, + "layer_enter" => Self::LAYER_ENTER, + "layer_exit" => Self::LAYER_EXIT, + _ => { + return Err(format!( + "unknown Sticky Key release_mode `{part}`; expected other_key_press, other_key_release, layer_enter, or layer_exit" + )); + } + }; + result.0 |= flag.0; + } + if result.0 == 0 { + return Err("Sticky Key release_mode must contain at least one trigger".to_string()); + } + Ok(result) + } +} + +#[derive(Clone, Debug, Default)] +pub struct StickyKeyProfile { + pub timeout_ms: Option, + pub activate_on_keypress: Option, + pub max_repeat: Option, + pub release_mode: Option, } /// Resolved behavioral configuration. @@ -222,16 +259,35 @@ impl crate::KeyboardTomlConfig { } }); - let sticky_key = toml_behavior.sticky_key.map(|s| StickyKeyConfig { - timeout_ms: s.timeout.as_ref().map(|t| t.0), - activate_on_keypress: s.activate_on_keypress, - quick_release: s.quick_release, - max_repeat: s.max_repeat, - release_on_layer_change: s.release_on_layer_change, - tap_key_release_on_layer_change: s.tap_key_release_on_layer_change, - one_shot_mod_release_on_layer_change: s.one_shot_mod_release_on_layer_change, - one_shot_layer_release_on_layer_change: s.one_shot_layer_release_on_layer_change, - }); + let sticky_key = toml_behavior.sticky_key.map(|s| { + let parse_profile = |p: crate::StickyKeyProfile| -> Result { + Ok(StickyKeyProfile { + timeout_ms: p.timeout.map(|t| t.0), + activate_on_keypress: p.activate_on_keypress, + max_repeat: p.max_repeat, + release_mode: p.release_mode + .as_deref() + .map(StickyKeyReleaseMode::parse) + .transpose()?, + }) + }; + if s.profiles.len() > self.rmk.sticky_key_profile_max_num { + return Err(format!( + "behavior.sticky_key.profiles defines {} profiles, but `[rmk] sticky_key_profile_max_num` is {}. Raise it in keyboard.toml", + s.profiles.len(), self.rmk.sticky_key_profile_max_num + )); + } + let profiles = s.profiles.into_iter() + .map(|(name, profile)| parse_profile(profile).map(|profile| (name, profile))) + .collect::, _>>()?; + Ok(StickyKeyConfig { + timeout_ms: s.timeout.as_ref().map(|t| t.0), + activate_on_keypress: s.activate_on_keypress, + max_repeat: s.max_repeat, + release_mode: s.release_mode.as_deref().map(StickyKeyReleaseMode::parse).transpose()?, + profiles, + }) + }).transpose()?; let auto_mouse_layer = toml_behavior .auto_mouse_layer @@ -286,6 +342,7 @@ mod tests { use std::fs; use std::time::{SystemTime, UNIX_EPOCH}; + use super::StickyKeyReleaseMode; use crate::KeyboardTomlConfig; #[test] @@ -331,7 +388,7 @@ hold_timeout = "200ms" } #[test] - fn sticky_key_layer_change_overrides_are_preserved() { + fn sticky_key_profiles_and_release_modes_are_resolved() { let toml = r#" [layout] rows = 1 @@ -344,10 +401,11 @@ keymap = [ ] [behavior.sticky_key] -release_on_layer_change = true -tap_key_release_on_layer_change = true -one_shot_mod_release_on_layer_change = false -one_shot_layer_release_on_layer_change = false +release_mode = "other_key_release | layer_exit" + +[behavior.sticky_key.profiles.alt_tab] +timeout = "5s" +release_mode = "other_key_press | layer_enter" "#; let unique = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); @@ -362,9 +420,19 @@ one_shot_layer_release_on_layer_change = false let _ = fs::remove_file(&path); let sticky_key = config.behavior().unwrap().sticky_key.unwrap(); - assert_eq!(sticky_key.release_on_layer_change, Some(true)); - assert_eq!(sticky_key.tap_key_release_on_layer_change, Some(true)); - assert_eq!(sticky_key.one_shot_mod_release_on_layer_change, Some(false)); - assert_eq!(sticky_key.one_shot_layer_release_on_layer_change, Some(false)); + assert_eq!( + sticky_key.release_mode, + Some(StickyKeyReleaseMode( + StickyKeyReleaseMode::OTHER_KEY_RELEASE.0 | StickyKeyReleaseMode::LAYER_EXIT.0 + )) + ); + let alt_tab = &sticky_key.profiles["alt_tab"]; + assert_eq!(alt_tab.timeout_ms, Some(5000)); + assert_eq!( + alt_tab.release_mode, + Some(StickyKeyReleaseMode( + StickyKeyReleaseMode::OTHER_KEY_PRESS.0 | StickyKeyReleaseMode::LAYER_ENTER.0 + )) + ); } } diff --git a/rmk-config/src/resolved/build_constants.rs b/rmk-config/src/resolved/build_constants.rs index e1cd0d271..de07385e8 100644 --- a/rmk-config/src/resolved/build_constants.rs +++ b/rmk-config/src/resolved/build_constants.rs @@ -35,6 +35,7 @@ pub struct BuildConstants { pub combo_max_length: usize, pub fork_max_num: usize, pub morse_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, @@ -161,6 +162,7 @@ impl crate::KeyboardTomlConfig { combo_max_length: rmk.combo_max_length, fork_max_num: rmk.fork_max_num, morse_max_num: rmk.morse_max_num, + sticky_key_profile_max_num: rmk.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, diff --git a/rmk-macro/src/codegen/action_parser.rs b/rmk-macro/src/codegen/action_parser.rs index d965bc660..007919a83 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 { @@ -157,6 +157,31 @@ pub(crate) fn expand_profile_name( } } +pub(crate) fn sorted_sticky_profile_names( + profiles: &Option>, +) -> Vec { + let mut names: Vec = profiles + .as_ref() + .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); + let Some(index) = names.iter().position(|candidate| candidate == name) else { + panic!("\n❌ `{name}` profile name is not found in behavior.sticky_key.profiles"); + }; + quote! { #index as u8 } +} + /// Split `s` on commas that are *not* nested inside parentheses. /// /// Each piece is trimmed and empty pieces are dropped. This lets an argument @@ -360,6 +385,7 @@ 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) }; @@ -420,26 +446,49 @@ pub(crate) fn parse_key( } else if lower.starts_with("osl(") { // OSL(n) — user-facing alias for the layer sticky key SK(MO(n)). // Emits the same `sk_layer!` as SK(MO(n)), so the action is byte-identical. - let layer = parse_layer(&key); - quote! { ::rmk::sk_layer!(#layer) } + let args = split_top_level(strip_call(&key)); + let layer = args[0].parse::().unwrap(); + let profile = sticky_profile_index( + args.get(1).map(|p| p.trim_start_matches('@')), + sticky_profiles, + ); + quote! { ::rmk::sk_layer!(#layer, #profile) } } else if lower.starts_with("osm(") { // OSM(modifier) — user-facing alias for the pure-mod sticky key SK(modifier). // Emits the same `sk_mod!` as SK(modifier), so the action is byte-identical. - let modifiers = parse_modifiers(strip_call(&key)); + let args = split_top_level(strip_call(&key)); + let modifiers = parse_modifiers(&args[0]); if modifiers.is_empty() { panic!( "\n\u{274c} keyboard.toml: OSM(modifier) is not valid! \ OSM is an alias for SK(modifier). Usage: OSM(LGui) | OSM(LCtrl | LShift)" ); } - quote! { ::rmk::sk_mod!(#modifiers) } + let profile = sticky_profile_index( + args.get(1).map(|p| p.trim_start_matches('@')), + sticky_profiles, + ); + quote! { ::rmk::sk_mod!(#modifiers, #profile) } } else if lower.starts_with("sk(") { let inner = strip_call(&key).trim(); + 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_inner = action_args.join(", "); + let inner = action_inner.trim(); let inner_lower = inner.to_lowercase(); if inner_lower.starts_with("mo(") { // Layer shape: SK(MO(n)) — OSL replacement. let layer = parse_layer(inner); - quote! { ::rmk::sk_layer!(#layer) } + quote! { ::rmk::sk_layer!(#layer, #profile) } } else if inner.contains('[') { // Tap-key shape: SK(key, [mods]). let bracket_start = inner.find('[').unwrap(); @@ -468,7 +517,7 @@ pub(crate) fn parse_key( ); } - quote! { ::rmk::sk!(#ident, #keep_modifiers) } + quote! { ::rmk::sk!(#ident, #keep_modifiers, #profile) } } else { // Pure-mod shape: SK(LGui) — OSM replacement. // @@ -490,7 +539,7 @@ pub(crate) fn parse_key( Usage: SK(LGui) | SK(Tab, [LAlt]) | SK(MO(n))" ); } - quote! { ::rmk::sk_mod!(#modifiers) } + quote! { ::rmk::sk_mod!(#modifiers, #profile) } } } else { let action = parse_action(&key); @@ -529,7 +578,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 { @@ -632,8 +681,8 @@ mod tests { ]; for (alias, sk, expected_macro) in cases { - let alias_tokens = parse_key(alias.to_string(), &None).to_string(); - let sk_tokens = parse_key(sk.to_string(), &None).to_string(); + let alias_tokens = parse_key(alias.to_string(), &None, &None).to_string(); + let sk_tokens = parse_key(sk.to_string(), &None, &None).to_string(); assert_eq!( alias_tokens, sk_tokens, "{alias} must expand identically to {sk}" diff --git a/rmk-macro/src/codegen/behavior.rs b/rmk-macro/src/codegen/behavior.rs index 2322794e0..981e953da 100644 --- a/rmk-macro/src/codegen/behavior.rs +++ b/rmk-macro/src/codegen/behavior.rs @@ -6,7 +6,7 @@ use quote::quote; use rmk_config::resolved::Behavior; use rmk_config::resolved::behavior::{ AutoMouseLayer, Combos, Forks, MacroOperation, Macros, Morse, MorseActionPair, MorseKey, - MorseProfile, + MorseProfile, StickyKeyProfile, StickyKeyReleaseMode, }; use super::action_parser::{expand_profile, expand_profile_name, get_key_with_alias, parse_key}; @@ -23,67 +23,68 @@ fn expand_tri_layer(tri_layer: &Option<[u8; 3]>) -> proc_macro2::TokenStream { } } -fn expand_sticky_key(behavior: &Behavior) -> proc_macro2::TokenStream { - let timeout = match &behavior.sticky_key { - Some(sk) => match sk.timeout_ms { - Some(millis) => quote! { ::rmk::embassy_time::Duration::from_millis(#millis) }, - None => quote! { ::rmk::embassy_time::Duration::from_secs(1) }, - }, - None => quote! { ::rmk::embassy_time::Duration::from_secs(1) }, - }; - let activate_on_keypress = behavior - .sticky_key - .as_ref() - .and_then(|sk| sk.activate_on_keypress) - .unwrap_or(false); - let quick_release = behavior - .sticky_key - .as_ref() - .and_then(|sk| sk.quick_release) +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 max_repeat = behavior + let max_repeat = profile.max_repeat.or(fallback.max_repeat).unwrap_or(0); + let release_mode = profile.release_mode.or(fallback.release_mode); + let release_mode = match release_mode { + Some(StickyKeyReleaseMode(bits)) => { + quote! { ::core::option::Option::Some(::rmk::config::StickyKeyReleaseMode(#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, + max_repeat: #max_repeat, + release_mode: #release_mode, + } + } +} + +fn expand_sticky_key(behavior: &Behavior) -> proc_macro2::TokenStream { + let default = behavior .sticky_key .as_ref() - .and_then(|sk| sk.max_repeat) - .unwrap_or(0); - let release_on_layer_change = behavior + .map(|sk| StickyKeyProfile { + timeout_ms: sk.timeout_ms, + activate_on_keypress: sk.activate_on_keypress, + max_repeat: sk.max_repeat, + release_mode: sk.release_mode, + }) + .unwrap_or_default(); + let default_timeout = default.timeout_ms.unwrap_or(1000); + let default_activate_on_keypress = default.activate_on_keypress.unwrap_or(false); + let default_max_repeat = default.max_repeat.unwrap_or(0); + let default_profile = expand_sticky_key_profile(&default, &StickyKeyProfile::default()); + let profile_tokens = behavior .sticky_key .as_ref() - .and_then(|sk| sk.release_on_layer_change) - .unwrap_or(false); - let option_bool = |value: Option| match value { - Some(value) => quote! { ::core::option::Option::Some(#value) }, - None => quote! { ::core::option::Option::None }, - }; - let tap_key_release_on_layer_change = option_bool( - behavior - .sticky_key - .as_ref() - .and_then(|sk| sk.tap_key_release_on_layer_change), - ); - let one_shot_mod_release_on_layer_change = option_bool( - behavior - .sticky_key - .as_ref() - .and_then(|sk| sk.one_shot_mod_release_on_layer_change), - ); - let one_shot_layer_release_on_layer_change = option_bool( - behavior - .sticky_key - .as_ref() - .and_then(|sk| sk.one_shot_layer_release_on_layer_change), - ); - + .map(|sk| { + let mut names: Vec<_> = sk.profiles.keys().collect(); + names.sort(); + names + .into_iter() + .map(|name| expand_sticky_key_profile(&sk.profiles[name], &default)) + .collect::>() + }) + .unwrap_or_default(); quote! { ::rmk::config::StickyKeyConfig { - timeout: #timeout, - activate_on_keypress: #activate_on_keypress, - quick_release: #quick_release, - max_repeat: #max_repeat, - release_on_layer_change: #release_on_layer_change, - tap_key_release_on_layer_change: #tap_key_release_on_layer_change, - one_shot_mod_release_on_layer_change: #one_shot_mod_release_on_layer_change, - one_shot_layer_release_on_layer_change: #one_shot_layer_release_on_layer_change, + default_profile: #default_profile, + profiles: ::rmk::heapless::Vec::from_iter([#(#profile_tokens),*]), + timeout: ::rmk::embassy_time::Duration::from_millis(#default_timeout), + activate_on_keypress: #default_activate_on_keypress, + max_repeat: #default_max_repeat, + ..Default::default() } } } @@ -91,6 +92,7 @@ fn expand_sticky_key(behavior: &Behavior) -> proc_macro2::TokenStream { 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() { @@ -103,18 +105,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),*]), } @@ -123,7 +126,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, }; @@ -139,7 +145,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); quote! { ::rmk::config::MorsesConfig { @@ -158,6 +164,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 { @@ -185,8 +192,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 }, @@ -264,6 +271,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! {}; @@ -281,7 +289,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 { @@ -300,7 +308,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()),*]) } @@ -311,7 +319,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()),*]) } @@ -327,10 +335,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( @@ -475,14 +483,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(); @@ -538,12 +547,17 @@ 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(|config| config.profiles.clone()) + .filter(|profiles| !profiles.is_empty()); let tri_layer = expand_tri_layer(&behavior.tri_layer); - 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); @@ -567,10 +581,10 @@ pub(crate) fn expand_behavior_config(behavior: &Behavior) -> proc_macro2::TokenS #[cfg(test)] mod tests { use super::*; - use rmk_config::resolved::behavior::StickyKeyConfig; + use rmk_config::resolved::behavior::{StickyKeyConfig, StickyKeyReleaseMode}; #[test] - fn sticky_key_codegen_preserves_shape_overrides() { + fn sticky_key_codegen_emits_profiles() { let behavior = Behavior { tri_layer: None, combos: None, @@ -580,28 +594,15 @@ mod tests { sticky_key: Some(StickyKeyConfig { timeout_ms: None, activate_on_keypress: None, - quick_release: None, max_repeat: None, - release_on_layer_change: Some(false), - tap_key_release_on_layer_change: Some(true), - one_shot_mod_release_on_layer_change: Some(false), - one_shot_layer_release_on_layer_change: None, + release_mode: Some(StickyKeyReleaseMode(StickyKeyReleaseMode::LAYER_ENTER.0)), + profiles: HashMap::new(), }), auto_mouse_layer: Vec::new(), }; let tokens = expand_sticky_key(&behavior).to_string().replace(' ', ""); - assert!(tokens.contains("release_on_layer_change:false")); - assert!( - tokens.contains("tap_key_release_on_layer_change:::core::option::Option::Some(true)") - ); - assert!( - tokens.contains( - "one_shot_mod_release_on_layer_change:::core::option::Option::Some(false)" - ) - ); - assert!( - tokens.contains("one_shot_layer_release_on_layer_change:::core::option::Option::None") - ); + assert!(tokens.contains("release_mode:::core::option::Option::Some")); + assert!(tokens.contains("profiles:::rmk::heapless::Vec::from_iter")); } } diff --git a/rmk-macro/src/codegen/layout.rs b/rmk-macro/src/codegen/layout.rs index e6aba2e0b..54eb873f2 100644 --- a/rmk-macro/src/codegen/layout.rs +++ b/rmk-macro/src/codegen/layout.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, Layout}; use super::action_parser::parse_key; @@ -16,6 +16,11 @@ pub(crate) fn expand_default_keymap(layout: &Layout, 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(|config| config.profiles.clone()) + .filter(|profiles| !profiles.is_empty()); let num_encoder: usize = layout.encoder_counts.iter().sum(); @@ -23,7 +28,7 @@ pub(crate) fn expand_default_keymap(layout: &Layout, behavior: &Behavior) -> Tok let mut encoder_map = vec![]; for layer in &layout.keymap { - layers.push(expand_layer(layer.clone(), &profiles)); + layers.push(expand_layer(layer.clone(), &profiles, &sticky_profiles)); } for encoder_layer in &layout.encoder_map { @@ -31,6 +36,7 @@ pub(crate) fn expand_default_keymap(layout: &Layout, behavior: &Behavior) -> Tok encoder_layer.clone(), num_encoder, &profiles, + &sticky_profiles, )); } encoder_map.resize( @@ -53,19 +59,24 @@ pub(crate) fn expand_default_keymap(layout: &Layout, behavior: &Behavior) -> Tok 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 @@ 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-types/build.rs b/rmk-types/build.rs index cf597927d..d533c3b6b 100644 --- a/rmk-types/build.rs +++ b/rmk-types/build.rs @@ -75,6 +75,10 @@ fn generate_constants(bc: &BuildConstants) -> String { bc.split_central_sleep_timeout_seconds )); lines.push(format!("pub const MORSE_MAX_NUM: usize = {};", bc.morse_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 = {};", rmk_config::resolved::behavior::AUTO_MOUSE_LAYER_MAX_NUM diff --git a/rmk-types/src/action/mod.rs b/rmk-types/src/action/mod.rs index be4229d0b..2bafbe89f 100644 --- a/rmk-types/src/action/mod.rs +++ b/rmk-types/src/action/mod.rs @@ -45,6 +45,8 @@ pub struct StickyKeyAction { /// `None` + `key == KeyCode::Hid(HidKeyCode::No)` = pure-mod (OSM) shape. /// `None` + `key != KeyCode::Hid(HidKeyCode::No)` = tap-key (alt-tab) shape. pub layer: Option, + /// Profile-table index. `u8::MAX` selects the default Sticky Key profile. + pub profile: u8, } /// A single basic action that a keyboard can execute. diff --git a/rmk/src/config/behavior.rs b/rmk/src/config/behavior.rs index 300b3c3f4..b4cd2eccb 100644 --- a/rmk/src/config/behavior.rs +++ b/rmk/src/config/behavior.rs @@ -6,7 +6,7 @@ use rmk_types::morse::{Morse, MorseMode, MorseProfile}; use crate::keyboard::combo::Combo; use crate::{ AUTO_MOUSE_LAYER_MAX_NUM, COMBO_MAX_NUM, FORK_MAX_NUM, MACRO_SPACE_SIZE, MORSE_MAX_NUM, MOUSE_KEY_INTERVAL, - MOUSE_WHEEL_INTERVAL, + MOUSE_WHEEL_INTERVAL, STICKY_KEY_PROFILE_MAX_NUM, }; /// Config for configurable action behavior @@ -102,36 +102,71 @@ impl Default for MorsesConfig { } } -/// Unified sticky-key configuration. Absorbs the former one_shot, one_shot_modifiers, -/// and sticky_key tables. `activate_on_keypress`/`quick_release` are honored only for -/// the pure-modifier SK shape (key == No); see docs. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct StickyKeyReleaseMode(pub u8); + +impl StickyKeyReleaseMode { + pub const OTHER_KEY_PRESS: Self = Self(1 << 0); + pub const OTHER_KEY_RELEASE: Self = Self(1 << 1); + pub const LAYER_ENTER: Self = Self(1 << 2); + pub const LAYER_EXIT: Self = Self(1 << 3); + + pub const fn contains(self, other: Self) -> bool { + self.0 & other.0 != 0 + } +} + +/// A resolved Sticky Key profile. `release_mode = None` preserves the legacy +/// shape-native release behavior for keymaps that do not opt into explicit modes. #[derive(Clone, Copy, Debug)] -pub struct StickyKeyConfig { +pub struct StickyKeyProfile { /// Applies to every SK shape. Default 1s. pub timeout: Duration, /// Honored only by pure-mod SK. Default false. pub activate_on_keypress: bool, - /// Honored only by pure-mod SK. Default false. - pub quick_release: bool, /// 0 = infinite; governs tap-key cycling. Default 0. pub max_repeat: u16, - /// Fallback used when the active SK shape has no layer-change override. + /// Explicit release triggers. `None` retains legacy shape-native behavior. + pub release_mode: Option, +} + +impl Default for StickyKeyProfile { + fn default() -> Self { + Self { + timeout: Duration::from_secs(1), + activate_on_keypress: false, + max_repeat: 0, + release_mode: None, + } + } +} + +/// Unified Sticky Key configuration with a default profile and compact named +/// profile table. Actions retain only a `u8` profile index. +#[derive(Clone, Debug)] +pub struct StickyKeyConfig { + pub default_profile: StickyKeyProfile, + pub profiles: Vec, + /// Legacy Rust-API compatibility knobs. TOML uses `release_mode` instead. + pub timeout: Duration, + pub activate_on_keypress: bool, + pub max_repeat: u16, + pub quick_release: bool, pub release_on_layer_change: bool, - /// Tap-key SK override. `None` inherits `release_on_layer_change`. pub tap_key_release_on_layer_change: Option, - /// One-shot-mod SK override. `None` inherits `release_on_layer_change`. pub one_shot_mod_release_on_layer_change: Option, - /// One-shot-layer SK override. `None` inherits `release_on_layer_change`. pub one_shot_layer_release_on_layer_change: Option, } impl Default for StickyKeyConfig { fn default() -> Self { Self { + default_profile: StickyKeyProfile::default(), + profiles: Vec::new(), timeout: Duration::from_secs(1), activate_on_keypress: false, - quick_release: false, max_repeat: 0, + quick_release: false, release_on_layer_change: false, tap_key_release_on_layer_change: None, one_shot_mod_release_on_layer_change: None, diff --git a/rmk/src/config/mod.rs b/rmk/src/config/mod.rs index c24ba5e53..45ef75209 100644 --- a/rmk/src/config/mod.rs +++ b/rmk/src/config/mod.rs @@ -8,7 +8,7 @@ mod vial; pub use behavior::{ AutoMouseLayerConfig, BehaviorConfig, CombosConfig, ForksConfig, KeyboardMacrosConfig, MorsesConfig, - MouseKeyConfig, StickyKeyConfig, TapConfig, + MouseKeyConfig, StickyKeyConfig, StickyKeyProfile, StickyKeyReleaseMode, TapConfig, }; #[cfg(feature = "_ble")] pub use ble_battery::BleBatteryConfig; diff --git a/rmk/src/host/via/keycode_convert.rs b/rmk/src/host/via/keycode_convert.rs index cc02dd2e9..2f70c0f2a 100644 --- a/rmk/src/host/via/keycode_convert.rs +++ b/rmk/src/host/via/keycode_convert.rs @@ -82,7 +82,7 @@ pub(crate) fn to_via_keycode(key_action: KeyAction) -> u16 { } }, Action::User(id) => (id as u16 & 0x1F) | 0x7E00, - Action::StickyKey(sk) => match (sk.key, sk.layer) { + Action::StickyKey(sk) if sk.profile == u8::MAX => match (sk.key, sk.layer) { // OSL, VIA range (same as old OneShotLayer) (_, Some(layer)) if layer < 32 => 0x5280 | layer as u16, // OSM, VIA range (same as old OneShotModifier) @@ -238,6 +238,7 @@ pub(crate) fn from_via_keycode(via_keycode: u16) -> KeyAction { key: HidKeyCode::No, keep: ModifierCombination::new(), layer: Some(layer), + profile: u8::MAX, })) } // OSM(mod) — one-shot modifier (VIA range 0x52A0..0x52BF, matching old OneShotModifier) @@ -247,6 +248,7 @@ pub(crate) fn from_via_keycode(via_keycode: u16) -> KeyAction { key: HidKeyCode::No, keep: m, layer: None, + profile: u8::MAX, })) } 0x7C18 => KeyAction::TapHold( @@ -879,6 +881,7 @@ mod test { key: HidKeyCode::No, keep: ModifierCombination::LCTRL, layer: None, + profile: u8::MAX, })); let via = to_via_keycode(osm_ctrl); assert_eq!(via, 0x52A1); // 0x52A0 | LCtrl packed bits (0x01) @@ -890,6 +893,7 @@ mod test { key: HidKeyCode::No, keep: ModifierCombination::LSHIFT, layer: None, + profile: u8::MAX, })); let via = to_via_keycode(osm_shift); assert_eq!(via, 0x52A2); // 0x52A0 | LShift packed bits (0x02) @@ -901,6 +905,7 @@ mod test { key: HidKeyCode::No, keep: ModifierCombination::LALT, layer: None, + profile: u8::MAX, })); let via = to_via_keycode(osm_alt); assert_eq!(via, 0x52A4); // 0x52A0 | LAlt packed bits (0x04) @@ -915,6 +920,7 @@ mod test { key: HidKeyCode::No, keep: ModifierCombination::new(), layer: Some(0), + profile: u8::MAX, })); let via = to_via_keycode(osl_0); assert_eq!(via, 0x5280); @@ -926,6 +932,7 @@ mod test { key: HidKeyCode::No, keep: ModifierCombination::new(), layer: Some(5), + profile: u8::MAX, })); let via = to_via_keycode(osl_5); assert_eq!(via, 0x5285); @@ -939,6 +946,7 @@ mod test { key: HidKeyCode::Tab, keep: ModifierCombination::LALT, layer: None, + profile: u8::MAX, })); assert_eq!(to_via_keycode(tap_key_sticky_key), 0); diff --git a/rmk/src/keyboard.rs b/rmk/src/keyboard.rs index 70956b935..b17607595 100644 --- a/rmk/src/keyboard.rs +++ b/rmk/src/keyboard.rs @@ -1236,13 +1236,22 @@ impl<'a> Keyboard<'a> { // THROUGH the terminating key's report (and is then consumed by `update_sticky_key` // in `process_action_key`, per `quick_release`). Only the tap-key shape releases its // held modifier cleanly before the foreign key registers. - if event.pressed && self.sticky_key_state.is_tap_key() { + if self.sticky_key_state.is_tap_key() { let is_sk_or_modifier = match action { Action::StickyKey(_) | Action::Modifier(_) => true, Action::Key(KeyCode::Hid(hid_key)) if hid_key.is_modifier() => true, _ => false, }; - if !is_sk_or_modifier { + let release_mode = self + .sticky_key_state + .profile() + .and_then(|index| self.keymap.sticky_key_profile(index).release_mode); + let should_release = match release_mode { + Some(mode) if event.pressed => mode.contains(crate::config::StickyKeyReleaseMode::OTHER_KEY_PRESS), + Some(mode) => mode.contains(crate::config::StickyKeyReleaseMode::OTHER_KEY_RELEASE), + None => event.pressed, + }; + if !is_sk_or_modifier && should_release { self.release_sticky_key_if_active().await; } } @@ -1268,14 +1277,16 @@ impl<'a> Keyboard<'a> { // Reactivate the layer after the key is released if event.pressed { self.keymap.deactivate_layer(layer_num); - self.release_sticky_key_on_layer_change().await; + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_EXIT) + .await; } } Action::LayerToggle(layer_num) => { // Toggle a layer when the key is released if !event.pressed { self.keymap.toggle_layer(layer_num); - self.release_sticky_key_on_layer_change().await; + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_ENTER) + .await; } } Action::LayerToggleOnly(layer_num) => { @@ -1291,18 +1302,23 @@ impl<'a> Keyboard<'a> { } // Activate the target layer self.keymap.activate_layer(layer_num); - self.release_sticky_key_on_layer_change().await; + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_EXIT) + .await; + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_ENTER) + .await; } } Action::DefaultLayer(layer_num) => { // Set the default layer self.keymap.set_default_layer(layer_num); - self.release_sticky_key_on_layer_change().await; + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_ENTER) + .await; } Action::PersistentDefaultLayer(layer_num) => { // Set the default layer and persist it so it survives a reboot self.keymap.set_default_layer(layer_num); - self.release_sticky_key_on_layer_change().await; + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_ENTER) + .await; // Persist only if the layer was valid (set_default_layer rejects out-of-range) #[cfg(feature = "storage")] if event.pressed && self.keymap.get_default_layer() == layer_num { @@ -1611,10 +1627,16 @@ impl<'a> Keyboard<'a> { true }; - // Consume any pending one-shot StickyKey; on quick-release of a basic key, re-send the report. - let quick_release = self.keymap.sticky_key_config().quick_release; + // Consume any pending one-shot StickyKey. A press-triggered release needs a + // follow-up report after the terminating key has been registered. + let press_release = self.sticky_key_state.profile().is_some_and(|index| { + self.keymap + .sticky_key_profile(index) + .release_mode + .is_some_and(|mode| mode.contains(crate::config::StickyKeyReleaseMode::OTHER_KEY_PRESS)) + }); let sk_consumed = self.update_sticky_key(event); - if quick_release && sk_consumed && is_basic_keyboard_key && event.pressed { + if press_release && sk_consumed && is_basic_keyboard_key && event.pressed { self.send_keyboard_report_with_resolved_modifiers(true).await; } } @@ -1624,10 +1646,12 @@ impl<'a> Keyboard<'a> { // Change layer state only when the key's state is changed if event.pressed { self.keymap.activate_layer(layer_num); - self.release_sticky_key_on_layer_change().await; + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_ENTER) + .await; } else { self.keymap.deactivate_layer(layer_num); - self.release_sticky_key_on_layer_change().await; + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_EXIT) + .await; } } diff --git a/rmk/src/keyboard/sticky_key.rs b/rmk/src/keyboard/sticky_key.rs index b15f6f1a4..a58ba3b4f 100644 --- a/rmk/src/keyboard/sticky_key.rs +++ b/rmk/src/keyboard/sticky_key.rs @@ -13,6 +13,7 @@ use rmk_types::action::StickyKeyAction; use rmk_types::keycode::HidKeyCode; use rmk_types::modifier::ModifierCombination; +use crate::config::StickyKeyReleaseMode; use crate::event::{KeyboardEvent, KeyboardEventPos}; use crate::keyboard::Keyboard; @@ -78,6 +79,8 @@ pub(crate) enum StickyKeyState { key: HidKeyCode, /// `Some(n)` = OSL shape; `None` = pure-mod or tap-key shape. layer: Option, + /// Selected Sticky Key profile (`u8::MAX` means default profile). + profile: u8, phase: SkPhase, /// Whether the physical StickyKey switch is currently held down. pressed: bool, @@ -154,24 +157,26 @@ impl StickyKeyState { pub fn is_layer(&self) -> bool { matches!(self, StickyKeyState::Active { layer: Some(_), .. }) } + + pub(crate) fn profile(&self) -> Option { + match self { + StickyKeyState::Active { profile, .. } => Some(*profile), + StickyKeyState::None => None, + } + } } impl Keyboard<'_> { - /// Release the active StickyKey when its shape-specific layer-change policy says to. - /// A shape override takes precedence over the global fallback. - pub(crate) async fn release_sticky_key_on_layer_change(&mut self) { - let config = self.keymap.sticky_key_config(); - let shape_override = if self.sticky_key_state.is_tap_key() { - config.tap_key_release_on_layer_change - } else if self.sticky_key_state.is_pure_mod() { - config.one_shot_mod_release_on_layer_change - } else if self.sticky_key_state.is_layer() { - config.one_shot_layer_release_on_layer_change - } else { + pub(crate) async fn release_sticky_key_on_layer_event(&mut self, event: StickyKeyReleaseMode) { + let Some(index) = self.sticky_key_state.profile() else { return; }; - - if shape_override.unwrap_or(config.release_on_layer_change) { + if self + .keymap + .sticky_key_profile(index) + .release_mode + .is_some_and(|mode| mode.contains(event)) + { self.release_sticky_key_if_active().await; } } @@ -189,8 +194,8 @@ impl Keyboard<'_> { /// Pure-mod (OSM) shape: accumulate the modifier across taps, apply it through the /// terminating key, honor `activate_on_keypress`/`quick_release`. async fn process_sticky_pure_mod(&mut self, params: StickyKeyAction, event: KeyboardEvent) { - let config = self.keymap.sticky_key_config(); - let deadline = StickyKeyDeadline::from_timeout(config.timeout); + let profile = self.keymap.sticky_key_profile(params.profile); + let deadline = StickyKeyDeadline::from_timeout(profile.timeout); if event.pressed { // Single mutually-exclusive latch: a pure-mod press accumulates (3c) onto an @@ -210,6 +215,7 @@ impl Keyboard<'_> { mods: params.keep, key: params.key, layer: None, + profile: params.profile, phase: SkPhase::Pressed, pressed: true, repeat_count: 1, @@ -229,7 +235,7 @@ impl Keyboard<'_> { } } - if config.activate_on_keypress { + if profile.activate_on_keypress { self.send_keyboard_report_with_resolved_modifiers(true).await; } } else { @@ -269,8 +275,8 @@ impl Keyboard<'_> { /// the latch is consumed. async fn process_sticky_layer(&mut self, params: StickyKeyAction, event: KeyboardEvent) { let layer_num = params.layer.expect("layer shape requires a layer"); - let config = self.keymap.sticky_key_config(); - let deadline = StickyKeyDeadline::from_timeout(config.timeout); + let profile = self.keymap.sticky_key_profile(params.profile); + let deadline = StickyKeyDeadline::from_timeout(profile.timeout); if event.pressed { // A held tap-key owns a registered HID key. Release it before this @@ -305,6 +311,7 @@ impl Keyboard<'_> { mods: existing_mods | params.keep, key: params.key, layer: Some(layer_num), + profile: params.profile, phase: prev_phase, pressed: true, repeat_count: 1, @@ -354,8 +361,8 @@ impl Keyboard<'_> { /// between presses, cycle on each press (`max_repeat`). Ignores /// `activate_on_keypress`/`quick_release`. async fn process_sticky_tap_key(&mut self, params: StickyKeyAction, event: KeyboardEvent) { - let config = self.keymap.sticky_key_config(); - let deadline = StickyKeyDeadline::from_timeout(config.timeout); + let profile = self.keymap.sticky_key_profile(params.profile); + let deadline = StickyKeyDeadline::from_timeout(profile.timeout); if event.pressed { // A repeated press of the same physical tap-key cycles it. A different tap-key, like @@ -378,6 +385,7 @@ impl Keyboard<'_> { mods: params.keep, key: params.key, layer: None, + profile: params.profile, phase: SkPhase::Latched, pressed: true, repeat_count: 1, @@ -393,7 +401,7 @@ impl Keyboard<'_> { // Saturating so an unbounded (`max_repeat == 0`) cycle can never overflow // the counter and panic on a debug build after 65535 presses. *repeat_count = repeat_count.saturating_add(1); - if config.max_repeat > 0 && *repeat_count > config.max_repeat { + if profile.max_repeat > 0 && *repeat_count > profile.max_repeat { should_deactivate = true; } else { *pressed = true; @@ -426,7 +434,7 @@ impl Keyboard<'_> { // this physical release could unregister the tap key safely. Re-arm the // latched modifier now; otherwise it would remain active indefinitely. if deadline.get().is_none() { - *deadline = StickyKeyDeadline::from_timeout(config.timeout); + *deadline = StickyKeyDeadline::from_timeout(profile.timeout); } self.unregister_key(params.key, event); self.send_keyboard_report_with_resolved_modifiers(false).await; @@ -450,6 +458,10 @@ impl Keyboard<'_> { if !self.sticky_key_state.is_pure_mod() && !self.sticky_key_state.is_layer() { return false; } + let mode = self + .sticky_key_state + .profile() + .and_then(|index| self.keymap.sticky_key_profile(index).release_mode); // Layer (OSL) shape: mirror the former `update_osl`. Pressed→Held on a foreign key // (handled by the shared Pressed arm below, which also clears the deadline). A Latched // layer is consumed on the foreign key's RELEASE: deactivate the layer and clear the @@ -460,13 +472,14 @@ impl Keyboard<'_> { .. } = self.sticky_key_state { - if !event.pressed { + if !event.pressed + && (mode.is_none() || mode.is_some_and(|mode| mode.contains(StickyKeyReleaseMode::OTHER_KEY_RELEASE))) + { self.keymap.deactivate_layer(layer_num); self.sticky_key_state = StickyKeyState::None; + return false; } - return false; } - let quick_release = self.keymap.sticky_key_config().quick_release; match &mut self.sticky_key_state { StickyKeyState::Active { phase: phase @ SkPhase::Pressed, @@ -485,7 +498,7 @@ impl Keyboard<'_> { phase: SkPhase::Latched, layer: Some(layer_num), .. - } if quick_release && event.pressed => { + } if mode.is_some_and(|mode| mode.contains(StickyKeyReleaseMode::OTHER_KEY_PRESS)) && event.pressed => { self.keymap.deactivate_layer(*layer_num); self.sticky_key_state = StickyKeyState::None; true @@ -493,14 +506,22 @@ impl Keyboard<'_> { StickyKeyState::Active { phase: SkPhase::Latched, .. - } if quick_release && event.pressed => { + } if mode.is_some_and(|mode| mode.contains(StickyKeyReleaseMode::OTHER_KEY_PRESS)) && event.pressed => { + self.sticky_key_state = StickyKeyState::None; + true + } + StickyKeyState::Active { + phase: SkPhase::Latched, + .. + } if mode.is_some_and(|mode| mode.contains(StickyKeyReleaseMode::OTHER_KEY_RELEASE)) && !event.pressed => { self.sticky_key_state = StickyKeyState::None; true } + // No explicit release mode preserves the old one-shot behavior. StickyKeyState::Active { phase: SkPhase::Latched, .. - } if !quick_release && !event.pressed => { + } if mode.is_none() && !event.pressed => { self.sticky_key_state = StickyKeyState::None; true } @@ -554,7 +575,10 @@ impl Keyboard<'_> { // must NOT produce a spurious empty report. Mirrors the former OSM timeout path. // - layer shape: deactivating a layer emits nothing → never report. let needs_report = if self.sticky_key_state.is_pure_mod() { - let activate_on_keypress = self.keymap.sticky_key_config().activate_on_keypress; + let activate_on_keypress = self + .sticky_key_state + .profile() + .is_some_and(|index| self.keymap.sticky_key_profile(index).activate_on_keypress); matches!( self.sticky_key_state, StickyKeyState::Active { diff --git a/rmk/src/keymap.rs b/rmk/src/keymap.rs index ba246228b..ee3277967 100644 --- a/rmk/src/keymap.rs +++ b/rmk/src/keymap.rs @@ -11,7 +11,7 @@ use { }; use crate::MACRO_SPACE_SIZE; -use crate::config::{BehaviorConfig, Hand, MouseKeyConfig, PositionalConfig, StickyKeyConfig}; +use crate::config::{BehaviorConfig, Hand, MouseKeyConfig, PositionalConfig, StickyKeyProfile, StickyKeyReleaseMode}; use crate::event::{KeyboardEvent, KeyboardEventPos, LayerChangeEvent, publish_event}; use crate::input_device::rotary_encoder::Direction; use crate::keyboard::combo::Combo; @@ -564,11 +564,40 @@ impl<'a> KeyMap<'a> { } pub(crate) fn sticky_key_timeout(&self) -> Duration { - self.inner.borrow().behavior.sticky_key.timeout + self.inner.borrow().behavior.sticky_key.default_profile.timeout } - pub(crate) fn sticky_key_config(&self) -> StickyKeyConfig { - self.inner.borrow().behavior.sticky_key + pub(crate) fn sticky_key_profile(&self, index: u8) -> StickyKeyProfile { + let config = &self.inner.borrow().behavior.sticky_key; + if let Some(profile) = config.profiles.get(index as usize) { + return *profile; + } + let mut profile = config.default_profile; + profile.timeout = config.timeout; + if config.activate_on_keypress { + profile.activate_on_keypress = true; + } + if config.max_repeat != 0 { + profile.max_repeat = config.max_repeat; + } + if profile.release_mode.is_none() { + let mut mode = 0; + if config.quick_release { + mode |= StickyKeyReleaseMode::OTHER_KEY_PRESS.0; + } + let layer_release = config + .one_shot_mod_release_on_layer_change + .or(config.one_shot_layer_release_on_layer_change) + .or(config.tap_key_release_on_layer_change) + .unwrap_or(config.release_on_layer_change); + if layer_release { + mode |= StickyKeyReleaseMode::LAYER_ENTER.0 | StickyKeyReleaseMode::LAYER_EXIT.0; + } + if mode != 0 { + profile.release_mode = Some(StickyKeyReleaseMode(mode)); + } + } + profile } pub(crate) fn tap_interval(&self) -> u16 { @@ -610,7 +639,7 @@ impl<'a> KeyMap<'a> { } pub(crate) fn set_sticky_key_timeout(&self, timeout: Duration) { - self.inner.borrow_mut().behavior.sticky_key.timeout = timeout; + self.inner.borrow_mut().behavior.sticky_key.default_profile.timeout = timeout; } pub(crate) fn set_tap_interval(&self, interval: u16) { diff --git a/rmk/src/layout_macro.rs b/rmk/src/layout_macro.rs index 26b7003c0..07967fc11 100644 --- a/rmk/src/layout_macro.rs +++ b/rmk/src/layout_macro.rs @@ -324,11 +324,15 @@ macro_rules! thp { #[macro_export] macro_rules! sk { ($key:ident, $keep:expr) => { + $crate::sk!($key, $keep, ::core::primitive::u8::MAX) + }; + ($key:ident, $keep:expr, $profile:expr) => { $crate::types::action::KeyAction::Single($crate::types::action::Action::StickyKey( $crate::types::action::StickyKeyAction { key: $crate::types::keycode::HidKeyCode::$key, keep: $keep, layer: None, + profile: $profile, }, )) }; @@ -348,11 +352,15 @@ macro_rules! sk { #[macro_export] macro_rules! sk_mod { ($m:expr) => { + $crate::sk_mod!($m, ::core::primitive::u8::MAX) + }; + ($m:expr, $profile:expr) => { $crate::types::action::KeyAction::Single($crate::types::action::Action::StickyKey( $crate::types::action::StickyKeyAction { key: $crate::types::keycode::HidKeyCode::No, keep: $m, layer: None, + profile: $profile, }, )) }; @@ -370,11 +378,15 @@ macro_rules! sk_mod { #[macro_export] macro_rules! sk_layer { ($n:literal) => { + $crate::sk_layer!($n, ::core::primitive::u8::MAX) + }; + ($n:literal, $profile:expr) => { $crate::types::action::KeyAction::Single($crate::types::action::Action::StickyKey( $crate::types::action::StickyKeyAction { key: $crate::types::keycode::HidKeyCode::No, keep: $crate::types::modifier::ModifierCombination::new(), layer: Some($n), + profile: $profile, }, )) }; @@ -391,6 +403,9 @@ macro_rules! osm { ($m:expr) => { $crate::sk_mod!($m) }; + ($m:expr, $profile:expr) => { + $crate::sk_mod!($m, $profile) + }; } /// Create a one-shot layer action (alias for `sk_layer!`). @@ -404,6 +419,9 @@ macro_rules! osl { ($n:literal) => { $crate::sk_layer!($n) }; + ($n:literal, $profile:expr) => { + $crate::sk_layer!($n, $profile) + }; } /// Create a layer toggle action. diff --git a/rmk/src/storage/mod.rs b/rmk/src/storage/mod.rs index 532c099ff..ed925e33f 100644 --- a/rmk/src/storage/mod.rs +++ b/rmk/src/storage/mod.rs @@ -334,7 +334,7 @@ impl From<&config::BehaviorConfig> for StorageData { prior_idle_time: behavior.morse.prior_idle_time.as_millis() as u16, morse_default_profile: behavior.morse.default_profile, combo_timeout: behavior.combo.timeout.as_millis() as u16, - sticky_key_timeout: behavior.sticky_key.timeout.as_millis() as u16, + sticky_key_timeout: behavior.sticky_key.default_profile.timeout.as_millis() as u16, tap_interval: behavior.tap.tap_interval, tap_capslock_interval: behavior.tap.tap_capslock_interval, }) @@ -513,7 +513,7 @@ impl Keyboard } #[test] -fn sticky_key_config_layer_change_overrides_do_not_increase_struct_size() { - assert_eq!(core::mem::size_of::(), 16); +fn sticky_key_config_reserves_the_bounded_profile_table() { + assert!(core::mem::size_of::() >= core::mem::size_of::()); } /// A tap-key override can enable layer-change release while the global fallback is disabled. From b0e6a6322392ac2e6a3683f1ad61fb7614ba680f Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:33:02 -0500 Subject: [PATCH 097/119] fix(sticky-key): preserve profile release semantics --- rmk-macro/src/codegen/action_parser.rs | 19 +- rmk-types/src/action/mod.rs | 20 ++ .../rmk/snapshots/endpoint_keys_base.snap | 20 +- .../rmk/snapshots/endpoint_keys_bulk.snap | 12 +- rmk/src/keyboard.rs | 124 ++++++++---- rmk/src/keyboard/auto_mouse_layer.rs | 9 +- rmk/src/keyboard/sticky_key.rs | 52 ++++- rmk/src/keymap.rs | 190 ++++++++++++++---- rmk/src/storage/mod.rs | 4 +- rmk/tests/keyboard_sticky_key_test.rs | 89 +++++++- 10 files changed, 434 insertions(+), 105 deletions(-) diff --git a/rmk-macro/src/codegen/action_parser.rs b/rmk-macro/src/codegen/action_parser.rs index 007919a83..65ed79eef 100644 --- a/rmk-macro/src/codegen/action_parser.rs +++ b/rmk-macro/src/codegen/action_parser.rs @@ -575,7 +575,7 @@ pub(crate) fn get_key_with_alias(key: String) -> Ident { #[cfg(test)] mod tests { use super::*; - use rmk_config::resolved::behavior::MorseProfile; + use rmk_config::resolved::behavior::{MorseProfile, StickyKeyProfile}; fn expand(key: &str) -> String { parse_key(key.to_string(), &None, &None).to_string() @@ -695,4 +695,21 @@ mod tests { ); } } + + #[test] + #[should_panic(expected = "profile name is not found in behavior.sticky_key.profiles")] + fn unknown_sticky_key_profile_is_rejected() { + let mut profiles = HashMap::new(); + profiles.insert( + "known".to_string(), + StickyKeyProfile { + timeout_ms: None, + activate_on_keypress: None, + max_repeat: None, + release_mode: None, + }, + ); + + parse_key("SK(LShift, @missing)".to_string(), &None, &Some(profiles)); + } } diff --git a/rmk-types/src/action/mod.rs b/rmk-types/src/action/mod.rs index 2bafbe89f..f1a04273f 100644 --- a/rmk-types/src/action/mod.rs +++ b/rmk-types/src/action/mod.rs @@ -103,3 +103,23 @@ pub enum Action { #[cfg(feature = "steno")] Steno(StenoKey), } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sticky_key_action_profile_round_trips() { + let action = Action::StickyKey(StickyKeyAction { + key: HidKeyCode::Tab, + keep: ModifierCombination::LALT, + layer: None, + profile: 7, + }); + let mut bytes = [0; 32]; + let encoded = postcard::to_slice(&action, &mut bytes).unwrap(); + let decoded: Action = postcard::from_bytes(encoded).unwrap(); + + assert_eq!(decoded, action); + } +} diff --git a/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_base.snap b/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_base.snap index 501cda6a2..fc5198bd8 100644 --- a/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_base.snap +++ b/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_base.snap @@ -8,22 +8,22 @@ behavior/get REQ 79 40 45 f9 6e 78 ce 15 RESP ac 59 82 ee ea 41 6c 64 behavior/set REQ c0 6d 36 93 9c 5a 7a b0 RESP 92 d6 0a 5d 06 93 e2 17 -combo/get REQ 81 6e 51 70 26 48 4d 13 RESP 1d 07 03 e0 04 ed 9f eb -combo/set REQ 0d 5d 68 3b e0 92 9b a9 RESP 2c 9b 2b 68 fe 35 21 25 +combo/get REQ 81 6e 51 70 26 48 4d 13 RESP e5 b7 a9 44 8f 92 b0 d7 +combo/set REQ 95 83 1f 15 b6 88 f7 63 RESP 2c 9b 2b 68 fe 35 21 25 conn/set_type REQ 59 5c 7b 51 0e ff d7 12 RESP 8f e7 08 b9 4d 3f 68 d5 conn/type REQ 4d f1 b2 e7 8d ec 46 a0 RESP 02 58 66 87 39 d7 b5 b5 -encoder/get REQ 4c 0d e1 c9 58 89 4b 52 RESP 3a b5 a9 f2 d7 ea 7c a1 -encoder/set REQ f4 1c 84 f6 1c 26 6c aa RESP ea a8 3d 9e dd 6e 67 c7 -fork/get REQ 21 8f a2 dc 1f e8 3a 1b RESP b1 8d 77 f2 39 9f 04 ab -fork/set REQ 01 84 97 2c e1 27 98 58 RESP 0c 8a ca c0 83 a9 dc be +encoder/get REQ 4c 0d e1 c9 58 89 4b 52 RESP ea ff 96 38 8f fd 6a 4d +encoder/set REQ e4 b8 74 60 0b a8 50 de RESP ea a8 3d 9e dd 6e 67 c7 +fork/get REQ 21 8f a2 dc 1f e8 3a 1b RESP cd df 34 81 57 57 f5 50 +fork/set REQ dd 54 55 2f 89 c8 2b 69 RESP 0c 8a ca c0 83 a9 dc be keymap/default_layer REQ 3b 9b e3 4e c2 47 56 de RESP 79 3f e3 4e c2 11 56 de -keymap/get REQ 9c ce 0f 70 d3 94 0f fb RESP 2b d8 a5 13 1f 83 ce 54 -keymap/set REQ c4 6f be 33 59 52 a6 22 RESP a7 01 c4 70 bb ea d3 b9 +keymap/get REQ 9c ce 0f 70 d3 94 0f fb RESP 47 08 7b f9 d9 5c 69 92 +keymap/set REQ 60 b9 6b b4 49 28 bc 60 RESP a7 01 c4 70 bb ea d3 b9 keymap/set_default_layer REQ 6c 6c 14 62 2a 07 9d b3 RESP 2b 67 98 d3 da 4b f3 98 macro/get REQ 0a 43 62 d5 55 40 09 9d RESP 85 2c 14 7a 94 7c e9 f1 macro/set REQ f7 e6 c3 bd 4c 03 a5 e7 RESP 4e 8c 8b 52 00 fa 68 03 -morse/get REQ f5 0c 0d f1 f0 6b 74 e2 RESP 17 3c 75 a1 49 c8 63 92 -morse/set REQ 4f 09 15 ee 2f 47 c2 d1 RESP 40 c6 f5 18 aa 72 42 a5 +morse/get REQ f5 0c 0d f1 f0 6b 74 e2 RESP 77 0d 11 c1 b7 89 76 11 +morse/set REQ af b9 5e 1f 1f 1c 71 c6 RESP 40 c6 f5 18 aa 72 42 a5 status/layer/get REQ d7 6a 8a 1b 7b bb be 32 RESP 75 45 8a 1b 7b a5 be 32 status/matrix/get REQ 4b ae a1 68 0d d9 90 44 RESP 63 13 83 85 e4 e0 0b 36 sys/bootloader REQ 29 a1 89 88 85 d6 a1 26 RESP 29 a1 89 88 85 d6 a1 26 diff --git a/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_bulk.snap b/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_bulk.snap index 9f8044806..12b358ead 100644 --- a/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_bulk.snap +++ b/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_bulk.snap @@ -6,9 +6,9 @@ # UPDATE_SNAPSHOTS=1 cargo test -p rmk-types --features rmk_protocol # Format: REQ <8-byte hex> RESP <8-byte hex> -combo/bulk_get REQ 52 b1 93 5f 86 d5 96 53 RESP 45 ce 16 e1 4c e4 3c d0 -combo/bulk_set REQ 03 db a6 66 35 0a e7 09 RESP 83 3b 2e b1 a0 96 2f 3d -keymap/bulk_get REQ 11 21 e6 78 15 e5 8a ca RESP 23 e2 3d 4a e0 fe d5 98 -keymap/bulk_set REQ 6d 0a e9 b3 e6 d0 78 63 RESP 42 98 cc 60 91 e5 c5 f3 -morse/bulk_get REQ 46 e8 ff eb aa ed 5f db RESP 9f 5c e2 29 8b 7b 3d c8 -morse/bulk_set REQ 21 98 96 76 9d 39 aa 81 RESP f7 57 bd 43 2b 0b ec b8 +combo/bulk_get REQ 52 b1 93 5f 86 d5 96 53 RESP 0d 45 36 78 e7 2a 69 5c +combo/bulk_set REQ 1b 62 8c 01 c9 5e 83 53 RESP 83 3b 2e b1 a0 96 2f 3d +keymap/bulk_get REQ 11 21 e6 78 15 e5 8a ca RESP df 5c d7 0e 86 2e 9b c5 +keymap/bulk_set REQ 21 ca 49 0c 6a 62 b2 7d RESP 42 98 cc 60 91 e5 c5 f3 +morse/bulk_get REQ 46 e8 ff eb aa ed 5f db RESP 1f 10 82 9c 32 b9 ca 45 +morse/bulk_set REQ 1d 41 58 3a 12 54 a5 09 RESP f7 57 bd 43 2b 0b ec b8 diff --git a/rmk/src/keyboard.rs b/rmk/src/keyboard.rs index b17607595..6b09b6440 100644 --- a/rmk/src/keyboard.rs +++ b/rmk/src/keyboard.rs @@ -1,7 +1,6 @@ use core::fmt::Debug; -#[cfg(all(feature = "split", feature = "_ble"))] -use embassy_futures::select::{Either, select}; +use embassy_futures::select::{Either, Either3, select, select3}; use embassy_futures::yield_now; #[cfg(feature = "_ble")] use embassy_sync::signal::Signal; @@ -28,9 +27,9 @@ use crate::keyboard::combo::Combo; use crate::keyboard::fork::ActiveFork; use crate::keyboard::held_buffer::{HeldBuffer, HeldKey, KeyState}; use crate::keyboard::mouse::{MouseAction, MouseState}; -use crate::keyboard::sticky_key::{SkPhase, StickyKeyState}; +use crate::keyboard::sticky_key::{SkPhase, StickyKeyState, next_sticky_layer_event}; use crate::keyboard_macros::MacroOperation; -use crate::keymap::KeyMap; +use crate::keymap::{KeyMap, StickyKeyShape}; #[cfg(all(feature = "split", feature = "_ble"))] use crate::split::ble::central::update_activity_time; use crate::{COMBO_MAX_NUM, FORK_MAX_NUM, MACRO_SPACE_SIZE, boot}; @@ -164,19 +163,33 @@ impl Runnable for Keyboard<'_> { .chain(self.mouse.next_deadline()) .reduce(|a, b| a.min(b)); if let Some(deadline) = deadline { - let event_result = - with_deadline(deadline, self.keyboard_event_subscriber.next_message_pure()).await; - match event_result { - Ok(event) => { + match select3( + self.keyboard_event_subscriber.next_message_pure(), + next_sticky_layer_event(), + Timer::at(deadline), + ) + .await + { + Either3::First(event) => { self.process_inner(event).await; } - Err(_) => { - // timeout only, handled by post-check below + Either3::Second(layer_event) => { + self.release_sticky_key_on_layer_event(layer_event).await; } + Either3::Third(_) => {} } } else { - let event = self.keyboard_event_subscriber.next_message_pure().await; - self.process_inner(event).await + match select( + self.keyboard_event_subscriber.next_message_pure(), + next_sticky_layer_event(), + ) + .await + { + Either::First(event) => self.process_inner(event).await, + Either::Second(layer_event) => { + self.release_sticky_key_on_layer_event(layer_event).await; + } + } } }; @@ -1236,23 +1249,29 @@ impl<'a> Keyboard<'a> { // THROUGH the terminating key's report (and is then consumed by `update_sticky_key` // in `process_action_key`, per `quick_release`). Only the tap-key shape releases its // held modifier cleanly before the foreign key registers. + let mut release_tap_key_after_action = false; if self.sticky_key_state.is_tap_key() { let is_sk_or_modifier = match action { Action::StickyKey(_) | Action::Modifier(_) => true, Action::Key(KeyCode::Hid(hid_key)) if hid_key.is_modifier() => true, _ => false, }; - let release_mode = self - .sticky_key_state - .profile() - .and_then(|index| self.keymap.sticky_key_profile(index).release_mode); + let release_mode = self.sticky_key_state.profile().and_then(|index| { + self.keymap + .sticky_key_profile(index, StickyKeyShape::TapKey) + .release_mode + }); let should_release = match release_mode { Some(mode) if event.pressed => mode.contains(crate::config::StickyKeyReleaseMode::OTHER_KEY_PRESS), Some(mode) => mode.contains(crate::config::StickyKeyReleaseMode::OTHER_KEY_RELEASE), None => event.pressed, }; if !is_sk_or_modifier && should_release { - self.release_sticky_key_if_active().await; + if event.pressed { + self.release_sticky_key_if_active().await; + } else { + release_tap_key_after_action = true; + } } } @@ -1276,17 +1295,23 @@ impl<'a> Keyboard<'a> { // Turn off a layer temporarily when the key is pressed // Reactivate the layer after the key is released if event.pressed { - self.keymap.deactivate_layer(layer_num); - self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_EXIT) - .await; + if self.keymap.deactivate_layer(layer_num) { + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_EXIT) + .await; + } } } Action::LayerToggle(layer_num) => { // Toggle a layer when the key is released if !event.pressed { - self.keymap.toggle_layer(layer_num); - self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_ENTER) - .await; + if let Some(active) = self.keymap.toggle_layer(layer_num) { + let mode = if active { + crate::config::StickyKeyReleaseMode::LAYER_ENTER + } else { + crate::config::StickyKeyReleaseMode::LAYER_EXIT + }; + self.release_sticky_key_on_layer_event(mode).await; + } } } Action::LayerToggleOnly(layer_num) => { @@ -1295,30 +1320,41 @@ impl<'a> Keyboard<'a> { // Disable all layers except the default layer let default_layer = self.keymap.get_default_layer(); let (_, _, num_layer) = self.keymap.get_keymap_config(); + let mut exited = false; for i in 0..num_layer as u8 { if i != default_layer { - self.keymap.deactivate_layer(i); + exited |= self.keymap.deactivate_layer(i); } } // Activate the target layer - self.keymap.activate_layer(layer_num); + let entered = self.keymap.activate_layer(layer_num); + if exited { + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_EXIT) + .await; + } + if entered { + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_ENTER) + .await; + } + } + } + Action::DefaultLayer(layer_num) => { + // Set the default layer + if event.pressed && self.keymap.set_default_layer(layer_num) { self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_EXIT) .await; self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_ENTER) .await; } } - Action::DefaultLayer(layer_num) => { - // Set the default layer - self.keymap.set_default_layer(layer_num); - self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_ENTER) - .await; - } Action::PersistentDefaultLayer(layer_num) => { // Set the default layer and persist it so it survives a reboot - self.keymap.set_default_layer(layer_num); - self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_ENTER) - .await; + if event.pressed && self.keymap.set_default_layer(layer_num) { + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_EXIT) + .await; + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_ENTER) + .await; + } // Persist only if the layer was valid (set_default_layer rejects out-of-range) #[cfg(feature = "storage")] if event.pressed && self.keymap.get_default_layer() == layer_num { @@ -1388,6 +1424,10 @@ impl<'a> Keyboard<'a> { } _ => warn!("Action variant not supported: {:?}", action), } + + if release_tap_key_after_action { + self.release_sticky_key_if_active().await; + } } /// Tap action, send a key when the key is pressed, then release the key. @@ -1631,7 +1671,7 @@ impl<'a> Keyboard<'a> { // follow-up report after the terminating key has been registered. let press_release = self.sticky_key_state.profile().is_some_and(|index| { self.keymap - .sticky_key_profile(index) + .sticky_key_profile(index, StickyKeyShape::PureMod) .release_mode .is_some_and(|mode| mode.contains(crate::config::StickyKeyReleaseMode::OTHER_KEY_PRESS)) }); @@ -1645,13 +1685,15 @@ impl<'a> Keyboard<'a> { async fn process_action_layer_switch(&mut self, layer_num: u8, event: KeyboardEvent) { // Change layer state only when the key's state is changed if event.pressed { - self.keymap.activate_layer(layer_num); - self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_ENTER) - .await; + if self.keymap.activate_layer(layer_num) { + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_ENTER) + .await; + } } else { - self.keymap.deactivate_layer(layer_num); - self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_EXIT) - .await; + if self.keymap.deactivate_layer(layer_num) { + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_EXIT) + .await; + } } } diff --git a/rmk/src/keyboard/auto_mouse_layer.rs b/rmk/src/keyboard/auto_mouse_layer.rs index e7d3dc8da..857e989f4 100644 --- a/rmk/src/keyboard/auto_mouse_layer.rs +++ b/rmk/src/keyboard/auto_mouse_layer.rs @@ -16,8 +16,10 @@ use rmk_macro::processor; use crate::AUTO_MOUSE_LAYER_MAX_NUM; use crate::config::AutoMouseLayerConfig; +use crate::config::StickyKeyReleaseMode; use crate::core_traits::Runnable; use crate::event::{Axis, AxisValType, LayerChangeEvent, PointingEvent}; +use crate::keyboard::sticky_key::notify_sticky_layer_event; use crate::keymap::KeyMap; use crate::processor::DeadlineProcessor; @@ -73,6 +75,9 @@ impl<'a, 'k> AutoMouseLayerRunner<'a, 'k> { } let target_layer = self.entries[idx].config.target_layer; let activated_by_us = self.keymap.activate_layer_if_inactive(target_layer); + if activated_by_us { + notify_sticky_layer_event(StickyKeyReleaseMode::LAYER_ENTER); + } if pointing_step(&mut self.entries, idx, Instant::now(), activated_by_us) == PointingOutcome::OverlapFirstSeen { warn!( "auto_mouse_layer: layer {} is already active when motion was detected; \ @@ -128,7 +133,9 @@ impl DeadlineProcessor for AutoMouseLayerRunner<'_, '_> { async fn on_deadline(&mut self) { for layer in timeout_step(&mut self.entries, Instant::now()) { - self.keymap.deactivate_layer_if_active(layer); + if self.keymap.deactivate_layer_if_active(layer) { + notify_sticky_layer_event(StickyKeyReleaseMode::LAYER_EXIT); + } } } } diff --git a/rmk/src/keyboard/sticky_key.rs b/rmk/src/keyboard/sticky_key.rs index a58ba3b4f..3ed8f2a91 100644 --- a/rmk/src/keyboard/sticky_key.rs +++ b/rmk/src/keyboard/sticky_key.rs @@ -8,6 +8,7 @@ //! no inline `select` in this module. On expiry the run loop calls //! [`Keyboard::release_sticky_key_if_active`]. +use embassy_sync::channel::Channel; use embassy_time::{Duration, Instant}; use rmk_types::action::StickyKeyAction; use rmk_types::keycode::HidKeyCode; @@ -16,6 +17,19 @@ use rmk_types::modifier::ModifierCombination; use crate::config::StickyKeyReleaseMode; use crate::event::{KeyboardEvent, KeyboardEventPos}; use crate::keyboard::Keyboard; +use crate::keymap::StickyKeyShape; + +static STICKY_LAYER_EVENT_CHANNEL: Channel = Channel::new(); + +pub(crate) fn notify_sticky_layer_event(event: StickyKeyReleaseMode) { + if STICKY_LAYER_EVENT_CHANNEL.try_send(event).is_err() { + warn!("sticky-key layer event channel is full; dropping layer transition"); + } +} + +pub(crate) async fn next_sticky_layer_event() -> StickyKeyReleaseMode { + STICKY_LAYER_EVENT_CHANNEL.receive().await +} /// Latch phase of a sticky key. /// @@ -164,16 +178,28 @@ impl StickyKeyState { StickyKeyState::None => None, } } + + pub(crate) fn shape(&self) -> Option { + if self.is_pure_mod() { + Some(StickyKeyShape::PureMod) + } else if self.is_layer() { + Some(StickyKeyShape::Layer) + } else if self.is_tap_key() { + Some(StickyKeyShape::TapKey) + } else { + None + } + } } impl Keyboard<'_> { pub(crate) async fn release_sticky_key_on_layer_event(&mut self, event: StickyKeyReleaseMode) { - let Some(index) = self.sticky_key_state.profile() else { + let (Some(index), Some(shape)) = (self.sticky_key_state.profile(), self.sticky_key_state.shape()) else { return; }; if self .keymap - .sticky_key_profile(index) + .sticky_key_profile(index, shape) .release_mode .is_some_and(|mode| mode.contains(event)) { @@ -194,7 +220,7 @@ impl Keyboard<'_> { /// Pure-mod (OSM) shape: accumulate the modifier across taps, apply it through the /// terminating key, honor `activate_on_keypress`/`quick_release`. async fn process_sticky_pure_mod(&mut self, params: StickyKeyAction, event: KeyboardEvent) { - let profile = self.keymap.sticky_key_profile(params.profile); + let profile = self.keymap.sticky_key_profile(params.profile, StickyKeyShape::PureMod); let deadline = StickyKeyDeadline::from_timeout(profile.timeout); if event.pressed { @@ -224,12 +250,16 @@ impl Keyboard<'_> { } StickyKeyState::Active { mods, + profile, pressed, deadline: d, .. } => { // Same-shape pure-mod re-press: accumulate (3c) and refresh the deadline. *mods |= params.keep; + // The most recently pressed pure-mod StickyKey owns the + // accumulated latch's behavior and refreshed deadline. + *profile = params.profile; *pressed = true; *d = deadline; } @@ -275,7 +305,7 @@ impl Keyboard<'_> { /// the latch is consumed. async fn process_sticky_layer(&mut self, params: StickyKeyAction, event: KeyboardEvent) { let layer_num = params.layer.expect("layer shape requires a layer"); - let profile = self.keymap.sticky_key_profile(params.profile); + let profile = self.keymap.sticky_key_profile(params.profile, StickyKeyShape::Layer); let deadline = StickyKeyDeadline::from_timeout(profile.timeout); if event.pressed { @@ -361,7 +391,7 @@ impl Keyboard<'_> { /// between presses, cycle on each press (`max_repeat`). Ignores /// `activate_on_keypress`/`quick_release`. async fn process_sticky_tap_key(&mut self, params: StickyKeyAction, event: KeyboardEvent) { - let profile = self.keymap.sticky_key_profile(params.profile); + let profile = self.keymap.sticky_key_profile(params.profile, StickyKeyShape::TapKey); let deadline = StickyKeyDeadline::from_timeout(profile.timeout); if event.pressed { @@ -461,7 +491,8 @@ impl Keyboard<'_> { let mode = self .sticky_key_state .profile() - .and_then(|index| self.keymap.sticky_key_profile(index).release_mode); + .zip(self.sticky_key_state.shape()) + .and_then(|(index, shape)| self.keymap.sticky_key_profile(index, shape).release_mode); // Layer (OSL) shape: mirror the former `update_osl`. Pressed→Held on a foreign key // (handled by the shared Pressed arm below, which also clears the deadline). A Latched // layer is consumed on the foreign key's RELEASE: deactivate the layer and clear the @@ -575,10 +606,11 @@ impl Keyboard<'_> { // must NOT produce a spurious empty report. Mirrors the former OSM timeout path. // - layer shape: deactivating a layer emits nothing → never report. let needs_report = if self.sticky_key_state.is_pure_mod() { - let activate_on_keypress = self - .sticky_key_state - .profile() - .is_some_and(|index| self.keymap.sticky_key_profile(index).activate_on_keypress); + let activate_on_keypress = self.sticky_key_state.profile().is_some_and(|index| { + self.keymap + .sticky_key_profile(index, StickyKeyShape::PureMod) + .activate_on_keypress + }); matches!( self.sticky_key_state, StickyKeyState::Active { diff --git a/rmk/src/keymap.rs b/rmk/src/keymap.rs index ee3277967..9951297af 100644 --- a/rmk/src/keymap.rs +++ b/rmk/src/keymap.rs @@ -21,6 +21,13 @@ use crate::matrix::MatrixState; pub(crate) const HOLD_BUFFER_SIZE: usize = 16; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum StickyKeyShape { + PureMod, + TapKey, + Layer, +} + /// All allocated data needed to build a [`KeyMap`]. pub struct KeymapData { /// Per-layer key actions @@ -142,15 +149,19 @@ impl KeyMapInner<'_> { self.behavior.default_layer } - fn set_default_layer(&mut self, layer_num: u8) { + fn set_default_layer(&mut self, layer_num: u8) -> bool { if layer_num as usize >= self.num_layer { warn!( "Not a valid default layer {}, keyboard supports only {} layers", layer_num, self.num_layer ); - return; + return false; + } + if self.behavior.default_layer == layer_num { + return false; } self.behavior.default_layer = layer_num; + true } fn get_action_at(&self, pos: KeyboardEventPos, layer_num: usize) -> KeyAction { @@ -293,40 +304,50 @@ impl KeyMapInner<'_> { publish_event(LayerChangeEvent::new(layer)); } - fn activate_layer(&mut self, layer_num: u8) { + fn activate_layer(&mut self, layer_num: u8) -> bool { if layer_num as usize >= self.num_layer { warn!( "Not a valid layer {}, keyboard supports only {} layers", layer_num, self.num_layer ); - return; + return false; + } + if self.layer_state[layer_num as usize] { + return false; } self.layer_state[layer_num as usize] = true; self.update_tri_layer(); + true } - fn deactivate_layer(&mut self, layer_num: u8) { + fn deactivate_layer(&mut self, layer_num: u8) -> bool { if layer_num as usize >= self.num_layer { warn!( "Not a valid layer {}, keyboard supports only {} layers", layer_num, self.num_layer ); - return; + return false; + } + if !self.layer_state[layer_num as usize] { + return false; } self.layer_state[layer_num as usize] = false; self.update_tri_layer(); + true } - fn toggle_layer(&mut self, layer_num: u8) { + fn toggle_layer(&mut self, layer_num: u8) -> Option { if layer_num as usize >= self.num_layer { warn!( "Not a valid layer {}, keyboard supports only {} layers", layer_num, self.num_layer ); - return; + return None; } self.layer_state[layer_num as usize] = !self.layer_state[layer_num as usize]; + let active = self.layer_state[layer_num as usize]; self.update_tri_layer(); + Some(active) } } @@ -458,16 +479,17 @@ impl<'a> KeyMap<'a> { // ── Layers ── - pub(crate) fn activate_layer(&self, layer_num: u8) { - self.inner.borrow_mut().activate_layer(layer_num); + pub(crate) fn activate_layer(&self, layer_num: u8) -> bool { + self.inner.borrow_mut().activate_layer(layer_num) } - pub(crate) fn deactivate_layer(&self, layer_num: u8) { - self.inner.borrow_mut().deactivate_layer(layer_num); + pub(crate) fn deactivate_layer(&self, layer_num: u8) -> bool { + self.inner.borrow_mut().deactivate_layer(layer_num) } - pub(crate) fn toggle_layer(&self, layer_num: u8) { - self.inner.borrow_mut().toggle_layer(layer_num); + /// Toggle a valid layer, returning its new active state. + pub(crate) fn toggle_layer(&self, layer_num: u8) -> Option { + self.inner.borrow_mut().toggle_layer(layer_num) } /// Activate `layer_num` only if it is currently inactive. @@ -491,14 +513,15 @@ impl<'a> KeyMap<'a> { /// deactivates when the layer is currently active. Skips the /// `update_tri_layer` call (which would publish a `LayerChangeEvent`) when /// the layer is already inactive, avoiding a redundant event publish. - pub(crate) fn deactivate_layer_if_active(&self, layer_num: u8) { + pub(crate) fn deactivate_layer_if_active(&self, layer_num: u8) -> bool { let mut inner = self.inner.borrow_mut(); let idx = layer_num as usize; if idx >= inner.num_layer || !inner.layer_state[idx] { - return; + return false; } inner.layer_state[idx] = false; inner.update_tri_layer(); + true } pub(crate) fn auto_mouse_layer_configs( @@ -529,8 +552,8 @@ impl<'a> KeyMap<'a> { self.inner.borrow().get_default_layer() } - pub(crate) fn set_default_layer(&self, layer_num: u8) { - self.inner.borrow_mut().set_default_layer(layer_num); + pub(crate) fn set_default_layer(&self, layer_num: u8) -> bool { + self.inner.borrow_mut().set_default_layer(layer_num) } pub(crate) fn update_fn_layer_state(&self) { @@ -567,13 +590,18 @@ impl<'a> KeyMap<'a> { self.inner.borrow().behavior.sticky_key.default_profile.timeout } - pub(crate) fn sticky_key_profile(&self, index: u8) -> StickyKeyProfile { + pub(crate) fn sticky_key_profile(&self, index: u8, shape: StickyKeyShape) -> StickyKeyProfile { let config = &self.inner.borrow().behavior.sticky_key; if let Some(profile) = config.profiles.get(index as usize) { return *profile; } let mut profile = config.default_profile; - profile.timeout = config.timeout; + // Keep the resolved default profile canonical. The remaining fields are + // a compatibility shim for Rust callers using the legacy struct-update + // API: only non-default legacy values override the canonical profile. + if config.timeout != Duration::from_secs(1) { + profile.timeout = config.timeout; + } if config.activate_on_keypress { profile.activate_on_keypress = true; } @@ -582,14 +610,15 @@ impl<'a> KeyMap<'a> { } if profile.release_mode.is_none() { let mut mode = 0; - if config.quick_release { + if shape == StickyKeyShape::PureMod && config.quick_release { mode |= StickyKeyReleaseMode::OTHER_KEY_PRESS.0; } - let layer_release = config - .one_shot_mod_release_on_layer_change - .or(config.one_shot_layer_release_on_layer_change) - .or(config.tap_key_release_on_layer_change) - .unwrap_or(config.release_on_layer_change); + let layer_release = match shape { + StickyKeyShape::PureMod => config.one_shot_mod_release_on_layer_change, + StickyKeyShape::Layer => config.one_shot_layer_release_on_layer_change, + StickyKeyShape::TapKey => config.tap_key_release_on_layer_change, + } + .unwrap_or(config.release_on_layer_change); if layer_release { mode |= StickyKeyReleaseMode::LAYER_ENTER.0 | StickyKeyReleaseMode::LAYER_EXIT.0; } @@ -639,7 +668,11 @@ impl<'a> KeyMap<'a> { } pub(crate) fn set_sticky_key_timeout(&self, timeout: Duration) { - self.inner.borrow_mut().behavior.sticky_key.default_profile.timeout = timeout; + let mut inner = self.inner.borrow_mut(); + inner.behavior.sticky_key.default_profile.timeout = timeout; + // Keep the legacy Rust-API compatibility mirror synchronized so it + // cannot override a Vial runtime update during profile resolution. + inner.behavior.sticky_key.timeout = timeout; } pub(crate) fn set_tap_interval(&self, interval: u16) { @@ -814,11 +847,13 @@ impl<'a> KeyMap<'a> { #[cfg(test)] mod test { + use embassy_time::Duration; use rmk_types::fork::{Fork, StateBits}; use rmk_types::modifier::ModifierCombination; + use crate::config::{BehaviorConfig, PositionalConfig, StickyKeyProfile, StickyKeyReleaseMode}; use crate::keyboard::combo::{Combo, ComboConfig}; - use crate::keymap::fill_vec; + use crate::keymap::{KeyMap, KeymapData, StickyKeyShape, fill_vec}; use crate::{COMBO_MAX_NUM, FORK_MAX_NUM, k}; #[test] @@ -872,9 +907,6 @@ mod test { #[test] fn is_layer_active_reports_individual_layer_state() { - use crate::config::{BehaviorConfig, PositionalConfig}; - use crate::keymap::{KeyMap, KeymapData}; - let mut data = KeymapData::<1, 1, 4>::new([[[k!(A)]], [[k!(B)]], [[k!(C)]], [[k!(D)]]]); let mut behavior = BehaviorConfig::default(); let positional = PositionalConfig::<1, 1>::default(); @@ -892,16 +924,106 @@ mod test { assert!(!keymap.is_layer_active(3)); assert!(!keymap.activate_layer_if_inactive(2)); - keymap.deactivate_layer_if_active(2); + assert!(keymap.deactivate_layer_if_active(2)); assert!(!keymap.is_layer_active(2)); - keymap.deactivate_layer_if_active(2); + assert!(!keymap.deactivate_layer_if_active(2)); assert!(!keymap.is_layer_active(2)); // Mirrors the auto-mouse Either3::Third guard. assert!(keymap.activate_layer_if_inactive(2)); let self_activated = true; assert!(!(self_activated && !keymap.is_layer_active(2))); - keymap.deactivate_layer_if_active(2); + assert!(keymap.deactivate_layer_if_active(2)); assert!(self_activated && !keymap.is_layer_active(2)); } + + #[test] + fn layer_mutations_report_only_actual_transitions() { + let mut data = KeymapData::<1, 1, 2>::new([[[k!(A)]], [[k!(B)]]]); + let mut behavior = BehaviorConfig::default(); + let positional = PositionalConfig::<1, 1>::default(); + let keymap = KeyMap::build(&mut data, &mut behavior, &positional); + + assert!(keymap.activate_layer(1)); + assert!(!keymap.activate_layer(1)); + assert_eq!(keymap.toggle_layer(1), Some(false)); + assert_eq!(keymap.toggle_layer(1), Some(true)); + assert_eq!(keymap.toggle_layer(9), None); + assert!(keymap.set_default_layer(1)); + assert!(!keymap.set_default_layer(1)); + assert!(!keymap.set_default_layer(9)); + } + + #[test] + fn canonical_default_profile_is_not_overwritten_by_legacy_defaults() { + let mut data = KeymapData::<1, 1, 1>::new([[[k!(A)]]]); + let mut behavior = BehaviorConfig::default(); + behavior.sticky_key.default_profile.timeout = Duration::from_millis(275); + behavior.sticky_key.default_profile.max_repeat = 3; + let positional = PositionalConfig::<1, 1>::default(); + let keymap = KeyMap::build(&mut data, &mut behavior, &positional); + + let profile = keymap.sticky_key_profile(u8::MAX, StickyKeyShape::TapKey); + assert_eq!(profile.timeout, Duration::from_millis(275)); + assert_eq!(profile.max_repeat, 3); + } + + #[test] + fn runtime_timeout_update_changes_the_canonical_default_profile() { + let mut data = KeymapData::<1, 1, 1>::new([[[k!(A)]]]); + let mut behavior = BehaviorConfig::default(); + behavior.sticky_key.timeout = Duration::from_millis(50); + let positional = PositionalConfig::<1, 1>::default(); + let keymap = KeyMap::build(&mut data, &mut behavior, &positional); + + keymap.set_sticky_key_timeout(Duration::from_millis(640)); + + assert_eq!(keymap.sticky_key_timeout(), Duration::from_millis(640)); + assert_eq!( + keymap.sticky_key_profile(u8::MAX, StickyKeyShape::PureMod).timeout, + Duration::from_millis(640) + ); + } + + #[test] + fn named_profiles_ignore_legacy_default_overrides() { + let mut data = KeymapData::<1, 1, 1>::new([[[k!(A)]]]); + let mut behavior = BehaviorConfig::default(); + behavior.sticky_key.timeout = Duration::from_millis(50); + behavior.sticky_key.quick_release = true; + behavior + .sticky_key + .profiles + .push(StickyKeyProfile { + timeout: Duration::from_millis(900), + activate_on_keypress: false, + max_repeat: 4, + release_mode: Some(StickyKeyReleaseMode::OTHER_KEY_RELEASE), + }) + .unwrap(); + let positional = PositionalConfig::<1, 1>::default(); + let keymap = KeyMap::build(&mut data, &mut behavior, &positional); + + let profile = keymap.sticky_key_profile(0, StickyKeyShape::PureMod); + assert_eq!(profile.timeout, Duration::from_millis(900)); + assert_eq!(profile.max_repeat, 4); + assert_eq!(profile.release_mode, Some(StickyKeyReleaseMode::OTHER_KEY_RELEASE)); + } + + #[test] + fn legacy_release_overrides_are_shape_specific() { + let mut data = KeymapData::<1, 1, 1>::new([[[k!(A)]]]); + let mut behavior = BehaviorConfig::default(); + behavior.sticky_key.one_shot_mod_release_on_layer_change = Some(true); + behavior.sticky_key.tap_key_release_on_layer_change = Some(false); + let positional = PositionalConfig::<1, 1>::default(); + let keymap = KeyMap::build(&mut data, &mut behavior, &positional); + + let pure_mod = keymap.sticky_key_profile(u8::MAX, StickyKeyShape::PureMod); + let tap_key = keymap.sticky_key_profile(u8::MAX, StickyKeyShape::TapKey); + assert!(pure_mod.release_mode.is_some_and(|mode| { + mode.contains(StickyKeyReleaseMode::LAYER_ENTER) && mode.contains(StickyKeyReleaseMode::LAYER_EXIT) + })); + assert_eq!(tap_key.release_mode, None); + } } diff --git a/rmk/src/storage/mod.rs b/rmk/src/storage/mod.rs index ed925e33f..973884d91 100644 --- a/rmk/src/storage/mod.rs +++ b/rmk/src/storage/mod.rs @@ -513,7 +513,9 @@ impl Keyboard<'static> { // KEYMAP_TAP_SK: tap-key SK at col 0 and a basic key at col 1, for testing a timeout while held. const KEYMAP_TAP_SK: [[[KeyAction; 2]; 1]; 1] = [[[sk!(Tab, ModifierCombination::LALT), k!(A)]]]; +const KEYMAP_PROFILED_TAP_SK: [[[KeyAction; 2]; 1]; 1] = [[[sk!(Tab, ModifierCombination::LALT, 0), k!(A)]]]; + +const KEYMAP_PROFILED_PURE_MODS: [[[KeyAction; 3]; 1]; 1] = [[[ + sk_mod!(ModifierCombination::LSHIFT, 0), + sk_mod!(ModifierCombination::LCTRL, 1), + k!(A), +]]]; + fn create_test_keyboard_tap_sk() -> Keyboard<'static> { let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig { sticky_key: StickyKeyConfig { @@ -921,6 +929,85 @@ fn create_test_keyboard_tap_sk() -> Keyboard<'static> { Keyboard::new(wrap_keymap(KEYMAP_TAP_SK, per_key_config, behavior_config)) } +fn create_profiled_tap_sk_keyboard(profile: StickyKeyProfile) -> Keyboard<'static> { + let mut sticky_key = StickyKeyConfig::default(); + sticky_key.profiles.push(profile).unwrap(); + let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig { + sticky_key, + ..BehaviorConfig::default() + })); + let per_key_config: &'static PositionalConfig<1, 2> = Box::leak(Box::new(PositionalConfig::default())); + Keyboard::new(wrap_keymap(KEYMAP_PROFILED_TAP_SK, per_key_config, behavior_config)) +} + +#[test] +fn tap_key_other_key_release_keeps_modifier_through_release_report() { + key_sequence_test! { + keyboard: create_profiled_tap_sk_keyboard(StickyKeyProfile { + release_mode: Some(StickyKeyReleaseMode::OTHER_KEY_RELEASE), + ..StickyKeyProfile::default() + }), + sequence: [ + [0, 0, true, 0], + [0, 0, false, 0], + [0, 1, true, 0], + [0, 1, false, 0], + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], + [KC_LALT, [0, 0, 0, 0, 0, 0]], + [KC_LALT, [kc_to_u8!(A), 0, 0, 0, 0, 0]], + [KC_LALT, [0, 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + ] + }; +} + +#[test] +fn latest_accumulated_pure_mod_profile_owns_release_behavior() { + let mut sticky_key = StickyKeyConfig::default(); + sticky_key + .profiles + .push(StickyKeyProfile { + release_mode: Some(StickyKeyReleaseMode::OTHER_KEY_RELEASE), + ..StickyKeyProfile::default() + }) + .unwrap(); + sticky_key + .profiles + .push(StickyKeyProfile { + release_mode: Some(StickyKeyReleaseMode::OTHER_KEY_PRESS), + ..StickyKeyProfile::default() + }) + .unwrap(); + let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig { + sticky_key, + ..BehaviorConfig::default() + })); + let per_key_config: &'static PositionalConfig<1, 3> = Box::leak(Box::new(PositionalConfig::default())); + + key_sequence_test! { + keyboard: Keyboard::new(wrap_keymap( + KEYMAP_PROFILED_PURE_MODS, + per_key_config, + behavior_config, + )), + sequence: [ + [0, 0, true, 0], + [0, 0, false, 0], + [0, 1, true, 0], + [0, 1, false, 0], + [0, 2, true, 0], + [0, 2, false, 0], + ], + expected_reports: [ + [KC_LSHIFT | KC_LCTRL, [kc_to_u8!(A), 0, 0, 0, 0, 0]], + [0, [kc_to_u8!(A), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + ] + }; +} + // KEYMAP_TWO_TAP_SK: two tap-key SKs for verifying that a second physical key replaces the // first latch instead of reusing its key and modifiers. const KEYMAP_TWO_TAP_SK: [[[KeyAction; 2]; 1]; 1] = [[[ From 432d46f1b737d483a0540970cec126f2befaeb25 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:44:12 -0500 Subject: [PATCH 098/119] fix(config): preserve sticky profile references --- rmk-config/src/layout.rs | 60 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/rmk-config/src/layout.rs b/rmk-config/src/layout.rs index be0dc588f..74b2440e5 100644 --- a/rmk-config/src/layout.rs +++ b/rmk-config/src/layout.rs @@ -279,7 +279,32 @@ impl KeyboardTomlConfig { next_keys.push_str(value); made_replacement = true; } - None => return Err(format!("Undefined alias: {}", alias_key)), + None => { + // Sticky-key profiles use the same `@name` + // spelling as keymap aliases, but occur as the + // final argument of SK/OSM/OSL. Preserve that + // reference for the action parser instead of + // trying to resolve it as an alias. + let profile_end = current_keys[start_index + 1..] + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) + .map(|offset| start_index + 1 + offset) + .unwrap_or(current_keys.len()); + let profile_name = ¤t_keys[start_index + 1..profile_end]; + let follows_comma = current_keys[..start_index].trim_end().ends_with(','); + let closes_action = current_keys[profile_end..].trim_start().starts_with(')'); + let valid_profile_name = profile_name + .as_bytes() + .first() + .is_some_and(|c| c.is_ascii_alphabetic() || *c == b'_'); + + if follows_comma && closes_action && valid_profile_name { + next_keys.push_str(¤t_keys[start_index..profile_end]); + last_index = profile_end; + continue; + } + + return Err(format!("Undefined alias: {}", alias_key)); + } } last_index = end_index; // Move past the processed alias } else { @@ -810,4 +835,37 @@ mod tests { assert!(result.is_ok()); assert_eq!(result.unwrap(), vec!["OSM(LGui)", "OSM(LCtrl | LShift)", "OSL(1)"]); } + + #[test] + fn test_sticky_profile_refs_are_not_resolved_as_keymap_aliases() { + let aliases = HashMap::new(); + let layer_names = HashMap::new(); + let keymap = "SK(LGui, @osm) SK(Tab, [LAlt], @alt_tab) SK(MO(1), @nav) OSM(LShift, @osm) OSL(1, @nav)"; + + let result = KeyboardTomlConfig::keymap_parser(keymap, &aliases, &layer_names); + + assert!(result.is_ok()); + assert_eq!( + result.unwrap(), + vec![ + "SK(LGui, @osm)", + "SK(Tab, [LAlt], @alt_tab)", + "SK(MO(1), @nav)", + "OSM(LShift, @osm)", + "OSL(1, @nav)", + ] + ); + } + + #[test] + fn test_keymap_aliases_still_resolve_next_to_sticky_profile_refs() { + let aliases = HashMap::from([("copy".to_string(), "WM(C, LCtrl)".to_string())]); + let layer_names = HashMap::new(); + let keymap = "@copy SK(LGui, @osm)"; + + let result = KeyboardTomlConfig::keymap_parser(keymap, &aliases, &layer_names); + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), vec!["WM(C, LCtrl)", "SK(LGui, @osm)"]); + } } From 61cc25517278dfe13f13bdb6f6d923d93074ee42 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Sat, 18 Jul 2026 10:30:16 -0500 Subject: [PATCH 099/119] fix: satisfy clippy collapsible-if lints --- rmk/src/keyboard.rs | 26 ++++++++++++-------------- rmk/src/keyboard/sticky_key.rs | 12 +++++------- 2 files changed, 17 insertions(+), 21 deletions(-) diff --git a/rmk/src/keyboard.rs b/rmk/src/keyboard.rs index 6b09b6440..e14cd0b23 100644 --- a/rmk/src/keyboard.rs +++ b/rmk/src/keyboard.rs @@ -1294,24 +1294,22 @@ impl<'a> Keyboard<'a> { Action::LayerOff(layer_num) => { // Turn off a layer temporarily when the key is pressed // Reactivate the layer after the key is released - if event.pressed { - if self.keymap.deactivate_layer(layer_num) { - self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_EXIT) - .await; - } + if event.pressed && self.keymap.deactivate_layer(layer_num) { + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_EXIT) + .await; } } Action::LayerToggle(layer_num) => { // Toggle a layer when the key is released - if !event.pressed { - if let Some(active) = self.keymap.toggle_layer(layer_num) { - let mode = if active { - crate::config::StickyKeyReleaseMode::LAYER_ENTER - } else { - crate::config::StickyKeyReleaseMode::LAYER_EXIT - }; - self.release_sticky_key_on_layer_event(mode).await; - } + if !event.pressed + && let Some(active) = self.keymap.toggle_layer(layer_num) + { + let mode = if active { + crate::config::StickyKeyReleaseMode::LAYER_ENTER + } else { + crate::config::StickyKeyReleaseMode::LAYER_EXIT + }; + self.release_sticky_key_on_layer_event(mode).await; } } Action::LayerToggleOnly(layer_num) => { diff --git a/rmk/src/keyboard/sticky_key.rs b/rmk/src/keyboard/sticky_key.rs index 3ed8f2a91..de07d97f6 100644 --- a/rmk/src/keyboard/sticky_key.rs +++ b/rmk/src/keyboard/sticky_key.rs @@ -502,14 +502,12 @@ impl Keyboard<'_> { layer: Some(layer_num), .. } = self.sticky_key_state + && !event.pressed + && (mode.is_none() || mode.is_some_and(|mode| mode.contains(StickyKeyReleaseMode::OTHER_KEY_RELEASE))) { - if !event.pressed - && (mode.is_none() || mode.is_some_and(|mode| mode.contains(StickyKeyReleaseMode::OTHER_KEY_RELEASE))) - { - self.keymap.deactivate_layer(layer_num); - self.sticky_key_state = StickyKeyState::None; - return false; - } + self.keymap.deactivate_layer(layer_num); + self.sticky_key_state = StickyKeyState::None; + return false; } match &mut self.sticky_key_state { StickyKeyState::Active { From ff3ef6817cb859f5c959e854c00ccf87b17d1e83 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Sat, 18 Jul 2026 10:36:05 -0500 Subject: [PATCH 100/119] style: group auto mouse layer imports --- rmk/src/keyboard/auto_mouse_layer.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/rmk/src/keyboard/auto_mouse_layer.rs b/rmk/src/keyboard/auto_mouse_layer.rs index 857e989f4..13fdeea7f 100644 --- a/rmk/src/keyboard/auto_mouse_layer.rs +++ b/rmk/src/keyboard/auto_mouse_layer.rs @@ -15,8 +15,7 @@ use heapless::Vec; use rmk_macro::processor; use crate::AUTO_MOUSE_LAYER_MAX_NUM; -use crate::config::AutoMouseLayerConfig; -use crate::config::StickyKeyReleaseMode; +use crate::config::{AutoMouseLayerConfig, StickyKeyReleaseMode}; use crate::core_traits::Runnable; use crate::event::{Axis, AxisValType, LayerChangeEvent, PointingEvent}; use crate::keyboard::sticky_key::notify_sticky_layer_event; From 36f264209f74f7895a73efac9efdfb1dd7168a39 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Sun, 19 Jul 2026 16:25:20 -0500 Subject: [PATCH 101/119] refactor(sticky-key): simplify lifecycle state --- docs/docs/main/docs/configuration/appendix.md | 2 +- docs/docs/main/docs/configuration/behavior.md | 11 +- docs/docs/main/docs/configuration/event.md | 1 + rmk-config/Cargo.toml | 1 + .../src/default_config/event_default.toml | 5 + rmk-config/src/lib.rs | 1 + rmk-config/src/resolved/behavior.rs | 59 +- rmk-config/src/resolved/build_constants.rs | 1 + rmk-macro/src/codegen/behavior.rs | 13 +- rmk/Cargo.toml | 2 +- rmk/src/config/behavior.rs | 25 +- rmk/src/event/mod.rs | 1 + rmk/src/event/state.rs | 9 + rmk/src/keyboard.rs | 27 +- rmk/src/keyboard/auto_mouse_layer.rs | 7 +- rmk/src/keyboard/sticky_key.rs | 589 ++++++------------ rmk/src/keymap.rs | 6 +- rmk/tests/keyboard_sticky_key_test.rs | 77 +++ 18 files changed, 394 insertions(+), 443 deletions(-) diff --git a/docs/docs/main/docs/configuration/appendix.md b/docs/docs/main/docs/configuration/appendix.md index f2fd1c5da..4780d145f 100644 --- a/docs/docs/main/docs/configuration/appendix.md +++ b/docs/docs/main/docs/configuration/appendix.md @@ -120,7 +120,7 @@ sticky_key = { timeout = "1s", activate_on_keypress = false, max_repeat = 0, - # release_mode = "other_key_release | layer_exit", + # release_mode = "other_key_release | layer_exit | double_tap", } [behavior.morse] diff --git a/docs/docs/main/docs/configuration/behavior.md b/docs/docs/main/docs/configuration/behavior.md index ce41842b1..e541cadd1 100644 --- a/docs/docs/main/docs/configuration/behavior.md +++ b/docs/docs/main/docs/configuration/behavior.md @@ -52,7 +52,7 @@ The `[behavior.sticky_key]` table configures the unified **Sticky Key** (`SK`) f | `timeout` | `"1s"` | Auto-release an unused sticky key after this idle time. String suffixed `s` or `ms`. | | `activate_on_keypress` | `false` | **Pure-mod SKs only.** When `true`, send the modifier immediately as the SK key itself is pressed, instead of waiting and applying it to the next key. (Also known as One-Shot Sticky Modifiers / OSSM.) | | `max_repeat` | `0` | **Tap-key SKs only.** Caps how many repeated presses of the key keep the modifier held; `0` = unlimited. Pure-mod (`SK(LGui)`) and layer (`SK(MO(n))`) SKs ignore this — they always apply to exactly one following key. | -| `release_mode` | unset | Optional `|`-separated release triggers: `other_key_press`, `other_key_release`, `layer_enter`, and `layer_exit`. | +| `release_mode` | unset | Optional `|`-separated release triggers: `other_key_press`, `other_key_release`, `layer_enter`, `layer_exit`, and `double_tap`. | The default table applies to every Sticky Key. Define named overrides in `[behavior.sticky_key.profiles]` and select one by adding `@name` as the last @@ -62,6 +62,9 @@ Profile fields omitted from a named profile inherit from the default table. When `release_mode` is omitted, RMK preserves the legacy shape-native behavior: tap-key SKs release on another non-modifier key press; OSM and OSL are consumed on the terminating key release. An explicit mode overrides that behavior. +`double_tap` releases an active Sticky Key when the same physical Sticky Key is +pressed a second time. For tap-key SKs, this replaces the normal second cycling +press with a release. `timeout` applies to the sticky latch, not to a key that is still physically held. Holding an `SK` key longer than the configured timeout will not synthesize a key @@ -76,7 +79,7 @@ timeout = "1s" [behavior.sticky_key.profiles.alt_tab] timeout = "5s" -release_mode = "other_key_press | layer_enter | layer_exit" +release_mode = "other_key_press | layer_enter | layer_exit | double_tap" ``` Default values: @@ -86,7 +89,7 @@ Default values: timeout = "1s" activate_on_keypress = false max_repeat = 0 -# release_mode = "other_key_release | layer_exit" +# release_mode = "other_key_release | layer_exit | double_tap" ``` OSSM example (pure-mod SK activates on key press): @@ -129,7 +132,7 @@ Accepted breaking changes: - The old 5-positional `SK(key, [mod], max_repeat, timeout_ms, exit_on_layer_change)` form is **removed** → build error. The trailing knobs now live in `[behavior.sticky_key]`. - The `[behavior.one_shot]` and `[behavior.one_shot_modifiers]` config tables are **removed** → use `[behavior.sticky_key]`. -- The former `quick_release` and layer-change settings are replaced by `release_mode`; use one or more of `other_key_press`, `other_key_release`, `layer_enter`, and `layer_exit`. +- The former `quick_release` and layer-change settings are replaced by `release_mode`; use one or more of `other_key_press`, `other_key_release`, `layer_enter`, `layer_exit`, and `double_tap`. - Tap-key (alt-tab) SKs now have a **1s default timeout** (previously they had no timeout). Set `timeout` higher or rely on the default. ## Combo diff --git a/docs/docs/main/docs/configuration/event.md b/docs/docs/main/docs/configuration/event.md index 2c2741b86..8b5fd949e 100644 --- a/docs/docs/main/docs/configuration/event.md +++ b/docs/docs/main/docs/configuration/event.md @@ -55,6 +55,7 @@ peripheral_battery.subs = 4 | `pointing` | `PointingEvent` | channel_size=8 | | **State Events** | | | | `layer_change` | `LayerChangeEvent` | subs=4 | +| `sticky_key_release` | Internal Sticky Key event | channel_size=2 | | `wpm_update` | `WpmUpdateEvent` | | | `led_indicator` | `LedIndicatorEvent` | | | `sleep_state` | `SleepStateEvent` | | diff --git a/rmk-config/Cargo.toml b/rmk-config/Cargo.toml index db0e58d20..4cc7845f9 100644 --- a/rmk-config/Cargo.toml +++ b/rmk-config/Cargo.toml @@ -18,3 +18,4 @@ once_cell = "1.19" pest = "2.8" pest_derive = "2.8" paste = "1.0.15" +bitfield-struct = "0.13" diff --git a/rmk-config/src/default_config/event_default.toml b/rmk-config/src/default_config/event_default.toml index e6d054cfb..66e629fa3 100644 --- a/rmk-config/src/default_config/event_default.toml +++ b/rmk-config/src/default_config/event_default.toml @@ -24,6 +24,11 @@ channel_size = 1 pubs = 2 subs = 1 +[event.sticky_key_release] +channel_size = 2 +pubs = 1 +subs = 1 + [event.wpm_update] channel_size = 1 pubs = 1 diff --git a/rmk-config/src/lib.rs b/rmk-config/src/lib.rs index 3404e08f8..8f760c66e 100644 --- a/rmk-config/src/lib.rs +++ b/rmk-config/src/lib.rs @@ -409,6 +409,7 @@ define_event_config!( keyboard, // Keyboard state events layer_change, + sticky_key_release, wpm_update, led_indicator, sleep_state, diff --git a/rmk-config/src/resolved/behavior.rs b/rmk-config/src/resolved/behavior.rs index 7a353c0de..0406fb96c 100644 --- a/rmk-config/src/resolved/behavior.rs +++ b/rmk-config/src/resolved/behavior.rs @@ -1,5 +1,7 @@ use std::collections::HashMap; +use bitfield_struct::bitfield; + pub struct StickyKeyConfig { pub timeout_ms: Option, pub activate_on_keypress: Option, @@ -8,32 +10,42 @@ pub struct StickyKeyConfig { pub profiles: HashMap, } -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct StickyKeyReleaseMode(pub u8); +#[bitfield(u8, order = Lsb, debug = false)] +#[derive(Debug, 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, + #[bits(3)] + __: u8, +} impl StickyKeyReleaseMode { - pub const OTHER_KEY_PRESS: Self = Self(1 << 0); - pub const OTHER_KEY_RELEASE: Self = Self(1 << 1); - pub const LAYER_ENTER: Self = Self(1 << 2); - pub const LAYER_EXIT: Self = Self(1 << 3); + pub const OTHER_KEY_PRESS: Self = Self::new().with_other_key_press(true); + pub const OTHER_KEY_RELEASE: Self = Self::new().with_other_key_release(true); + pub const LAYER_ENTER: Self = Self::new().with_layer_enter(true); + pub const LAYER_EXIT: Self = Self::new().with_layer_exit(true); + pub const DOUBLE_TAP: Self = Self::new().with_double_tap(true); pub fn parse(value: &str) -> Result { let mut result = Self::default(); for part in value.split('|').map(str::trim).filter(|part| !part.is_empty()) { - let flag = match part { - "other_key_press" => Self::OTHER_KEY_PRESS, - "other_key_release" => Self::OTHER_KEY_RELEASE, - "layer_enter" => Self::LAYER_ENTER, - "layer_exit" => Self::LAYER_EXIT, + result = match part { + "other_key_press" => result.with_other_key_press(true), + "other_key_release" => result.with_other_key_release(true), + "layer_enter" => result.with_layer_enter(true), + "layer_exit" => result.with_layer_exit(true), + "double_tap" => result.with_double_tap(true), _ => { return Err(format!( - "unknown Sticky Key release_mode `{part}`; expected other_key_press, other_key_release, layer_enter, or layer_exit" + "unknown Sticky Key release_mode `{part}`; expected other_key_press, other_key_release, layer_enter, layer_exit, or double_tap" )); } }; - result.0 |= flag.0; } - if result.0 == 0 { + if result.into_bits() == 0 { return Err("Sticky Key release_mode must contain at least one trigger".to_string()); } Ok(result) @@ -401,7 +413,7 @@ keymap = [ ] [behavior.sticky_key] -release_mode = "other_key_release | layer_exit" +release_mode = "other_key_release | layer_exit | double_tap" [behavior.sticky_key.profiles.alt_tab] timeout = "5s" @@ -422,17 +434,22 @@ release_mode = "other_key_press | layer_enter" let sticky_key = config.behavior().unwrap().sticky_key.unwrap(); assert_eq!( sticky_key.release_mode, - Some(StickyKeyReleaseMode( - StickyKeyReleaseMode::OTHER_KEY_RELEASE.0 | StickyKeyReleaseMode::LAYER_EXIT.0 - )) + Some( + StickyKeyReleaseMode::new() + .with_other_key_release(true) + .with_layer_exit(true) + .with_double_tap(true) + ) ); let alt_tab = &sticky_key.profiles["alt_tab"]; assert_eq!(alt_tab.timeout_ms, Some(5000)); assert_eq!( alt_tab.release_mode, - Some(StickyKeyReleaseMode( - StickyKeyReleaseMode::OTHER_KEY_PRESS.0 | StickyKeyReleaseMode::LAYER_ENTER.0 - )) + Some( + StickyKeyReleaseMode::new() + .with_other_key_press(true) + .with_layer_enter(true) + ) ); } } diff --git a/rmk-config/src/resolved/build_constants.rs b/rmk-config/src/resolved/build_constants.rs index de07385e8..00bd5813f 100644 --- a/rmk-config/src/resolved/build_constants.rs +++ b/rmk-config/src/resolved/build_constants.rs @@ -100,6 +100,7 @@ impl crate::KeyboardTomlConfig { modifier, keyboard, layer_change, + sticky_key_release, wpm_update, led_indicator, sleep_state, diff --git a/rmk-macro/src/codegen/behavior.rs b/rmk-macro/src/codegen/behavior.rs index 981e953da..9d49ea850 100644 --- a/rmk-macro/src/codegen/behavior.rs +++ b/rmk-macro/src/codegen/behavior.rs @@ -6,7 +6,7 @@ use quote::quote; use rmk_config::resolved::Behavior; use rmk_config::resolved::behavior::{ AutoMouseLayer, Combos, Forks, MacroOperation, Macros, Morse, MorseActionPair, MorseKey, - MorseProfile, StickyKeyProfile, StickyKeyReleaseMode, + MorseProfile, StickyKeyProfile, }; use super::action_parser::{expand_profile, expand_profile_name, get_key_with_alias, parse_key}; @@ -35,8 +35,13 @@ fn expand_sticky_key_profile( let max_repeat = profile.max_repeat.or(fallback.max_repeat).unwrap_or(0); let release_mode = profile.release_mode.or(fallback.release_mode); let release_mode = match release_mode { - Some(StickyKeyReleaseMode(bits)) => { - quote! { ::core::option::Option::Some(::rmk::config::StickyKeyReleaseMode(#bits)) } + Some(mode) => { + let bits = mode.into_bits(); + quote! { + ::core::option::Option::Some( + ::rmk::config::StickyKeyReleaseMode::from_bits(#bits) + ) + } } None => quote! { ::core::option::Option::None }, }; @@ -595,7 +600,7 @@ mod tests { timeout_ms: None, activate_on_keypress: None, max_repeat: None, - release_mode: Some(StickyKeyReleaseMode(StickyKeyReleaseMode::LAYER_ENTER.0)), + release_mode: Some(StickyKeyReleaseMode::LAYER_ENTER), profiles: HashMap::new(), }), auto_mouse_layer: Vec::new(), diff --git a/rmk/Cargo.toml b/rmk/Cargo.toml index 3b22016e8..21c732bf5 100644 --- a/rmk/Cargo.toml +++ b/rmk/Cargo.toml @@ -47,6 +47,7 @@ postcard = { version = "1", features = ["experimental-derive"] } # Used in macro paste = "1" +bitfield-struct = "0.13" # Display dependencies ssd1306 = { version = "0.10", optional = true, features = ["async"] } @@ -285,4 +286,3 @@ _ble = ["dep:trouble-host", "dep:bt-hci", "storage", "rmk-types/_ble"] doctest = false [workspace.dependencies] embassy-nrf = { version = "0.10", features = ["nrf52840"] } - diff --git a/rmk/src/config/behavior.rs b/rmk/src/config/behavior.rs index b4cd2eccb..9e04b7c0c 100644 --- a/rmk/src/config/behavior.rs +++ b/rmk/src/config/behavior.rs @@ -1,3 +1,4 @@ +use bitfield_struct::bitfield; use embassy_time::Duration; use heapless::Vec; use rmk_types::fork::Fork; @@ -102,17 +103,27 @@ impl Default for MorsesConfig { } } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct StickyKeyReleaseMode(pub u8); +#[bitfield(u8, order = Lsb, debug = false)] +#[derive(Debug, 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, + #[bits(3)] + __: u8, +} impl StickyKeyReleaseMode { - pub const OTHER_KEY_PRESS: Self = Self(1 << 0); - pub const OTHER_KEY_RELEASE: Self = Self(1 << 1); - pub const LAYER_ENTER: Self = Self(1 << 2); - pub const LAYER_EXIT: Self = Self(1 << 3); + pub const OTHER_KEY_PRESS: Self = Self::new().with_other_key_press(true); + pub const OTHER_KEY_RELEASE: Self = Self::new().with_other_key_release(true); + pub const LAYER_ENTER: Self = Self::new().with_layer_enter(true); + pub const LAYER_EXIT: Self = Self::new().with_layer_exit(true); + pub const DOUBLE_TAP: Self = Self::new().with_double_tap(true); pub const fn contains(self, other: Self) -> bool { - self.0 & other.0 != 0 + self.into_bits() & other.into_bits() != 0 } } diff --git a/rmk/src/event/mod.rs b/rmk/src/event/mod.rs index e13297825..4b6e54b09 100644 --- a/rmk/src/event/mod.rs +++ b/rmk/src/event/mod.rs @@ -67,6 +67,7 @@ pub use input::{ pub use split::{CentralConnectedEvent, PeripheralConnectedEvent}; #[cfg(all(feature = "split", feature = "_ble"))] pub use split::{ClearPeerEvent, PeripheralBatteryEvent}; +pub(crate) use state::StickyKeyReleaseEvent; pub use state::{LayerChangeEvent, LedIndicatorEvent, SleepStateEvent, WpmUpdateEvent}; /// Trait for event publishers diff --git a/rmk/src/event/state.rs b/rmk/src/event/state.rs index eb38b7213..3ac637f81 100644 --- a/rmk/src/event/state.rs +++ b/rmk/src/event/state.rs @@ -3,6 +3,8 @@ use rmk_macro::event; use rmk_types::led_indicator::LedIndicator; +use crate::config::StickyKeyReleaseMode; + /// Active layer changed event #[event(channel_size = crate::LAYER_CHANGE_EVENT_CHANNEL_SIZE, pubs = crate::LAYER_CHANGE_EVENT_PUB_SIZE, subs = crate::LAYER_CHANGE_EVENT_SUB_SIZE)] #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -17,6 +19,13 @@ impl LayerChangeEvent { impl_payload_wrapper!(LayerChangeEvent, u8); +/// A layer transition that may release an active Sticky Key. +#[event(channel_size = crate::STICKY_KEY_RELEASE_EVENT_CHANNEL_SIZE, pubs = crate::STICKY_KEY_RELEASE_EVENT_PUB_SIZE, subs = crate::STICKY_KEY_RELEASE_EVENT_SUB_SIZE)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct StickyKeyReleaseEvent(pub StickyKeyReleaseMode); + +impl_payload_wrapper!(StickyKeyReleaseEvent, StickyKeyReleaseMode); + /// WPM updated event #[event(channel_size = crate::WPM_UPDATE_EVENT_CHANNEL_SIZE, pubs = crate::WPM_UPDATE_EVENT_PUB_SIZE, subs = crate::WPM_UPDATE_EVENT_SUB_SIZE)] #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/rmk/src/keyboard.rs b/rmk/src/keyboard.rs index e14cd0b23..c318c88db 100644 --- a/rmk/src/keyboard.rs +++ b/rmk/src/keyboard.rs @@ -20,14 +20,15 @@ use crate::core_traits::Runnable; #[cfg(all(feature = "split", feature = "_ble"))] use crate::event::ClearPeerEvent; use crate::event::{ - ActionEvent, KeyboardEvent, KeyboardEventPos, ModifierEvent, SubscribableEvent, publish_event, publish_event_async, + ActionEvent, KeyboardEvent, KeyboardEventPos, ModifierEvent, StickyKeyReleaseEvent, SubscribableEvent, + publish_event, publish_event_async, }; use crate::hid::{KeyboardReport, Report}; use crate::keyboard::combo::Combo; use crate::keyboard::fork::ActiveFork; use crate::keyboard::held_buffer::{HeldBuffer, HeldKey, KeyState}; use crate::keyboard::mouse::{MouseAction, MouseState}; -use crate::keyboard::sticky_key::{SkPhase, StickyKeyState, next_sticky_layer_event}; +use crate::keyboard::sticky_key::StickyKeyState; use crate::keyboard_macros::MacroOperation; use crate::keymap::{KeyMap, StickyKeyShape}; #[cfg(all(feature = "split", feature = "_ble"))] @@ -165,7 +166,7 @@ impl Runnable for Keyboard<'_> { if let Some(deadline) = deadline { match select3( self.keyboard_event_subscriber.next_message_pure(), - next_sticky_layer_event(), + self.sticky_key_release_event_subscriber.next_message_pure(), Timer::at(deadline), ) .await @@ -174,20 +175,20 @@ impl Runnable for Keyboard<'_> { self.process_inner(event).await; } Either3::Second(layer_event) => { - self.release_sticky_key_on_layer_event(layer_event).await; + self.release_sticky_key_on_layer_event(layer_event.0).await; } Either3::Third(_) => {} } } else { match select( self.keyboard_event_subscriber.next_message_pure(), - next_sticky_layer_event(), + self.sticky_key_release_event_subscriber.next_message_pure(), ) .await { Either::First(event) => self.process_inner(event).await, Either::Second(layer_event) => { - self.release_sticky_key_on_layer_event(layer_event).await; + self.release_sticky_key_on_layer_event(layer_event.0).await; } } } @@ -218,6 +219,15 @@ pub struct Keyboard<'a> { { crate::KEYBOARD_EVENT_PUB_SIZE }, >, + sticky_key_release_event_subscriber: embassy_sync::pubsub::Subscriber< + 'static, + crate::RawMutex, + StickyKeyReleaseEvent, + { crate::STICKY_KEY_RELEASE_EVENT_CHANNEL_SIZE }, + { crate::STICKY_KEY_RELEASE_EVENT_SUB_SIZE }, + { crate::STICKY_KEY_RELEASE_EVENT_PUB_SIZE }, + >, + /// Unprocessed events pub unprocessed_events: Vec, @@ -285,6 +295,7 @@ impl<'a> Keyboard<'a> { Keyboard { keymap, keyboard_event_subscriber: KeyboardEvent::subscriber(), + sticky_key_release_event_subscriber: StickyKeyReleaseEvent::subscriber(), last_press_time: Instant::now(), sticky_key_state: StickyKeyState::default(), caps_word: CapsWordState::default(), @@ -1467,9 +1478,9 @@ impl<'a> Keyboard<'a> { // press report and is "released" together with the key release — except in held // mode (key pressed while SK still physically held), where the modifier behaves // like a normal held modifier and stays applied until the SK itself is released. - if let StickyKeyState::Active { mods, phase, .. } = self.sticky_key_state { + if let Some(mods) = self.sticky_key_state.value().copied() { if self.sticky_key_state.is_pure_mod() || self.sticky_key_state.is_layer() { - if pressed || phase == SkPhase::Held { + if pressed || self.sticky_key_state.is_held() { result |= mods; } } else { diff --git a/rmk/src/keyboard/auto_mouse_layer.rs b/rmk/src/keyboard/auto_mouse_layer.rs index 13fdeea7f..b2cd18b2d 100644 --- a/rmk/src/keyboard/auto_mouse_layer.rs +++ b/rmk/src/keyboard/auto_mouse_layer.rs @@ -17,8 +17,7 @@ use rmk_macro::processor; use crate::AUTO_MOUSE_LAYER_MAX_NUM; use crate::config::{AutoMouseLayerConfig, StickyKeyReleaseMode}; use crate::core_traits::Runnable; -use crate::event::{Axis, AxisValType, LayerChangeEvent, PointingEvent}; -use crate::keyboard::sticky_key::notify_sticky_layer_event; +use crate::event::{Axis, AxisValType, LayerChangeEvent, PointingEvent, StickyKeyReleaseEvent, publish_event}; use crate::keymap::KeyMap; use crate::processor::DeadlineProcessor; @@ -75,7 +74,7 @@ impl<'a, 'k> AutoMouseLayerRunner<'a, 'k> { let target_layer = self.entries[idx].config.target_layer; let activated_by_us = self.keymap.activate_layer_if_inactive(target_layer); if activated_by_us { - notify_sticky_layer_event(StickyKeyReleaseMode::LAYER_ENTER); + publish_event(StickyKeyReleaseEvent(StickyKeyReleaseMode::LAYER_ENTER)); } if pointing_step(&mut self.entries, idx, Instant::now(), activated_by_us) == PointingOutcome::OverlapFirstSeen { warn!( @@ -133,7 +132,7 @@ impl DeadlineProcessor for AutoMouseLayerRunner<'_, '_> { async fn on_deadline(&mut self) { for layer in timeout_step(&mut self.entries, Instant::now()) { if self.keymap.deactivate_layer_if_active(layer) { - notify_sticky_layer_event(StickyKeyReleaseMode::LAYER_EXIT); + publish_event(StickyKeyReleaseEvent(StickyKeyReleaseMode::LAYER_EXIT)); } } } diff --git a/rmk/src/keyboard/sticky_key.rs b/rmk/src/keyboard/sticky_key.rs index de07d97f6..16c86e52d 100644 --- a/rmk/src/keyboard/sticky_key.rs +++ b/rmk/src/keyboard/sticky_key.rs @@ -2,13 +2,12 @@ //! //! A unified one-shot action engine covering pure-mod (OSM), tap-key, and layer (OSL) shapes. //! The shape is determined by the `StickyKeyAction` payload at compile time. -//! Runtime state is tracked in `StickyKeyState`; the latch phase is tracked in `SkPhase`. +//! Runtime state and its lifecycle are represented by `StickyKeyState`. //! //! Timeout is driven solely by the run-loop deadline race (see `Keyboard::run`); there is //! no inline `select` in this module. On expiry the run loop calls //! [`Keyboard::release_sticky_key_if_active`]. -use embassy_sync::channel::Channel; use embassy_time::{Duration, Instant}; use rmk_types::action::StickyKeyAction; use rmk_types::keycode::HidKeyCode; @@ -19,164 +18,81 @@ use crate::event::{KeyboardEvent, KeyboardEventPos}; use crate::keyboard::Keyboard; use crate::keymap::StickyKeyShape; -static STICKY_LAYER_EVENT_CHANNEL: Channel = Channel::new(); - -pub(crate) fn notify_sticky_layer_event(event: StickyKeyReleaseMode) { - if STICKY_LAYER_EVENT_CHANNEL.try_send(event).is_err() { - warn!("sticky-key layer event channel is full; dropping layer transition"); - } -} - -pub(crate) async fn next_sticky_layer_event() -> StickyKeyReleaseMode { - STICKY_LAYER_EVENT_CHANNEL.receive().await +fn deadline_from_timeout(timeout: Duration) -> Option { + (timeout != Duration::MAX).then(|| Instant::now() + timeout) } -/// Latch phase of a sticky key. -/// -/// Mirrors the former OSM state machine: `Pressed` == Initial, `Latched` == Single, -/// `Held` == Held. -#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] -pub(crate) enum SkPhase { - /// SK pressed, not yet consumed (still physically held). OSM `Initial`. - #[default] - Pressed, - /// Armed — SK released before any other key, waiting for the next (foreign) key. OSM `Single`. - Latched, - /// Another key was pressed while the SK was still held; behaves like a normal held - /// modifier until the SK is released. OSM `Held`. - Held, +/// The operation performed while a Sticky Key is active. +#[derive(Clone, Copy, Debug)] +enum StickyKeyEffect { + Modifier, + Layer(u8), + TapKey(HidKeyCode), } -/// An optional deadline stored without `Option`'s extra discriminant. -/// -/// `Duration::MAX` already means "no timeout" in sticky-key configuration, so -/// `Instant::MAX` is reserved as the inactive sentinel here. +/// Data carried through each active Sticky Key lifecycle state. #[derive(Clone, Copy, Debug)] -pub(crate) struct StickyKeyDeadline(Instant); - -impl StickyKeyDeadline { - const NONE: Self = Self(Instant::MAX); - - fn from_timeout(timeout: Duration) -> Self { - if timeout == Duration::MAX { - Self::NONE - } else { - Self(Instant::now() + timeout) - } - } - - const fn get(self) -> Option { - if self.0.as_ticks() == Instant::MAX.as_ticks() { - None - } else { - Some(self.0) - } - } - - fn clear(&mut self) { - *self = Self::NONE; - } +pub(crate) struct ActiveStickyKey { + /// Physical key that owns this latch. + source: KeyboardEventPos, + mods: ModifierCombination, + effect: StickyKeyEffect, + /// Selected Sticky Key profile (`u8::MAX` means default profile). + profile: u8, + repeat_count: u16, + deadline: Option, } -/// State for the StickyKey action. -#[derive(Clone, Copy, Default, Debug)] +/// Lifecycle of a Sticky Key. +#[derive(Clone, Copy, Debug, Default)] pub(crate) enum StickyKeyState { - /// StickyKey is inactive. + /// No Sticky Key is active. #[default] None, - /// StickyKey is active — carries all latch state the engine needs. - Active { - /// Physical key that owns this latch. - source: KeyboardEventPos, - mods: ModifierCombination, - /// `HidKeyCode::No` = pure-mod or layer shape; any other key = tap-key shape. - key: HidKeyCode, - /// `Some(n)` = OSL shape; `None` = pure-mod or tap-key shape. - layer: Option, - /// Selected Sticky Key profile (`u8::MAX` means default profile). - profile: u8, - phase: SkPhase, - /// Whether the physical StickyKey switch is currently held down. - pressed: bool, - repeat_count: u16, - deadline: StickyKeyDeadline, - }, + /// The physical Sticky Key is down and no foreign key has been pressed. + Pressed(ActiveStickyKey), + /// The physical Sticky Key was released and is armed for a foreign key. + Latched(ActiveStickyKey), + /// A foreign key was pressed while the physical Sticky Key remained down. + Held(ActiveStickyKey), } -#[cfg(test)] -mod size_tests { - use core::mem::size_of; - - use super::*; - - #[allow(dead_code)] - enum StateWithOptionDeadline { - None, - Active { - source: KeyboardEventPos, - mods: ModifierCombination, - key: HidKeyCode, - layer: Option, - phase: SkPhase, - pressed: bool, - repeat_count: u16, - deadline: Option, - }, - } - - #[test] - fn sentinel_deadline_reduces_sticky_key_state_size() { - assert_eq!(size_of::(), size_of::()); - assert!(size_of::() < size_of::()); - } +enum ReleaseTransition { + Ignored, + Latched, + Held, } impl StickyKeyState { pub fn value(&self) -> Option<&ModifierCombination> { - match self { - StickyKeyState::Active { mods, .. } => Some(mods), - StickyKeyState::None => None, - } + self.active().map(|active| &active.mods) } pub fn is_active(&self) -> bool { - matches!(self, StickyKeyState::Active { .. }) + !matches!(self, StickyKeyState::None) } pub fn deadline(&self) -> Option { - match self { - StickyKeyState::Active { deadline, .. } => deadline.get(), - StickyKeyState::None => None, - } + self.active().and_then(|active| active.deadline) } - /// True when this is a pure-mod shape: active with no tap key and no layer. pub fn is_pure_mod(&self) -> bool { - matches!( - self, - StickyKeyState::Active { - key: HidKeyCode::No, - layer: None, - .. - } - ) + self.active() + .is_some_and(|active| matches!(active.effect, StickyKeyEffect::Modifier)) } - /// True when this is a tap-key shape: active with a non-No key code. pub fn is_tap_key(&self) -> bool { - self.is_active() && !self.is_pure_mod() && !self.is_layer() + self.active() + .is_some_and(|active| matches!(active.effect, StickyKeyEffect::TapKey(_))) } - /// True when this is a layer (OSL) shape: active with a `Some` layer. pub fn is_layer(&self) -> bool { - matches!(self, StickyKeyState::Active { layer: Some(_), .. }) + self.active() + .is_some_and(|active| matches!(active.effect, StickyKeyEffect::Layer(_))) } pub(crate) fn profile(&self) -> Option { - match self { - StickyKeyState::Active { profile, .. } => Some(*profile), - StickyKeyState::None => None, - } + self.active().map(|active| active.profile) } pub(crate) fn shape(&self) -> Option { @@ -190,9 +106,39 @@ impl StickyKeyState { None } } + + pub(crate) fn is_held(&self) -> bool { + matches!(self, Self::Held(_)) + } + + fn active(&self) -> Option<&ActiveStickyKey> { + match self { + Self::Pressed(active) | Self::Latched(active) | Self::Held(active) => Some(active), + Self::None => None, + } + } } impl Keyboard<'_> { + fn transition_on_release( + &mut self, + owner: Option, + deadline: Option, + ) -> ReleaseTransition { + match self.sticky_key_state { + StickyKeyState::Pressed(mut active) if owner.is_none_or(|owner| active.source == owner) => { + active.deadline = deadline; + self.sticky_key_state = StickyKeyState::Latched(active); + ReleaseTransition::Latched + } + StickyKeyState::Held(active) if owner.is_none_or(|owner| active.source == owner) => { + self.sticky_key_state = StickyKeyState::None; + ReleaseTransition::Held + } + _ => ReleaseTransition::Ignored, + } + } + pub(crate) async fn release_sticky_key_on_layer_event(&mut self, event: StickyKeyReleaseMode) { let (Some(index), Some(shape)) = (self.sticky_key_state.profile(), self.sticky_key_state.shape()) else { return; @@ -208,12 +154,33 @@ impl Keyboard<'_> { } pub(crate) async fn process_action_sticky_key(&mut self, params: StickyKeyAction, event: KeyboardEvent) { - if params.layer.is_some() { - self.process_sticky_layer(params, event).await; + let shape = if params.layer.is_some() { + StickyKeyShape::Layer } else if params.key == HidKeyCode::No { - self.process_sticky_pure_mod(params, event).await; + StickyKeyShape::PureMod } else { - self.process_sticky_tap_key(params, event).await; + StickyKeyShape::TapKey + }; + + if event.pressed + && matches!( + self.sticky_key_state, + StickyKeyState::Latched(ActiveStickyKey { source, .. }) if source == event.pos + ) + && self + .keymap + .sticky_key_profile(params.profile, shape) + .release_mode + .is_some_and(|mode| mode.double_tap()) + { + self.release_sticky_key_if_active().await; + return; + } + + match shape { + StickyKeyShape::Layer => self.process_sticky_layer(params, event).await, + StickyKeyShape::PureMod => self.process_sticky_pure_mod(params, event).await, + StickyKeyShape::TapKey => self.process_sticky_tap_key(params, event).await, } } @@ -221,168 +188,83 @@ impl Keyboard<'_> { /// terminating key, honor `activate_on_keypress`/`quick_release`. async fn process_sticky_pure_mod(&mut self, params: StickyKeyAction, event: KeyboardEvent) { let profile = self.keymap.sticky_key_profile(params.profile, StickyKeyShape::PureMod); - let deadline = StickyKeyDeadline::from_timeout(profile.timeout); + let deadline = deadline_from_timeout(profile.timeout); if event.pressed { - // Single mutually-exclusive latch: a pure-mod press accumulates (3c) onto an - // existing pure-mod latch, but REPLACES any other shape (layer or tap-key). - // Releasing the foreign latch first deactivates a held layer and drops its mods - // cleanly, so only a same-shape (pure-mod) latch can reach the accumulate arm below. if self.sticky_key_state.is_active() && !self.sticky_key_state.is_pure_mod() && !self.sticky_key_state.is_layer() { self.release_sticky_key_if_active().await; } - match &mut self.sticky_key_state { - StickyKeyState::None => { - self.sticky_key_state = StickyKeyState::Active { - source: event.pos, - mods: params.keep, - key: params.key, - layer: None, - profile: params.profile, - phase: SkPhase::Pressed, - pressed: true, - repeat_count: 1, - deadline, - }; - } - StickyKeyState::Active { - mods, - profile, - pressed, - deadline: d, - .. - } => { - // Same-shape pure-mod re-press: accumulate (3c) and refresh the deadline. - *mods |= params.keep; - // The most recently pressed pure-mod StickyKey owns the - // accumulated latch's behavior and refreshed deadline. - *profile = params.profile; - *pressed = true; - *d = deadline; + + self.sticky_key_state = match self.sticky_key_state.active().copied() { + None => StickyKeyState::Pressed(ActiveStickyKey { + source: event.pos, + mods: params.keep, + effect: StickyKeyEffect::Modifier, + profile: params.profile, + repeat_count: 1, + deadline, + }), + Some(mut active) => { + active.source = event.pos; + active.mods |= params.keep; + active.profile = params.profile; + active.deadline = deadline; + StickyKeyState::Pressed(active) } - } + }; if profile.activate_on_keypress { self.send_keyboard_report_with_resolved_modifiers(true).await; } } else { - // SK released. - if let StickyKeyState::Active { pressed, .. } = &mut self.sticky_key_state { - *pressed = false; - } - match self.sticky_key_state { - StickyKeyState::Active { - phase: SkPhase::Pressed, - .. - } => { - // Released before any other key → arm it for the next key. Refresh the - // deadline on release-to-Latched so the timeout is measured from release - // time (not press time). Mirrors the layer shape behavior at line 214. - if let StickyKeyState::Active { phase, deadline: d, .. } = &mut self.sticky_key_state { - *phase = SkPhase::Latched; - *d = deadline; - } - } - StickyKeyState::Active { - phase: SkPhase::Held, .. - } => { - // Held-mode: the modifier was applied as a normal held modifier; releasing - // the SK releases it now in its own report. - self.sticky_key_state = StickyKeyState::None; - self.send_keyboard_report_with_resolved_modifiers(false).await; - } - _ => {} + // Combo outputs may be released by a different constituent position, + // so modifier actions cannot require the original source position. + if matches!(self.transition_on_release(None, deadline), ReleaseTransition::Held) { + self.send_keyboard_report_with_resolved_modifiers(false).await; } } } - /// Layer (OSL) shape: activate the layer for the next foreign key. Mirrors the former - /// `process_action_osl`. The layer carries no modifier, so consuming it emits no HID - /// report — the foreign key resolves on the active layer in `process_action_key` before - /// the latch is consumed. + /// Layer (OSL) shape: activate the layer for the next foreign key. The layer carries + /// no modifier, so consuming it emits no HID report. async fn process_sticky_layer(&mut self, params: StickyKeyAction, event: KeyboardEvent) { let layer_num = params.layer.expect("layer shape requires a layer"); let profile = self.keymap.sticky_key_profile(params.profile, StickyKeyShape::Layer); - let deadline = StickyKeyDeadline::from_timeout(profile.timeout); + let deadline = deadline_from_timeout(profile.timeout); if event.pressed { - // A held tap-key owns a registered HID key. Release it before this - // layer shape takes over the shared latch so its later physical - // release cannot leave that HID key stuck in the report. if self.sticky_key_state.is_tap_key() { self.release_sticky_key_if_active().await; } - // Latch-replacement rule on a single mutually-exclusive latch: a layer SK press - // takes over the latch. Deactivate any previously-latched OSL layer first, then - // drop any latched mods/tap-key. A layer-on-layer press keeps the existing phase - // (mirrors old `process_action_osl` lines 51-56); any other shape becomes a fresh - // Pressed latch. - let (prev_phase, existing_mods) = match self.sticky_key_state { - StickyKeyState::Active { - layer: Some(prev_layer), - phase, - mods, - .. - } => { - self.keymap.deactivate_layer(prev_layer); - (phase, mods) + let existing_mods = match self.sticky_key_state.active().copied() { + Some(active) => { + if let StickyKeyEffect::Layer(previous_layer) = active.effect { + self.keymap.deactivate_layer(previous_layer); + } + active.mods } - StickyKeyState::Active { mods, phase, .. } => (phase, mods), - _ => (SkPhase::Pressed, ModifierCombination::new()), + None => ModifierCombination::new(), }; self.keymap.activate_layer(layer_num); - self.sticky_key_state = StickyKeyState::Active { + self.sticky_key_state = StickyKeyState::Pressed(ActiveStickyKey { source: event.pos, mods: existing_mods | params.keep, - key: params.key, - layer: Some(layer_num), + effect: StickyKeyEffect::Layer(layer_num), profile: params.profile, - phase: prev_phase, - pressed: true, repeat_count: 1, deadline, - }; + }); } else { - // SK released. - if !matches!( - self.sticky_key_state, - StickyKeyState::Active { source, .. } if source == event.pos + if matches!( + self.transition_on_release(Some(event.pos), deadline), + ReleaseTransition::Held ) { - return; - } - match self.sticky_key_state { - StickyKeyState::Active { - phase: SkPhase::Pressed | SkPhase::Latched, - .. - } => { - // Released before any other key → arm it for the next key and (re)arm the - // deadline so the run-loop race covers expiry. - if let StickyKeyState::Active { - phase, - pressed, - deadline: d, - .. - } = &mut self.sticky_key_state - { - *phase = SkPhase::Latched; - *pressed = false; - *d = deadline; - } - } - StickyKeyState::Active { - phase: SkPhase::Held, .. - } => { - // Held-mode: the layer stayed active while the SK was physically held. - // Releasing the SK deactivates the layer now (no HID report). - self.keymap.deactivate_layer(layer_num); - self.sticky_key_state = StickyKeyState::None; - } - StickyKeyState::None => {} + self.keymap.deactivate_layer(layer_num); } } } @@ -392,83 +274,54 @@ impl Keyboard<'_> { /// `activate_on_keypress`/`quick_release`. async fn process_sticky_tap_key(&mut self, params: StickyKeyAction, event: KeyboardEvent) { let profile = self.keymap.sticky_key_profile(params.profile, StickyKeyShape::TapKey); - let deadline = StickyKeyDeadline::from_timeout(profile.timeout); + let deadline = deadline_from_timeout(profile.timeout); if event.pressed { - // A repeated press of the same physical tap-key cycles it. A different tap-key, like - // any foreign StickyKey shape, replaces the active latch so it gets its own key and - // modifiers rather than reusing the first key's state. - let is_different_tap_key = matches!( - self.sticky_key_state, - StickyKeyState::Active { source, .. } if source != event.pos - ); + let is_different_tap_key = self + .sticky_key_state + .active() + .is_some_and(|active| active.source != event.pos); if self.sticky_key_state.is_active() && (!self.sticky_key_state.is_tap_key() || is_different_tap_key) { self.release_sticky_key_if_active().await; } let mut should_deactivate = false; - - match &mut self.sticky_key_state { - StickyKeyState::None => { - self.sticky_key_state = StickyKeyState::Active { - source: event.pos, - mods: params.keep, - key: params.key, - layer: None, - profile: params.profile, - phase: SkPhase::Latched, - pressed: true, - repeat_count: 1, - deadline, - }; - } - StickyKeyState::Active { - pressed, - repeat_count, - deadline: d, - .. - } => { - // Saturating so an unbounded (`max_repeat == 0`) cycle can never overflow - // the counter and panic on a debug build after 65535 presses. - *repeat_count = repeat_count.saturating_add(1); - if profile.max_repeat > 0 && *repeat_count > profile.max_repeat { + self.sticky_key_state = match self.sticky_key_state.active().copied() { + None => StickyKeyState::Pressed(ActiveStickyKey { + source: event.pos, + mods: params.keep, + effect: StickyKeyEffect::TapKey(params.key), + profile: params.profile, + repeat_count: 1, + deadline, + }), + Some(mut active) => { + active.repeat_count = active.repeat_count.saturating_add(1); + if profile.max_repeat > 0 && active.repeat_count > profile.max_repeat { should_deactivate = true; + StickyKeyState::None } else { - *pressed = true; - *d = deadline; + active.deadline = deadline; + StickyKeyState::Pressed(active) } } - } + }; if should_deactivate { - self.sticky_key_state = StickyKeyState::None; self.send_keyboard_report_with_resolved_modifiers(false).await; } else { self.register_key(params.key, event); self.send_keyboard_report_with_resolved_modifiers(true).await; } - } else { - // Only unregister and report if SK was active (key was registered on press). - // If max_repeat deactivated SK silently on the press event, the key was never - // registered, so the release is a no-op. - if let StickyKeyState::Active { - source, - pressed, - deadline, - .. - } = &mut self.sticky_key_state - && *source == event.pos - { - *pressed = false; - // A timeout that fired while this key was held cleared its deadline so - // this physical release could unregister the tap key safely. Re-arm the - // latched modifier now; otherwise it would remain active indefinitely. - if deadline.get().is_none() { - *deadline = StickyKeyDeadline::from_timeout(profile.timeout); - } - self.unregister_key(params.key, event); - self.send_keyboard_report_with_resolved_modifiers(false).await; + } else if let StickyKeyState::Pressed(mut active) = self.sticky_key_state + && active.source == event.pos + { + if active.deadline.is_none() { + active.deadline = deadline; } + self.unregister_key(params.key, event); + self.send_keyboard_report_with_resolved_modifiers(false).await; + self.sticky_key_state = StickyKeyState::Latched(active); } } @@ -493,68 +346,32 @@ impl Keyboard<'_> { .profile() .zip(self.sticky_key_state.shape()) .and_then(|(index, shape)| self.keymap.sticky_key_profile(index, shape).release_mode); - // Layer (OSL) shape: mirror the former `update_osl`. Pressed→Held on a foreign key - // (handled by the shared Pressed arm below, which also clears the deadline). A Latched - // layer is consumed on the foreign key's RELEASE: deactivate the layer and clear the - // latch. No HID report — deactivating a layer emits nothing. - if let StickyKeyState::Active { - phase: SkPhase::Latched, - layer: Some(layer_num), - .. - } = self.sticky_key_state - && !event.pressed - && (mode.is_none() || mode.is_some_and(|mode| mode.contains(StickyKeyReleaseMode::OTHER_KEY_RELEASE))) - { - self.keymap.deactivate_layer(layer_num); - self.sticky_key_state = StickyKeyState::None; - return false; - } - match &mut self.sticky_key_state { - StickyKeyState::Active { - phase: phase @ SkPhase::Pressed, - deadline, - .. - } => { - // A key was pressed while the SK is still physically held → promote to Held. - // OSM `Held` has no timeout: the modifier stays live until the SK is physically - // released (held-alt-tab use case). Clear the run-loop deadline so it does not - // spuriously time-out while held. - *phase = SkPhase::Held; - deadline.clear(); + match self.sticky_key_state { + StickyKeyState::Pressed(mut active) => { + active.deadline = None; + self.sticky_key_state = StickyKeyState::Held(active); false } - StickyKeyState::Active { - phase: SkPhase::Latched, - layer: Some(layer_num), - .. - } if mode.is_some_and(|mode| mode.contains(StickyKeyReleaseMode::OTHER_KEY_PRESS)) && event.pressed => { - self.keymap.deactivate_layer(*layer_num); - self.sticky_key_state = StickyKeyState::None; - true - } - StickyKeyState::Active { - phase: SkPhase::Latched, - .. - } if mode.is_some_and(|mode| mode.contains(StickyKeyReleaseMode::OTHER_KEY_PRESS)) && event.pressed => { - self.sticky_key_state = StickyKeyState::None; - true - } - StickyKeyState::Active { - phase: SkPhase::Latched, - .. - } if mode.is_some_and(|mode| mode.contains(StickyKeyReleaseMode::OTHER_KEY_RELEASE)) && !event.pressed => { - self.sticky_key_state = StickyKeyState::None; - true - } - // No explicit release mode preserves the old one-shot behavior. - StickyKeyState::Active { - phase: SkPhase::Latched, - .. - } if mode.is_none() && !event.pressed => { - self.sticky_key_state = StickyKeyState::None; - true + StickyKeyState::Latched(active) => { + let release_on_press = + event.pressed && mode.is_some_and(|mode| mode.contains(StickyKeyReleaseMode::OTHER_KEY_PRESS)); + let release_on_release = !event.pressed + && (mode.is_none() + || mode.is_some_and(|mode| mode.contains(StickyKeyReleaseMode::OTHER_KEY_RELEASE))); + if !release_on_press && !release_on_release { + return false; + } + + if let StickyKeyEffect::Layer(layer) = active.effect { + self.keymap.deactivate_layer(layer); + self.sticky_key_state = StickyKeyState::None; + release_on_press + } else { + self.sticky_key_state = StickyKeyState::None; + true + } } - _ => false, + StickyKeyState::None | StickyKeyState::Held(_) => false, } } @@ -568,20 +385,18 @@ impl Keyboard<'_> { return; } - // If the SK is still physically held (Pressed phase), the deadline fired but the + // If the SK is still physically held, the deadline fired but the // key hasn't been released yet. Don't clear the latch — the physical release // handler (process_sticky_*) will transition Held→None cleanly. For pure-mod, // the deadline was set on press (→ Held on any other key press), so this can // only happen when the key is held and idle. For layer and tap-key shapes, the // deadline fires in the same scenario. // Clear the deadline to avoid busy-looping on every iteration. - if matches!(self.sticky_key_state, StickyKeyState::Active { pressed: true, .. }) { + if let StickyKeyState::Pressed(active) = &mut self.sticky_key_state { debug!( "StickyKey timeout fired while key is still held — clearing deadline, deferring to physical release" ); - if let StickyKeyState::Active { deadline, .. } = &mut self.sticky_key_state { - deadline.clear(); - } + active.deadline = None; return; } @@ -609,13 +424,7 @@ impl Keyboard<'_> { .sticky_key_profile(index, StickyKeyShape::PureMod) .activate_on_keypress }); - matches!( - self.sticky_key_state, - StickyKeyState::Active { - phase: SkPhase::Held, - .. - } - ) || activate_on_keypress + self.sticky_key_state.is_held() || activate_on_keypress } else { // tap-key shape always reports; layer shape never does (deactivating emits nothing). !self.sticky_key_state.is_layer() @@ -624,12 +433,11 @@ impl Keyboard<'_> { // A tap-key may still have its HID key registered when it is displaced by a different // StickyKey while physically held. Unregister it before clearing the latch so it cannot // remain stuck in the report. - if let StickyKeyState::Active { - key: hid_key, - layer: None, + if let Some(ActiveStickyKey { + effect: StickyKeyEffect::TapKey(hid_key), source, .. - } = self.sticky_key_state + }) = self.sticky_key_state.active().copied() { self.unregister_key( hid_key, @@ -641,9 +449,10 @@ impl Keyboard<'_> { } // For the layer shape, deactivate the active layer before clearing the latch. - if let StickyKeyState::Active { - layer: Some(layer_num), .. - } = self.sticky_key_state + if let Some(ActiveStickyKey { + effect: StickyKeyEffect::Layer(layer_num), + .. + }) = self.sticky_key_state.active().copied() { self.keymap.deactivate_layer(layer_num); } diff --git a/rmk/src/keymap.rs b/rmk/src/keymap.rs index 9951297af..539ed039d 100644 --- a/rmk/src/keymap.rs +++ b/rmk/src/keymap.rs @@ -611,7 +611,7 @@ impl<'a> KeyMap<'a> { if profile.release_mode.is_none() { let mut mode = 0; if shape == StickyKeyShape::PureMod && config.quick_release { - mode |= StickyKeyReleaseMode::OTHER_KEY_PRESS.0; + mode |= StickyKeyReleaseMode::OTHER_KEY_PRESS.into_bits(); } let layer_release = match shape { StickyKeyShape::PureMod => config.one_shot_mod_release_on_layer_change, @@ -620,10 +620,10 @@ impl<'a> KeyMap<'a> { } .unwrap_or(config.release_on_layer_change); if layer_release { - mode |= StickyKeyReleaseMode::LAYER_ENTER.0 | StickyKeyReleaseMode::LAYER_EXIT.0; + mode |= StickyKeyReleaseMode::LAYER_ENTER.into_bits() | StickyKeyReleaseMode::LAYER_EXIT.into_bits(); } if mode != 0 { - profile.release_mode = Some(StickyKeyReleaseMode(mode)); + profile.release_mode = Some(StickyKeyReleaseMode::from_bits(mode)); } } profile diff --git a/rmk/tests/keyboard_sticky_key_test.rs b/rmk/tests/keyboard_sticky_key_test.rs index 0f341ee26..42c104941 100644 --- a/rmk/tests/keyboard_sticky_key_test.rs +++ b/rmk/tests/keyboard_sticky_key_test.rs @@ -80,6 +80,16 @@ fn create_test_keyboard_puremod() -> Keyboard<'static> { Keyboard::new(wrap_keymap(KEYMAP_PUREMOD, per_key_config, behavior_config)) } +fn sticky_key_config_with_release_mode(release_mode: StickyKeyReleaseMode) -> StickyKeyConfig { + StickyKeyConfig { + default_profile: StickyKeyProfile { + release_mode: Some(release_mode), + ..StickyKeyProfile::default() + }, + ..StickyKeyConfig::default() + } +} + // KEYMAP_MIXED: all three SK shapes on layer 0, used to exercise the mutually-exclusive // latch (pressing a different-shape SK while one is latched REPLACES it, never merges). // Layer 0: SK(LGui) SK(Tab,LAlt) SK(MO(1)) P No No @@ -739,6 +749,52 @@ fn test_sk_puremod_cross_tap_accumulation() { }; } +#[test] +fn pure_mod_double_tap_releases_latch() { + let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig { + sticky_key: sticky_key_config_with_release_mode(StickyKeyReleaseMode::DOUBLE_TAP), + ..BehaviorConfig::default() + })); + let per_key_config: &'static PositionalConfig<1, 6> = Box::leak(Box::new(PositionalConfig::default())); + + key_sequence_test! { + keyboard: Keyboard::new(wrap_keymap(KEYMAP_PUREMOD, per_key_config, behavior_config)), + sequence: [ + [0, 0, true, 0], + [0, 0, false, 0], + [0, 0, true, 0], + [0, 0, false, 0], + [0, 3, true, 0], + [0, 3, false, 0], + ], + expected_reports: [ + [0, [kc_to_u8!(P), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + ] + }; +} + +#[test] +fn sticky_layer_double_tap_deactivates_layer() { + key_sequence_test! { + keyboard: create_osl_layer_change_keyboard( + sticky_key_config_with_release_mode(StickyKeyReleaseMode::DOUBLE_TAP) + ), + sequence: [ + [0, 0, true, 0], + [0, 0, false, 0], + [0, 0, true, 0], + [0, 0, false, 0], + [0, 2, true, 0], + [0, 2, false, 0], + ], + expected_reports: [ + [0, [kc_to_u8!(A), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + ] + }; +} + /// StickyKey Test 12 (regression): a tap-key SK pressed while a PURE-MOD SK is latched /// REPLACES it — the latch is mutually exclusive, so the old modifier is dropped, not /// merged. Without the replacement guard the tap-key press would OR the pure-mod's LGui @@ -963,6 +1019,27 @@ fn tap_key_other_key_release_keeps_modifier_through_release_report() { }; } +#[test] +fn tap_key_double_tap_releases_instead_of_cycling() { + key_sequence_test! { + keyboard: create_profiled_tap_sk_keyboard(StickyKeyProfile { + release_mode: Some(StickyKeyReleaseMode::DOUBLE_TAP), + ..StickyKeyProfile::default() + }), + sequence: [ + [0, 0, true, 0], + [0, 0, false, 0], + [0, 0, true, 0], + [0, 0, false, 0], + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], + [KC_LALT, [0, 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + ] + }; +} + #[test] fn latest_accumulated_pure_mod_profile_owns_release_behavior() { let mut sticky_key = StickyKeyConfig::default(); From c072015c6cf071c6896bc2893382e2163e37922c Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:19:26 -0500 Subject: [PATCH 102/119] feat(sticky-key): align feature with current main --- docs/docs/main/docs/configuration/appendix.md | 13 +- docs/docs/main/docs/configuration/behavior.md | 108 +- docs/docs/main/docs/configuration/event.md | 1 + docs/docs/main/docs/configuration/layout.md | 14 +- .../main/docs/configuration/rmk_config.md | 3 + .../use_config/esp32_ble_split/keyboard.toml | 2 +- examples/use_config/esp32c3_ble/keyboard.toml | 2 +- examples/use_config/esp32c6_ble/keyboard.toml | 2 +- examples/use_config/esp32s3_ble/keyboard.toml | 2 +- .../use_config/nrf52832_ble/keyboard.toml | 2 +- .../use_config/nrf52840_ble/keyboard.toml | 2 +- .../nrf52840_ble_split/keyboard.toml | 2 +- .../keyboard.toml | 2 +- .../nrf52840_ble_split_dongle/keyboard.toml | 2 +- .../use_config/pi_pico_w_ble/keyboard.toml | 2 +- .../pi_pico_w_ble_split/keyboard.toml | 2 +- examples/use_config/rp2040/keyboard.toml | 2 +- .../rp2040_direct_pin/keyboard.toml | 2 +- examples/use_config/rp2040_oled/keyboard.toml | 2 +- .../use_config/rp2040_split/keyboard.toml | 2 +- .../use_config/rp2040_split_pio/keyboard.toml | 2 +- examples/use_config/stm32f1/keyboard.toml | 2 +- examples/use_config/stm32f4/keyboard.toml | 2 +- examples/use_config/stm32h7/keyboard.toml | 2 +- rmk-config/Cargo.toml | 1 + rmk-config/src/behavior.rs | 3 +- .../src/default_config/event_default.toml | 5 + rmk-config/src/keymap.pest | 30 +- rmk-config/src/layout.rs | 159 ++- rmk-config/src/lib.rs | 48 +- rmk-config/src/resolved/behavior.rs | 163 ++- rmk-config/src/resolved/build_constants.rs | 3 + rmk-macro/src/codegen/action_parser.rs | 191 ++- rmk-macro/src/codegen/behavior.rs | 181 ++- rmk-macro/src/codegen/layout.rs | 26 +- rmk-types/build.rs | 4 + rmk-types/src/action/mod.rs | 55 + .../rmk/snapshots/endpoint_keys_base.snap | 20 +- .../rmk/snapshots/endpoint_keys_bulk.snap | 12 +- rmk-types/src/protocol/rmk/system.rs | 7 +- rmk/Cargo.toml | 1 + rmk/src/config/behavior.rs | 84 +- rmk/src/config/mod.rs | 2 +- rmk/src/event/mod.rs | 1 + rmk/src/event/state.rs | 9 + rmk/src/host/context.rs | 9 +- rmk/src/host/via/keycode_convert.rs | 147 +- rmk/src/host/via/vial.rs | 6 +- rmk/src/keyboard.rs | 282 +++- rmk/src/keyboard/auto_mouse_layer.rs | 16 +- rmk/src/keyboard/oneshot.rs | 194 --- rmk/src/keyboard/sticky_key.rs | 477 +++++++ rmk/src/keymap.rs | 215 ++- rmk/src/layout_macro.rs | 108 +- rmk/src/storage/mod.rs | 16 +- rmk/tests/keyboard_combo_test.rs | 17 +- rmk/tests/keyboard_one_shot_test.rs | 106 +- rmk/tests/keyboard_sticky_key_test.rs | 1220 +++++++++++++++++ 58 files changed, 3336 insertions(+), 659 deletions(-) delete mode 100644 rmk/src/keyboard/oneshot.rs create mode 100644 rmk/src/keyboard/sticky_key.rs create mode 100644 rmk/tests/keyboard_sticky_key_test.rs diff --git a/docs/docs/main/docs/configuration/appendix.md b/docs/docs/main/docs/configuration/appendix.md index e3cee5513..4780d145f 100644 --- a/docs/docs/main/docs/configuration/appendix.md +++ b/docs/docs/main/docs/configuration/appendix.md @@ -115,15 +115,12 @@ tri_layer = { adjust = 3, } -# OneShot configuration -one_shot = { - timeout = "1s" -} - -# One Shot Modifiers configuration -one_shot_modifiers = { +# Sticky Key configuration (replaces the former one_shot / one_shot_modifiers tables) +sticky_key = { + timeout = "1s", activate_on_keypress = false, - quick_release = false, + max_repeat = 0, + # release_mode = "other_key_release | layer_exit | double_tap", } [behavior.morse] diff --git a/docs/docs/main/docs/configuration/behavior.md b/docs/docs/main/docs/configuration/behavior.md index 9eeb6a172..986e24f92 100644 --- a/docs/docs/main/docs/configuration/behavior.md +++ b/docs/docs/main/docs/configuration/behavior.md @@ -9,12 +9,9 @@ tri_layer = { lower = 2, adjust = 3, } -one_shot = { +sticky_key = { timeout = "1s", } -one_shot_modifiers = { - activate_on_keypress = false, -} ``` ## Tri Layer @@ -34,51 +31,110 @@ In this example, when both layers 1 (`upper`) and 2 (`lower`) are active, layer Note that `"#layer_name"` could also be used in place of layer numbers. -## One-Shot +## Sticky Key + +The `[behavior.sticky_key]` table configures the unified **Sticky Key** (`SK`) feature. `SK` unifies the former `OSM` (one-shot modifier) and `OSL` (one-shot layer) actions into a single engine. `OSM(mod)` and `OSL(n)` remain available as aliases for `SK(mod)` and `SK(MO(n))` — they desugar to the exact same action, so either spelling works. + +### SK shapes -The `one_shot` sub-table contains common one-shot configuration (for both OSM and OSL) +`SK` selects its behavior based on the shape of its argument: -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`). +| Shape | Syntax | Behavior | +|-------|--------|----------| +| Pure-mod | `SK(LGui)` (modifiers chain like `WM`, e.g. `SK(LCtrl\|LShift)`) | One-shot modifier — the modifier is held for the next key press, then released automatically. | +| Layer | `SK(MO(n))` | One-shot layer — layer `n` is active for the next key press, then released. | +| Tap-key | `SK(Tab, [LAlt])` (the modifier list is in `[ ]`; modifiers chain, e.g. `SK(Tab, [LCtrl\|LShift])`) | The modifier stays held across **repeated presses of the same key** (Alt+Tab-style window/tab cycling): the first press sends `modifier + key`, each subsequent press keeps the modifier held. Releases automatically when any non-SK, non-modifier key is pressed. | -## One-Shot Modifiers +### Config fields -The `one_shot_modifiers` sub-table configures one-shot modifiers (OSM). +| Field | Default | Meaning | +|-------|---------|---------| +| `timeout` | `"1s"` | Auto-release an unused sticky key after this idle time. String suffixed `s` or `ms`. | +| `activate_on_keypress` | `false` | **Pure-mod SKs only.** When `true`, send the modifier immediately as the SK key itself is pressed, instead of waiting and applying it to the next key. (Also known as One-Shot Sticky Modifiers / OSSM.) | +| `max_repeat` | `0` | **Tap-key SKs only.** Caps how many repeated presses of the key keep the modifier held; `0` = unlimited. Pure-mod (`SK(LGui)`) and layer (`SK(MO(n))`) SKs ignore this — they always apply to exactly one following key. | +| `release_mode` | unset | Optional `|`-separated release triggers: `other_key_press`, `other_key_release`, `layer_enter`, `layer_exit`, and `double_tap`. | -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). +The default table applies to every Sticky Key. Define named overrides in +`[behavior.sticky_key.profiles]` and select one by adding `@name` as the last +argument: `SK(LGui, @gui)`, `SK(Tab, [LAlt], @alt_tab)`, or `SK(MO(1), @nav)`. +Profile fields omitted from a named profile inherit from the default table. -If you press One-Shot Modifier again, it will be sent as a normal modifier key press and, therefore, released. +When `release_mode` is omitted, RMK preserves the legacy shape-native behavior: +tap-key SKs release on another non-modifier key press; OSM and OSL are consumed +on the terminating key release. An explicit mode overrides that behavior. +`double_tap` releases an active Sticky Key when the same physical Sticky Key is +pressed a second time. For tap-key SKs, this replaces the normal second cycling +press with a release. -The `quick_release` option controls when the one-shot modifier is released: +`timeout` applies to the sticky latch, not to a key that is still physically held. +Holding an `SK` key longer than the configured timeout will not synthesize a key +release; releasing the physical key then completes the action normally. -- `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. +For example, an Alt+Tab profile can release on another key press or either +direction of a layer transition: + +```toml +[behavior.sticky_key] +timeout = "1s" + +[behavior.sticky_key.profiles.alt_tab] +timeout = "5s" +release_mode = "other_key_press | layer_enter | layer_exit | double_tap" +``` Default values: ```toml -[behavior.one_shot_modifiers] +[behavior.sticky_key] +timeout = "1s" activate_on_keypress = false -quick_release = false +max_repeat = 0 +# release_mode = "other_key_release | layer_exit | double_tap" ``` -OSSM example: +OSSM example (pure-mod SK activates on key press): ```toml -[behavior.one_shot_modifiers] +[behavior.sticky_key] activate_on_keypress = true ``` -Quick-release example: +Press-release-mode example (modifier released when next key is pressed): ```toml -[behavior.one_shot_modifiers] -quick_release = true +[behavior.sticky_key] +release_mode = "other_key_press" ``` +Longer timeout example: + +```toml +[behavior.sticky_key] +timeout = "5s" +``` + +For keymap usage, see `SK(...)` in the [keymap configuration](./layout#keyboard-layout-configuration). + +### Migration from OSM / OSL + +`OSM(mod)` and `OSL(n)` are **still supported** as aliases — they desugar to `SK(mod)` and `SK(MO(n))` respectively, so existing keymaps keep working unchanged. The `SK` forms are the canonical spelling; use whichever you prefer. The old 5-positional `SK` form and the `[behavior.one_shot]` / `[behavior.one_shot_modifiers]` config tables, however, are **removed** — using them is a build error. + +| Old | New (canonical) | Alias still accepted | +|-----|-----------------|----------------------| +| `OSM(LGui)` | `SK(LGui)` | `OSM(LGui)` | +| `OSL(1)` | `SK(MO(1))` | `OSL(1)` | +| `SK(Tab, [LAlt], 0, 0, false)` (5-positional) | `SK(Tab, [LAlt])` + `[behavior.sticky_key]` | — | +| `[behavior.one_shot]` `timeout` | `[behavior.sticky_key]` `timeout` | — | +| `[behavior.one_shot_modifiers]` `activate_on_keypress` / `quick_release` | `[behavior.sticky_key]` `activate_on_keypress` / `release_mode` | — | +| `exit_on_layer_change` | `release_mode = "layer_enter | layer_exit"` | — | + +Accepted breaking changes: + +- The old 5-positional `SK(key, [mod], max_repeat, timeout_ms, exit_on_layer_change)` form is **removed** → build error. The trailing knobs now live in `[behavior.sticky_key]`. +- The `[behavior.one_shot]` and `[behavior.one_shot_modifiers]` config tables are **removed** → use `[behavior.sticky_key]`. +- The former `quick_release` and layer-change settings are replaced by `release_mode`; use one or more of `other_key_press`, `other_key_release`, `layer_enter`, `layer_exit`, and `double_tap`. +- Tap-key (alt-tab) SKs now have a **1s default timeout** (previously they had no timeout). Set `timeout` higher or rely on the default. + ## Combo In the `combo` sub-table, you can configure the keyboard's combo key functionality. Combo allows you to define a group of keys that, when pressed simultaneously, will trigger a specific output action. @@ -434,7 +490,7 @@ keymap = [ ["A", "B", "C"], ["TD(0)", "TD(1)", "TD(2)"], # Use morse dances 0, 1, and 2 ["LCtrl", "MO(1)", "LShift"], - ["OSL(1)", "LT(2, Kc9, PN)", "LM(1, LShift | LGui)"] # PN is a morse profile name here + ["SK(MO(1))", "LT(2, Kc9, PN)", "LM(1, LShift | LGui)"] # PN is a morse profile name here [ ["_", "TT(1)", "TG(2)"], ["_", "_", "_"], diff --git a/docs/docs/main/docs/configuration/event.md b/docs/docs/main/docs/configuration/event.md index 2c2741b86..8b5fd949e 100644 --- a/docs/docs/main/docs/configuration/event.md +++ b/docs/docs/main/docs/configuration/event.md @@ -55,6 +55,7 @@ peripheral_battery.subs = 4 | `pointing` | `PointingEvent` | channel_size=8 | | **State Events** | | | | `layer_change` | `LayerChangeEvent` | subs=4 | +| `sticky_key_release` | Internal Sticky Key event | channel_size=2 | | `wpm_update` | `WpmUpdateEvent` | | | `led_indicator` | `LedIndicatorEvent` | | | `sleep_state` | `SleepStateEvent` | | diff --git a/docs/docs/main/docs/configuration/layout.md b/docs/docs/main/docs/configuration/layout.md index 9f43f80da..40c0db2b8 100644 --- a/docs/docs/main/docs/configuration/layout.md +++ b/docs/docs/main/docs/configuration/layout.md @@ -122,11 +122,15 @@ The `layer.keys` string should follow several rules: 2. Use `MO(n)` to create a layer activate action, `n` is the layer number 3. Use `LM(n, modifier)` to create layer activate with modifier action. The modifier can be chained in the same way as `WM` 4. Use `LT(n, key, )` to create a layer activate action or tap key(tap/hold). The `key` here is the RMK [`KeyCode`](https://docs.rs/rmk/latest/rmk/keycode/enum.KeyCode.html), The `profile_name` is optional, which defines the key's [profile](./behavior#per-key-profiles-for-morse-tapdance-tap-hold-fine-tuning) - 5. Use `OSL(n)` to create a one-shot layer action, `n` is the layer number - 6. Use `OSM(modifier)` to create a one-shot modifier action. The modifier can be chained in the same way as `WM` - 7. Use `TT(n)` to create a layer activate or tap toggle action, `n` is the layer number - 8. Use `TG(n)` to create a layer toggle action, `n` is the layer number - 9. Use `TO(n)` to create a layer toggle only action (activate layer `n` and deactivate all other layers), `n` is the layer number + 5. Use `SK(...)` to create a sticky key action — behavior is selected by argument shape: + - `SK(modifier, @profile)` — one-shot modifier (also spelled `OSM(modifier, @profile)`, an alias). The optional `@profile` selects a `[behavior.sticky_key.profiles]` entry. + - `SK(MO(n), @profile)` — one-shot layer (also spelled `OSL(n, @profile)`, an alias). + - `SK(key, [modifier], @profile)` — tap-key (Alt+Tab-style cycling). The modifier list is in `[ ]`; `@profile` is optional. + + See [Sticky Key](./behavior#sticky-key) for default and named-profile configuration. + 6. Use `TT(n)` to create a layer activate or tap toggle action, `n` is the layer number + 7. Use `TG(n)` to create a layer toggle action, `n` is the layer number + 8. Use `TO(n)` to create a layer toggle only action (activate layer `n` and deactivate all other layers), `n` is the layer number The definitions of these operations are the same as QMK's; you can find them [here](https://docs.qmk.fm/#/feature_layers). If you want other actions, please [file an issue](https://github.com/HaoboGu/rmk/issues/new). diff --git a/docs/docs/main/docs/configuration/rmk_config.md b/docs/docs/main/docs/configuration/rmk_config.md index 995189a6d..d8e6d99a0 100644 --- a/docs/docs/main/docs/configuration/rmk_config.md +++ b/docs/docs/main/docs/configuration/rmk_config.md @@ -18,6 +18,8 @@ combo_max_length = 4 fork_max_num = 8 # Maximum number of morse keys keyboard can store (max 256) morse_max_num = 8 +# Maximum number of named Sticky Key profiles (max 255) +sticky_key_profile_max_num = 16 # 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. @@ -59,6 +61,7 @@ Increasing the number of combos, forks, morses (tap dances), and macros will inc - `combo_max_length`: Maximum number of keys that can be pressed simultaneously in a combo, default value is 4. - `fork_max_num`: Maximum number of forks for conditional key actions, default value is 8. This value must be between 0 and 256. - `morse_max_num`: Maximum number of morses that can be stored, default value is 8. This value must be between 0 and 256. +- `sticky_key_profile_max_num`: Capacity of the named Sticky Key profile table, 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. (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. diff --git a/examples/use_config/esp32_ble_split/keyboard.toml b/examples/use_config/esp32_ble_split/keyboard.toml index 5b252689a..3ddfb3691 100644 --- a/examples/use_config/esp32_ble_split/keyboard.toml +++ b/examples/use_config/esp32_ble_split/keyboard.toml @@ -20,7 +20,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], diff --git a/examples/use_config/esp32c3_ble/keyboard.toml b/examples/use_config/esp32c3_ble/keyboard.toml index 36ec4a23c..0ebe277fc 100644 --- a/examples/use_config/esp32c3_ble/keyboard.toml +++ b/examples/use_config/esp32c3_ble/keyboard.toml @@ -24,7 +24,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], diff --git a/examples/use_config/esp32c6_ble/keyboard.toml b/examples/use_config/esp32c6_ble/keyboard.toml index 97c021044..35b41a8e7 100644 --- a/examples/use_config/esp32c6_ble/keyboard.toml +++ b/examples/use_config/esp32c6_ble/keyboard.toml @@ -24,7 +24,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], diff --git a/examples/use_config/esp32s3_ble/keyboard.toml b/examples/use_config/esp32s3_ble/keyboard.toml index 9f8c16a1e..4fd94bdc9 100644 --- a/examples/use_config/esp32s3_ble/keyboard.toml +++ b/examples/use_config/esp32s3_ble/keyboard.toml @@ -23,7 +23,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], diff --git a/examples/use_config/nrf52832_ble/keyboard.toml b/examples/use_config/nrf52832_ble/keyboard.toml index b8857e677..d1f3ec1b9 100644 --- a/examples/use_config/nrf52832_ble/keyboard.toml +++ b/examples/use_config/nrf52832_ble/keyboard.toml @@ -23,7 +23,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], diff --git a/examples/use_config/nrf52840_ble/keyboard.toml b/examples/use_config/nrf52840_ble/keyboard.toml index f9831ebb3..75c7cce2a 100644 --- a/examples/use_config/nrf52840_ble/keyboard.toml +++ b/examples/use_config/nrf52840_ble/keyboard.toml @@ -42,7 +42,7 @@ name = "second_layer" keys = """ TD(1) TO(0) WM(W,LShift) No DF(0) LT(1, Space) LM(0, LShift | RGui) -OSL(0) OSM(LAlt) TH(Kp1, Kp2) SHIFTED(Kp2) +SK(MO(0)) SK(LAlt) TH(Kp1, Kp2) SHIFTED(Kp2) @my_copy @my_paste """ # Encoder 0 - CW: BrightnessUp, CCW: BrightnessDown diff --git a/examples/use_config/nrf52840_ble_split/keyboard.toml b/examples/use_config/nrf52840_ble_split/keyboard.toml index 02a29c477..1cf088a20 100644 --- a/examples/use_config/nrf52840_ble_split/keyboard.toml +++ b/examples/use_config/nrf52840_ble_split/keyboard.toml @@ -15,7 +15,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["TD(1)", "TT(1)", "TG(2)"], diff --git a/examples/use_config/nrf52840_ble_split_direct_pin/keyboard.toml b/examples/use_config/nrf52840_ble_split_direct_pin/keyboard.toml index da49b93e3..2d253a575 100644 --- a/examples/use_config/nrf52840_ble_split_direct_pin/keyboard.toml +++ b/examples/use_config/nrf52840_ble_split_direct_pin/keyboard.toml @@ -15,7 +15,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], diff --git a/examples/use_config/nrf52840_ble_split_dongle/keyboard.toml b/examples/use_config/nrf52840_ble_split_dongle/keyboard.toml index 97cf795a8..f3dc5f900 100644 --- a/examples/use_config/nrf52840_ble_split_dongle/keyboard.toml +++ b/examples/use_config/nrf52840_ble_split_dongle/keyboard.toml @@ -15,7 +15,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["TD(1)", "TT(1)", "TG(2)"], diff --git a/examples/use_config/pi_pico_w_ble/keyboard.toml b/examples/use_config/pi_pico_w_ble/keyboard.toml index 5cf21bddb..de270815c 100644 --- a/examples/use_config/pi_pico_w_ble/keyboard.toml +++ b/examples/use_config/pi_pico_w_ble/keyboard.toml @@ -22,7 +22,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], diff --git a/examples/use_config/pi_pico_w_ble_split/keyboard.toml b/examples/use_config/pi_pico_w_ble_split/keyboard.toml index 966106f1f..9e032dc96 100644 --- a/examples/use_config/pi_pico_w_ble_split/keyboard.toml +++ b/examples/use_config/pi_pico_w_ble_split/keyboard.toml @@ -15,7 +15,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], diff --git a/examples/use_config/rp2040/keyboard.toml b/examples/use_config/rp2040/keyboard.toml index d24fb13ca..e0bae4c67 100644 --- a/examples/use_config/rp2040/keyboard.toml +++ b/examples/use_config/rp2040/keyboard.toml @@ -35,7 +35,7 @@ keymap = [ "LShift", ], [ - "OSL(1)", + "SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)", ], diff --git a/examples/use_config/rp2040_direct_pin/keyboard.toml b/examples/use_config/rp2040_direct_pin/keyboard.toml index 88196dbfb..3c5ad1a59 100644 --- a/examples/use_config/rp2040_direct_pin/keyboard.toml +++ b/examples/use_config/rp2040_direct_pin/keyboard.toml @@ -25,7 +25,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "_", "_"], - ["OSL(1)", "LT(2, Kc9)", "_"] + ["SK(MO(1))", "LT(2, Kc9)", "_"] ], [ ["_", "TT(1)", "TG(2)"], diff --git a/examples/use_config/rp2040_oled/keyboard.toml b/examples/use_config/rp2040_oled/keyboard.toml index f77446524..1986afd29 100644 --- a/examples/use_config/rp2040_oled/keyboard.toml +++ b/examples/use_config/rp2040_oled/keyboard.toml @@ -35,7 +35,7 @@ keymap = [ "LShift", ], [ - "OSL(1)", + "SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)", ], diff --git a/examples/use_config/rp2040_split/keyboard.toml b/examples/use_config/rp2040_split/keyboard.toml index e855a1179..3b5b8f235 100644 --- a/examples/use_config/rp2040_split/keyboard.toml +++ b/examples/use_config/rp2040_split/keyboard.toml @@ -15,7 +15,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], diff --git a/examples/use_config/rp2040_split_pio/keyboard.toml b/examples/use_config/rp2040_split_pio/keyboard.toml index b5d288caf..01c0cf675 100644 --- a/examples/use_config/rp2040_split_pio/keyboard.toml +++ b/examples/use_config/rp2040_split_pio/keyboard.toml @@ -15,7 +15,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], diff --git a/examples/use_config/stm32f1/keyboard.toml b/examples/use_config/stm32f1/keyboard.toml index a23b4ce92..2825fbed0 100644 --- a/examples/use_config/stm32f1/keyboard.toml +++ b/examples/use_config/stm32f1/keyboard.toml @@ -22,7 +22,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], diff --git a/examples/use_config/stm32f4/keyboard.toml b/examples/use_config/stm32f4/keyboard.toml index b7f3bcde2..c43f59a50 100644 --- a/examples/use_config/stm32f4/keyboard.toml +++ b/examples/use_config/stm32f4/keyboard.toml @@ -22,7 +22,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], diff --git a/examples/use_config/stm32h7/keyboard.toml b/examples/use_config/stm32h7/keyboard.toml index 53678a101..354bfa3b9 100644 --- a/examples/use_config/stm32h7/keyboard.toml +++ b/examples/use_config/stm32h7/keyboard.toml @@ -22,7 +22,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], diff --git a/rmk-config/Cargo.toml b/rmk-config/Cargo.toml index db0e58d20..4cc7845f9 100644 --- a/rmk-config/Cargo.toml +++ b/rmk-config/Cargo.toml @@ -18,3 +18,4 @@ once_cell = "1.19" pest = "2.8" pest_derive = "2.8" paste = "1.0.15" +bitfield-struct = "0.13" diff --git a/rmk-config/src/behavior.rs b/rmk-config/src/behavior.rs index a93ab7a07..2443fd3fc 100644 --- a/rmk-config/src/behavior.rs +++ b/rmk-config/src/behavior.rs @@ -19,8 +19,7 @@ impl crate::KeyboardTomlConfig { } None => default.tri_layer, }; - behavior.one_shot = behavior.one_shot.or(default.one_shot); - behavior.one_shot_modifiers = behavior.one_shot_modifiers.or(default.one_shot_modifiers); + behavior.sticky_key = behavior.sticky_key.or(default.sticky_key); behavior.combo = behavior.combo.or(default.combo); if let Some(combo) = &behavior.combo { if combo.combos.len() > self.rmk.combo_max_num { diff --git a/rmk-config/src/default_config/event_default.toml b/rmk-config/src/default_config/event_default.toml index e6d054cfb..66e629fa3 100644 --- a/rmk-config/src/default_config/event_default.toml +++ b/rmk-config/src/default_config/event_default.toml @@ -24,6 +24,11 @@ channel_size = 1 pubs = 2 subs = 1 +[event.sticky_key_release] +channel_size = 2 +pubs = 1 +subs = 1 + [event.wpm_update] channel_size = 1 pubs = 1 diff --git a/rmk-config/src/keymap.pest b/rmk-config/src/keymap.pest index 76f99f680..1a77d75dd 100644 --- a/rmk-config/src/keymap.pest +++ b/rmk-config/src/keymap.pest @@ -54,8 +54,15 @@ transparent_action = @{ ("_")+ | (^"Trns" ~ !ASCII_ALPHANUMERIC) } // One or mor // Rule 1: WM(key, modifier) - Key with Modifier wm_action = { ^"WM" ~ "(" ~ keycode_name ~ "," ~ modifier_combination ~ ")" } -// Rule 4.6: OSM(modifier) - One-Shot Modifier (requires quotes) -osm_action = { ^"OSM" ~ "(" ~ modifier_combination ~ ")" } +// Rule 4.6: OSM(modifier) - One-Shot Modifier. User-facing alias for the +// pure-mod sticky key SK(modifier); desugared to SK in keymap_parser. +sticky_profile_ref = { "@" ~ profile_name } +osm_action = { ^"OSM" ~ "(" ~ modifier_combination ~ ("," ~ sticky_profile_ref)? ~ ")" } + +// Rule 4.5: OSL(n) - One-Shot Layer. User-facing alias for the layer sticky +// key SK(MO(n)); desugared to SK in keymap_parser. Kept out of layer_action so +// nonsense like SK(OSL(n)) stays a grammar error. +osl_action = { ^"OSL" ~ "(" ~ layer_reference ~ ("," ~ sticky_profile_ref)? ~ ")" } // Rule 4.1: DF(n) - Switch Default Layer df_action = { ^"DF" ~ "(" ~ layer_reference ~ ")" } @@ -69,9 +76,6 @@ lm_action = { ^"LM" ~ "(" ~ layer_reference ~ "," ~ modifier_combination ~ ")" } // Rule 4.4: LT(n, key) - Layer Activate or Tap Key (Tap/Hold) lt_action = { ^"LT" ~ "(" ~ layer_reference ~ "," ~ nestable_action ~ ("," ~ profile_name)? ~ ")" } -// Rule 4.5: OSL(n) - One-Shot Layer -osl_action = { ^"OSL" ~ "(" ~ layer_reference ~ ")" } - // Rule 4.7: TT(n) - Layer Activate or Tap Toggle tt_action = { ^"TT" ~ "(" ~ layer_reference ~ ")" } @@ -84,7 +88,7 @@ to_action = { ^"TO" ~ "(" ~ layer_reference ~ ")" } // Grouping for Layer Actions layer_action = _{ df_action | mo_action | lm_action | lt_action | - osl_action | tt_action | tg_action | to_action + tt_action | tg_action | to_action } // Actions that resolve to a single `Action` and may therefore be nested inside @@ -113,12 +117,24 @@ morse_action = { (^"TD" | ^"MORSE") ~ "(" ~ number ~ ")" } // Rule 9: Macro(n) - Trigger Macro trigger_macro_action = { ^"MACRO" ~ "(" ~ number ~ ")" } +// bracketed modifier list for SK keep parameter: [LAlt] or [LAlt|LShift] or [] +modifier_keep_list = { "[" ~ modifier_combination ~ "]" | "[" ~ "]" } + +// SK(key, [mods], @profile) | SK(modifier, @profile) | SK(MO(n), @profile) +sk_action = { + ^"SK" ~ "(" ~ ( + layer_action ~ ("," ~ sticky_profile_ref)? + | (keycode_name ~ "," ~ modifier_keep_list ~ ("," ~ sticky_profile_ref)?) + | modifier_combination ~ ("," ~ sticky_profile_ref)? + ) ~ ")" +} + // --- Top Level Rules --- // 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 | osm_action | layer_action | mt_action | th_action | shifted_action | morse_action | trigger_macro_action | no_action | transparent_action | simple_keycode + wm_action | osm_action | osl_action | layer_action | mt_action | th_action | shifted_action | morse_action | trigger_macro_action | sk_action | no_action | transparent_action | simple_keycode } // The entire key map string: Start, zero or more key actions, End. diff --git a/rmk-config/src/layout.rs b/rmk-config/src/layout.rs index 99cd99f29..74b2440e5 100644 --- a/rmk-config/src/layout.rs +++ b/rmk-config/src/layout.rs @@ -279,7 +279,32 @@ impl KeyboardTomlConfig { next_keys.push_str(value); made_replacement = true; } - None => return Err(format!("Undefined alias: {}", alias_key)), + None => { + // Sticky-key profiles use the same `@name` + // spelling as keymap aliases, but occur as the + // final argument of SK/OSM/OSL. Preserve that + // reference for the action parser instead of + // trying to resolve it as an alias. + let profile_end = current_keys[start_index + 1..] + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) + .map(|offset| start_index + 1 + offset) + .unwrap_or(current_keys.len()); + let profile_name = ¤t_keys[start_index + 1..profile_end]; + let follows_comma = current_keys[..start_index].trim_end().ends_with(','); + let closes_action = current_keys[profile_end..].trim_start().starts_with(')'); + let valid_profile_name = profile_name + .as_bytes() + .first() + .is_some_and(|c| c.is_ascii_alphabetic() || *c == b'_'); + + if follows_comma && closes_action && valid_profile_name { + next_keys.push_str(¤t_keys[start_index..profile_end]); + last_index = profile_end; + continue; + } + + return Err(format!("Undefined alias: {}", alias_key)); + } } last_index = end_index; // Move past the processed alias } else { @@ -711,4 +736,136 @@ mod tests { assert!(result.is_err(), "Input should be rejected: {}", input); } } + + #[test] + fn test_sk_action_parsing() { + let aliases = HashMap::new(); + let layer_names = HashMap::new(); + + // Exercise all three SK shapes: tap-key SK(key, [mods]), pure-mod + // SK() (one-shot modifier), and layer SK(MO(n)) (one-shot layer). + let keymap = "SK(Tab, [LAlt]) SK(Tab, [LCtrl | LShift]) SK(LGui) SK(MO(1))"; + let result = KeyboardTomlConfig::keymap_parser(keymap, &aliases, &layer_names); + + assert!(result.is_ok()); + assert_eq!( + result.unwrap(), + vec!["SK(Tab, [LAlt])", "SK(Tab, [LCtrl | LShift])", "SK(LGui)", "SK(MO(1))"] + ); + } + + #[test] + fn test_sk_action_grammar() { + let test_cases = vec![ + // Tap-key shape: SK(key, [mods]) + "SK(Tab, [LAlt])", + "SK(Tab, [LCtrl])", + "SK(Tab, [LCtrl | LShift])", + "SK(Tab, [])", + "sk(Tab, [LAlt])", + // Pure-mod shape: SK() — one-shot modifier + "SK(LGui)", + "SK(LCtrl | LShift)", + "sk(lalt)", + // Layer shape: SK(MO(n)) — one-shot layer + "SK(MO(1))", + "SK(MO(3))", + "sk(mo(2))", + ]; + + for input in test_cases { + let result = ConfigParser::parse(Rule::key_map, input); + assert!(result.is_ok(), "Failed to parse: {}", input); + + let mut found_sk = false; + for pair in result.unwrap() { + if pair.as_rule() == Rule::key_map { + for inner_pair in pair.into_inner() { + if inner_pair.as_rule() == Rule::sk_action { + found_sk = true; + } + } + } + } + assert!(found_sk, "Input should be parsed as sk_action: {}", input); + } + } + + #[test] + fn test_osm_osl_alias_grammar() { + // OSM(modifier) parses as osm_action, OSL(n) as osl_action. + let osm_cases = vec!["OSM(LGui)", "OSM(LCtrl | LShift)", "osm(lalt)"]; + let osl_cases = vec!["OSL(1)", "OSL(3)", "osl(2)"]; + + let parses_as = |input: &str, rule: Rule| { + let result = ConfigParser::parse(Rule::key_map, input); + assert!(result.is_ok(), "Failed to parse: {}", input); + let mut found = false; + for pair in result.unwrap() { + if pair.as_rule() == Rule::key_map { + for inner_pair in pair.into_inner() { + if inner_pair.as_rule() == rule { + found = true; + } + } + } + } + assert!(found, "Input {} should be parsed as {:?}", input, rule); + }; + + for input in osm_cases { + parses_as(input, Rule::osm_action); + } + for input in osl_cases { + parses_as(input, Rule::osl_action); + } + } + + #[test] + fn test_osm_osl_alias_parsing() { + let aliases = HashMap::new(); + let layer_names = HashMap::new(); + + // OSM(modifier)/OSL(n) are forwarded as-is (like SK) and desugared to + // SK in the codegen parser. Here we only assert they survive keymap + // parsing intact; codegen byte-identicalness is covered in rmk-macro. + let keymap = "OSM(LGui) OSM(LCtrl | LShift) OSL(1)"; + let result = KeyboardTomlConfig::keymap_parser(keymap, &aliases, &layer_names); + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), vec!["OSM(LGui)", "OSM(LCtrl | LShift)", "OSL(1)"]); + } + + #[test] + fn test_sticky_profile_refs_are_not_resolved_as_keymap_aliases() { + let aliases = HashMap::new(); + let layer_names = HashMap::new(); + let keymap = "SK(LGui, @osm) SK(Tab, [LAlt], @alt_tab) SK(MO(1), @nav) OSM(LShift, @osm) OSL(1, @nav)"; + + let result = KeyboardTomlConfig::keymap_parser(keymap, &aliases, &layer_names); + + assert!(result.is_ok()); + assert_eq!( + result.unwrap(), + vec![ + "SK(LGui, @osm)", + "SK(Tab, [LAlt], @alt_tab)", + "SK(MO(1), @nav)", + "OSM(LShift, @osm)", + "OSL(1, @nav)", + ] + ); + } + + #[test] + fn test_keymap_aliases_still_resolve_next_to_sticky_profile_refs() { + let aliases = HashMap::from([("copy".to_string(), "WM(C, LCtrl)".to_string())]); + let layer_names = HashMap::new(); + let keymap = "@copy SK(LGui, @osm)"; + + let result = KeyboardTomlConfig::keymap_parser(keymap, &aliases, &layer_names); + + assert!(result.is_ok()); + assert_eq!(result.unwrap(), vec!["WM(C, LCtrl)", "SK(LGui, @osm)"]); + } } diff --git a/rmk-config/src/lib.rs b/rmk-config/src/lib.rs index 616ac0508..f9ea80613 100644 --- a/rmk-config/src/lib.rs +++ b/rmk-config/src/lib.rs @@ -232,6 +232,10 @@ pub(crate) struct RmkConstantsConfig { #[serde_inline_default(8)] #[serde(deserialize_with = "check_morse_max_num")] pub morse_max_num: usize, + /// Capacity of the named Sticky Key profile table (maximum 255). + #[serde_inline_default(16)] + #[serde(deserialize_with = "check_sticky_key_profile_max_num")] + pub sticky_key_profile_max_num: usize, /// Maximum number of patterns a morse key can handle #[serde_inline_default(8)] #[serde(deserialize_with = "check_max_patterns_per_key")] @@ -295,6 +299,17 @@ where Ok(value) } +fn check_sticky_key_profile_max_num<'de, D>(deserializer: D) -> Result +where + D: de::Deserializer<'de>, +{ + let value = Deserialize::deserialize(deserializer)?; + if value > 255 { + panic!("❌ Parse `keyboard.toml` error: sticky_key_profile_max_num must be between 0 and 255, got {value}"); + } + Ok(value) +} + fn check_max_patterns_per_key<'de, D>(deserializer: D) -> Result where D: de::Deserializer<'de>, @@ -327,6 +342,7 @@ impl Default for RmkConstantsConfig { combo_max_length: 4, fork_max_num: 8, morse_max_num: 8, + sticky_key_profile_max_num: 16, max_patterns_per_key: 8, macro_space_size: 256, debounce_time: 20, @@ -402,6 +418,7 @@ define_event_config!( keyboard, // Keyboard state events layer_change, + sticky_key_release, wpm_update, led_indicator, sleep_state, @@ -622,13 +639,12 @@ pub struct KeyInfo { #[serde(deny_unknown_fields)] pub(crate) struct BehaviorConfig { pub tri_layer: Option, - pub one_shot: Option, - pub one_shot_modifiers: Option, pub combo: Option, #[serde(alias = "macro")] pub macros: Option, pub fork: Option, pub morse: Option, + pub sticky_key: Option, pub auto_mouse_layer: Option>, } @@ -693,19 +709,31 @@ pub(crate) struct TriLayerConfig { pub adjust: u8, } -/// Configurations for oneshot modifiers/layers -#[derive(Clone, Debug, Deserialize)] +/// Configurations for sticky key +#[derive(Clone, Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] -pub(crate) struct OneShotConfig { +pub struct StickyKeyConfig { + /// Timeout before an unused sticky key auto-releases (e.g., "1000ms", "1s"). Default 1s. pub timeout: Option, + /// Pure-modifier sticky keys only: activate on the next key press instead of release. Default false. + pub activate_on_keypress: Option, + /// Max number of held keys the sticky modifier applies to; 0 = unlimited. Default 0. + pub max_repeat: Option, + /// `|`-separated release triggers, e.g. "other_key_press | layer_exit". + pub release_mode: Option, + /// Named profiles overriding this default configuration. + #[serde(default)] + pub profiles: HashMap, } -/// Configurations for oneshot modifiers -#[derive(Clone, Debug, Deserialize)] +/// Per-profile Sticky Key overrides. Omitted fields inherit the default table. +#[derive(Clone, Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] -pub struct OneShotModifiersConfig { +pub struct StickyKeyProfile { + pub timeout: Option, pub activate_on_keypress: Option, - pub quick_release: Option, + pub max_repeat: Option, + pub release_mode: Option, } /// Configurations for combos @@ -888,7 +916,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 e95921b94..d4bd1f91b 100644 --- a/rmk-config/src/resolved/behavior.rs +++ b/rmk-config/src/resolved/behavior.rs @@ -1,14 +1,73 @@ use std::collections::HashMap; +use bitfield_struct::bitfield; + +pub struct StickyKeyConfig { + pub timeout_ms: Option, + pub activate_on_keypress: Option, + pub max_repeat: Option, + pub release_mode: Option, + pub profiles: HashMap, +} + +#[bitfield(u8, order = Lsb, debug = false)] +#[derive(Debug, 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, + #[bits(3)] + __: u8, +} + +impl StickyKeyReleaseMode { + pub const OTHER_KEY_PRESS: Self = Self::new().with_other_key_press(true); + pub const OTHER_KEY_RELEASE: Self = Self::new().with_other_key_release(true); + pub const LAYER_ENTER: Self = Self::new().with_layer_enter(true); + pub const LAYER_EXIT: Self = Self::new().with_layer_exit(true); + pub const DOUBLE_TAP: Self = Self::new().with_double_tap(true); + + pub fn parse(value: &str) -> Result { + let mut result = Self::default(); + for part in value.split('|').map(str::trim).filter(|part| !part.is_empty()) { + result = match part { + "other_key_press" => result.with_other_key_press(true), + "other_key_release" => result.with_other_key_release(true), + "layer_enter" => result.with_layer_enter(true), + "layer_exit" => result.with_layer_exit(true), + "double_tap" => result.with_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 result.into_bits() == 0 { + return Err("Sticky Key release_mode must contain at least one trigger".to_string()); + } + Ok(result) + } +} + +#[derive(Clone, Debug, Default)] +pub struct StickyKeyProfile { + pub timeout_ms: Option, + pub activate_on_keypress: Option, + pub max_repeat: Option, + pub release_mode: Option, +} + /// Resolved behavioral configuration. pub struct Behavior { pub tri_layer: Option<[u8; 3]>, - pub one_shot_timeout_ms: Option, - pub one_shot_modifiers: Option, pub combos: Option, pub macros: Option, pub forks: Option, pub morse: Option, + pub sticky_key: Option, pub auto_mouse_layer: Vec, } @@ -31,11 +90,6 @@ pub const DEFAULT_AUTO_MOUSE_LAYER_THRESHOLD: u16 = 1; /// Fallback for `auto_mouse_layer_max_num` when no `keyboard.toml` is loaded. pub const DEFAULT_AUTO_MOUSE_LAYER_MAX_NUM: usize = 2; -pub struct OneShot { - pub activate_on_keypress: Option, - pub quick_release: Option, -} - pub struct Combos { pub combos: Vec, pub timeout_ms: Option, @@ -122,13 +176,6 @@ impl crate::KeyboardTomlConfig { let tri_layer = toml_behavior.tri_layer.map(|t| [t.upper, t.lower, t.adjust]); - let one_shot_timeout_ms = toml_behavior.one_shot.and_then(|o| o.timeout.map(|t| t.0)); - - let one_shot_modifiers = toml_behavior.one_shot_modifiers.map(|o| OneShot { - activate_on_keypress: o.activate_on_keypress, - quick_release: o.quick_release, - }); - let combos = toml_behavior.combo.map(|c| Combos { combos: c .combos @@ -223,6 +270,36 @@ impl crate::KeyboardTomlConfig { } }); + let sticky_key = toml_behavior.sticky_key.map(|s| { + let parse_profile = |p: crate::StickyKeyProfile| -> Result { + Ok(StickyKeyProfile { + timeout_ms: p.timeout.map(|t| t.0), + activate_on_keypress: p.activate_on_keypress, + max_repeat: p.max_repeat, + release_mode: p.release_mode + .as_deref() + .map(StickyKeyReleaseMode::parse) + .transpose()?, + }) + }; + if s.profiles.len() > self.rmk.sticky_key_profile_max_num { + return Err(format!( + "behavior.sticky_key.profiles defines {} profiles, but `[rmk] sticky_key_profile_max_num` is {}. Raise it in keyboard.toml", + s.profiles.len(), self.rmk.sticky_key_profile_max_num + )); + } + let profiles = s.profiles.into_iter() + .map(|(name, profile)| parse_profile(profile).map(|profile| (name, profile))) + .collect::, _>>()?; + Ok(StickyKeyConfig { + timeout_ms: s.timeout.as_ref().map(|t| t.0), + activate_on_keypress: s.activate_on_keypress, + max_repeat: s.max_repeat, + release_mode: s.release_mode.as_deref().map(StickyKeyReleaseMode::parse).transpose()?, + profiles, + }) + }).transpose()?; + let auto_mouse_layer = toml_behavior .auto_mouse_layer .unwrap_or_default() @@ -240,12 +317,11 @@ impl crate::KeyboardTomlConfig { Ok(Behavior { tri_layer, - one_shot_timeout_ms, - one_shot_modifiers, combos, macros, forks, morse, + sticky_key, auto_mouse_layer, }) } @@ -280,6 +356,7 @@ mod tests { use std::fs; use std::time::{SystemTime, UNIX_EPOCH}; + use super::StickyKeyReleaseMode; use crate::KeyboardTomlConfig; #[test] @@ -323,4 +400,58 @@ hold_timeout = "200ms" assert_eq!(morse.profiles["flow_off"].enable_flow_tap, Some(false)); assert_eq!(morse.profiles["inherit"].enable_flow_tap, None); } + + #[test] + fn sticky_key_profiles_and_release_modes_are_resolved() { + let toml = r#" +[layout] +rows = 1 +cols = 1 +layers = 1 +keymap = [ + [ + ["A"], + ], +] + +[behavior.sticky_key] +release_mode = "other_key_release | layer_exit | double_tap" + +[behavior.sticky_key.profiles.alt_tab] +timeout = "5s" +release_mode = "other_key_press | layer_enter" +"#; + + let unique = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + let path = std::env::temp_dir().join(format!( + "rmk-config-sticky-layer-change-{}-{}.toml", + std::process::id(), + unique + )); + + fs::write(&path, toml).unwrap(); + let config = KeyboardTomlConfig::new_from_toml_path_with_event_defaults(&path); + let _ = fs::remove_file(&path); + + let sticky_key = config.behavior().unwrap().sticky_key.unwrap(); + assert_eq!( + sticky_key.release_mode, + Some( + StickyKeyReleaseMode::new() + .with_other_key_release(true) + .with_layer_exit(true) + .with_double_tap(true) + ) + ); + let alt_tab = &sticky_key.profiles["alt_tab"]; + assert_eq!(alt_tab.timeout_ms, Some(5000)); + assert_eq!( + alt_tab.release_mode, + Some( + StickyKeyReleaseMode::new() + .with_other_key_press(true) + .with_layer_enter(true) + ) + ); + } } diff --git a/rmk-config/src/resolved/build_constants.rs b/rmk-config/src/resolved/build_constants.rs index 29832dab6..fe1a44be7 100644 --- a/rmk-config/src/resolved/build_constants.rs +++ b/rmk-config/src/resolved/build_constants.rs @@ -35,6 +35,7 @@ pub struct BuildConstants { pub combo_max_length: usize, pub fork_max_num: usize, pub morse_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, @@ -100,6 +101,7 @@ impl crate::KeyboardTomlConfig { modifier, keyboard, layer_change, + sticky_key_release, wpm_update, led_indicator, sleep_state, @@ -183,6 +185,7 @@ impl crate::KeyboardTomlConfig { combo_max_length: rmk.combo_max_length, fork_max_num: rmk.fork_max_num, morse_max_num: rmk.morse_max_num, + sticky_key_profile_max_num: rmk.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, diff --git a/rmk-macro/src/codegen/action_parser.rs b/rmk-macro/src/codegen/action_parser.rs index 41c932b68..65ed79eef 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 { @@ -157,6 +157,31 @@ pub(crate) fn expand_profile_name( } } +pub(crate) fn sorted_sticky_profile_names( + profiles: &Option>, +) -> Vec { + let mut names: Vec = profiles + .as_ref() + .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); + let Some(index) = names.iter().position(|candidate| candidate == name) else { + panic!("\n❌ `{name}` profile name is not found in behavior.sticky_key.profiles"); + }; + quote! { #index as u8 } +} + /// Split `s` on commas that are *not* nested inside parentheses. /// /// Each piece is trimmed and empty pieces are dropped. This lets an argument @@ -228,14 +253,6 @@ 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 { @@ -254,9 +271,6 @@ 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) }; @@ -371,6 +385,7 @@ 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) }; @@ -428,6 +443,104 @@ pub(crate) fn parse_key( } else if lower.starts_with("td(") || lower.starts_with("morse(") { let index = strip_call(&key).trim().parse::().unwrap(); quote! { ::rmk::types::action::KeyAction::Morse(#index) } + } else if lower.starts_with("osl(") { + // OSL(n) — user-facing alias for the layer sticky key SK(MO(n)). + // Emits the same `sk_layer!` as SK(MO(n)), so the action is byte-identical. + let args = split_top_level(strip_call(&key)); + let layer = args[0].parse::().unwrap(); + let profile = sticky_profile_index( + args.get(1).map(|p| p.trim_start_matches('@')), + sticky_profiles, + ); + quote! { ::rmk::sk_layer!(#layer, #profile) } + } else if lower.starts_with("osm(") { + // OSM(modifier) — user-facing alias for the pure-mod sticky key SK(modifier). + // Emits the same `sk_mod!` as SK(modifier), so the action is byte-identical. + let args = split_top_level(strip_call(&key)); + let modifiers = parse_modifiers(&args[0]); + if modifiers.is_empty() { + panic!( + "\n\u{274c} keyboard.toml: OSM(modifier) is not valid! \ + OSM is an alias for SK(modifier). Usage: OSM(LGui) | OSM(LCtrl | LShift)" + ); + } + let profile = sticky_profile_index( + args.get(1).map(|p| p.trim_start_matches('@')), + sticky_profiles, + ); + quote! { ::rmk::sk_mod!(#modifiers, #profile) } + } else if lower.starts_with("sk(") { + let inner = strip_call(&key).trim(); + 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_inner = action_args.join(", "); + let inner = action_inner.trim(); + let inner_lower = inner.to_lowercase(); + if inner_lower.starts_with("mo(") { + // Layer shape: SK(MO(n)) — OSL replacement. + let layer = parse_layer(inner); + quote! { ::rmk::sk_layer!(#layer, #profile) } + } else if inner.contains('[') { + // Tap-key shape: SK(key, [mods]). + let bracket_start = inner.find('[').unwrap(); + let bracket_end = inner.find(']').unwrap_or_else(|| { + panic!( + "\n\u{274c} keyboard.toml: SK has unclosed '['. \ + Usage: SK(LGui) | SK(Tab, [LAlt]) | SK(MO(n))" + ) + }); + + let key_str = inner[..bracket_start].trim().trim_end_matches(',').trim(); + let ident = get_key_with_alias(key_str.to_string()); + + let keep_mods_str = &inner[bracket_start + 1..bracket_end]; + let keep_modifiers = if keep_mods_str.trim().is_empty() { + ModifierCombinationMacro::new() + } else { + parse_modifiers(keep_mods_str) + }; + + // Legacy-tail guard: reject the old 5-positional form. + let after_bracket = inner[bracket_end + 1..].trim_start_matches(',').trim(); + if !after_bracket.is_empty() { + panic!( + "\n\u{274c} keyboard.toml: the 5-positional SK(...) form is removed; use SK(key, [mods]). max_repeat/timeout/release_on_layer_change now live in [behavior.sticky_key]." + ); + } + + quote! { ::rmk::sk!(#ident, #keep_modifiers, #profile) } + } else { + // Pure-mod shape: SK(LGui) — OSM replacement. + // + // A nested action other than MO(n) (e.g. SK(TG(1)), SK(TO(2))) parses as a + // valid `sk_action` in the pest grammar (it accepts the broad `layer_action`) + // but is NOT a supported SK layer shape. Catch it here with a targeted message + // instead of falling through to the generic "not a modifier" panic below. + if inner.contains('(') { + panic!( + "\n\u{274c} keyboard.toml: SK only supports MO(n) as its layer shape (got `{inner}`). \ + Usage: SK(LGui) | SK(Tab, [LAlt]) | SK(MO(n))" + ); + } + + let modifiers = parse_modifiers(inner); + if modifiers.is_empty() { + panic!( + "\n\u{274c} keyboard.toml: SK(modifier) is not valid! \ + Usage: SK(LGui) | SK(Tab, [LAlt]) | SK(MO(n))" + ); + } + quote! { ::rmk::sk_mod!(#modifiers, #profile) } + } } else { let action = parse_action(&key); quote! { ::rmk::types::action::KeyAction::Single(#action) } @@ -462,10 +575,10 @@ pub(crate) fn get_key_with_alias(key: String) -> Ident { #[cfg(test)] mod tests { use super::*; - use rmk_config::resolved::behavior::MorseProfile; + use rmk_config::resolved::behavior::{MorseProfile, StickyKeyProfile}; 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 { @@ -513,7 +626,9 @@ mod tests { .contains("KeyAction::Single(::rmk::types::action::Action::LayerOn(1u8))") ); assert!(squash(&expand("WM(C,LCtrl)")).contains("Action::KeyWithModifier")); - assert!(squash(&expand("OSM(LShift)")).contains("Action::OneShotModifier")); + // OSM/OSL are now aliases for the unified sticky key, so they desugar to + // `sk_mod!`/`sk_layer!` rather than the removed `OneShotModifier` variant. + assert!(squash(&expand("OSM(LShift)")).contains("::rmk::sk_mod!")); } #[test] @@ -551,4 +666,50 @@ mod tests { ); assert!(squash(&expand("LT(2, Enter)")).contains("Action::LayerOn(2u8)")); } + + /// OSM(modifier)/OSL(n) are aliases that must expand to the exact same + /// action tokens as their SK equivalents (sk_mod! / sk_layer!). + #[test] + fn osm_osl_aliases_match_sk_tokens() { + // (alias form, canonical SK form, macro the action must emit) + let cases = [ + ("OSM(LGui)", "SK(LGui)", "sk_mod"), + ("OSM(LCtrl | LShift)", "SK(LCtrl | LShift)", "sk_mod"), + ("osm(lalt)", "sk(lalt)", "sk_mod"), + ("OSL(1)", "SK(MO(1))", "sk_layer"), + ("OSL(3)", "SK(MO(3))", "sk_layer"), + ]; + + for (alias, sk, expected_macro) in cases { + let alias_tokens = parse_key(alias.to_string(), &None, &None).to_string(); + let sk_tokens = parse_key(sk.to_string(), &None, &None).to_string(); + assert_eq!( + alias_tokens, sk_tokens, + "{alias} must expand identically to {sk}" + ); + // ...and the shared expansion is the real sticky-key action, not + // just two strings that happen to match. + assert!( + alias_tokens.contains(expected_macro), + "{alias} should emit {expected_macro}!, got: {alias_tokens}" + ); + } + } + + #[test] + #[should_panic(expected = "profile name is not found in behavior.sticky_key.profiles")] + fn unknown_sticky_key_profile_is_rejected() { + let mut profiles = HashMap::new(); + profiles.insert( + "known".to_string(), + StickyKeyProfile { + timeout_ms: None, + activate_on_keypress: None, + max_repeat: None, + release_mode: None, + }, + ); + + parse_key("SK(LShift, @missing)".to_string(), &None, &Some(profiles)); + } } diff --git a/rmk-macro/src/codegen/behavior.rs b/rmk-macro/src/codegen/behavior.rs index d2ccbd9ba..0afa83150 100644 --- a/rmk-macro/src/codegen/behavior.rs +++ b/rmk-macro/src/codegen/behavior.rs @@ -6,7 +6,7 @@ use quote::quote; use rmk_config::resolved::Behavior; use rmk_config::resolved::behavior::{ AutoMouseLayer, Combos, Forks, MacroOperation, Macros, Morse, MorseActionPair, MorseKey, - MorseProfile, OneShot, + MorseProfile, StickyKeyProfile, }; use super::action_parser::{expand_profile, expand_profile_name, get_key_with_alias, parse_key}; @@ -23,51 +23,81 @@ fn expand_tri_layer(tri_layer: &Option<[u8; 3]>) -> proc_macro2::TokenStream { } } -fn expand_one_shot(one_shot_timeout_ms: &Option) -> proc_macro2::TokenStream { - let default = quote! {::rmk::config::OneShotConfig::default()}; - match one_shot_timeout_ms { - Some(millis) => { - let timeout = quote! {::embassy_time::Duration::from_millis(#millis)}; - +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 max_repeat = profile.max_repeat.or(fallback.max_repeat).unwrap_or(0); + let release_mode = profile.release_mode.or(fallback.release_mode); + let release_mode = match release_mode { + Some(mode) => { + let bits = mode.into_bits(); quote! { - ::rmk::config::OneShotConfig { - timeout: #timeout, - } + ::core::option::Option::Some( + ::rmk::config::StickyKeyReleaseMode::from_bits(#bits) + ) } } - None => default, + None => quote! { ::core::option::Option::None }, + }; + quote! { + ::rmk::config::StickyKeyProfile { + timeout: ::rmk::embassy_time::Duration::from_millis(#timeout), + activate_on_keypress: #activate_on_keypress, + max_repeat: #max_repeat, + release_mode: #release_mode, + } } } -fn expand_one_shot_modifiers(one_shot_modifiers: &Option) -> proc_macro2::TokenStream { - let default = quote! { ::core::default::Default::default() }; - - match one_shot_modifiers { - Some(one_shot_modifier) => { - let activate_on_keypress = match one_shot_modifier.activate_on_keypress { - Some(value) => quote! { activate_on_keypress: #value, }, - None => quote! {}, - }; - let quick_release = match one_shot_modifier.quick_release { - Some(value) => quote! { quick_release: #value, }, - None => quote! {}, - }; - - quote! { - ::rmk::config::OneShotModifiersConfig { - #activate_on_keypress - #quick_release - ..Default::default() - } - } +fn expand_sticky_key(behavior: &Behavior) -> proc_macro2::TokenStream { + let default = behavior + .sticky_key + .as_ref() + .map(|sk| StickyKeyProfile { + timeout_ms: sk.timeout_ms, + activate_on_keypress: sk.activate_on_keypress, + max_repeat: sk.max_repeat, + release_mode: sk.release_mode, + }) + .unwrap_or_default(); + let default_timeout = default.timeout_ms.unwrap_or(1000); + let default_activate_on_keypress = default.activate_on_keypress.unwrap_or(false); + let default_max_repeat = default.max_repeat.unwrap_or(0); + let default_profile = expand_sticky_key_profile(&default, &StickyKeyProfile::default()); + let profile_tokens = behavior + .sticky_key + .as_ref() + .map(|sk| { + let mut names: Vec<_> = sk.profiles.keys().collect(); + names.sort(); + names + .into_iter() + .map(|name| expand_sticky_key_profile(&sk.profiles[name], &default)) + .collect::>() + }) + .unwrap_or_default(); + quote! { + ::rmk::config::StickyKeyConfig { + default_profile: #default_profile, + profiles: ::rmk::heapless::Vec::from_iter([#(#profile_tokens),*]), + timeout: ::rmk::embassy_time::Duration::from_millis(#default_timeout), + activate_on_keypress: #default_activate_on_keypress, + max_repeat: #default_max_repeat, + ..Default::default() } - None => default, } } 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() { @@ -80,18 +110,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),*]), } @@ -100,7 +131,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, }; @@ -116,7 +150,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); quote! { ::rmk::config::MorsesConfig { @@ -135,6 +169,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 { @@ -162,8 +197,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 }, @@ -241,6 +276,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! {}; @@ -258,7 +294,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 { @@ -277,7 +313,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()),*]) } @@ -288,7 +324,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()),*]) } @@ -304,10 +340,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( @@ -452,14 +488,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(); @@ -530,30 +567,62 @@ 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(|config| config.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! { #[allow(clippy::needless_update)] let mut behavior_config = ::rmk::config::BehaviorConfig { tri_layer: #tri_layer, - one_shot: #one_shot, - one_shot_modifiers: #one_shot_modifiers, combo: #combos, fork: #forks, morse: #morse, 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::*; + use rmk_config::resolved::behavior::{StickyKeyConfig, StickyKeyReleaseMode}; + + #[test] + fn sticky_key_codegen_emits_profiles() { + let behavior = Behavior { + tri_layer: None, + combos: None, + macros: None, + forks: None, + morse: None, + sticky_key: Some(StickyKeyConfig { + timeout_ms: None, + activate_on_keypress: None, + max_repeat: None, + release_mode: Some(StickyKeyReleaseMode::LAYER_ENTER), + profiles: HashMap::new(), + }), + auto_mouse_layer: Vec::new(), + }; + + let tokens = expand_sticky_key(&behavior).to_string().replace(' ', ""); + assert!(tokens.contains("release_mode:::core::option::Option::Some")); + assert!(tokens.contains("profiles:::rmk::heapless::Vec::from_iter")); + } +} diff --git a/rmk-macro/src/codegen/layout.rs b/rmk-macro/src/codegen/layout.rs index e6aba2e0b..54eb873f2 100644 --- a/rmk-macro/src/codegen/layout.rs +++ b/rmk-macro/src/codegen/layout.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, Layout}; use super::action_parser::parse_key; @@ -16,6 +16,11 @@ pub(crate) fn expand_default_keymap(layout: &Layout, 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(|config| config.profiles.clone()) + .filter(|profiles| !profiles.is_empty()); let num_encoder: usize = layout.encoder_counts.iter().sum(); @@ -23,7 +28,7 @@ pub(crate) fn expand_default_keymap(layout: &Layout, behavior: &Behavior) -> Tok let mut encoder_map = vec![]; for layer in &layout.keymap { - layers.push(expand_layer(layer.clone(), &profiles)); + layers.push(expand_layer(layer.clone(), &profiles, &sticky_profiles)); } for encoder_layer in &layout.encoder_map { @@ -31,6 +36,7 @@ pub(crate) fn expand_default_keymap(layout: &Layout, behavior: &Behavior) -> Tok encoder_layer.clone(), num_encoder, &profiles, + &sticky_profiles, )); } encoder_map.resize( @@ -53,19 +59,24 @@ pub(crate) fn expand_default_keymap(layout: &Layout, behavior: &Behavior) -> Tok 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 @@ 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-types/build.rs b/rmk-types/build.rs index d2fb0a39f..dc5e73ada 100644 --- a/rmk-types/build.rs +++ b/rmk-types/build.rs @@ -76,6 +76,10 @@ fn generate_constants(bc: &BuildConstants) -> String { bc.split_central_sleep_timeout_seconds )); lines.push(format!("pub const MORSE_MAX_NUM: usize = {};", bc.morse_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/mod.rs b/rmk-types/src/action/mod.rs index 450c81eb2..e87ac9eb3 100644 --- a/rmk-types/src/action/mod.rs +++ b/rmk-types/src/action/mod.rs @@ -31,6 +31,35 @@ use crate::modifier::ModifierCombination; #[cfg(feature = "steno")] use crate::steno::StenoKey; +/// Effect produced by a sticky-key action. +/// +/// Each variant carries only the data that is meaningful for that effect, so +/// invalid combinations cannot be constructed. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, MaxSize)] +#[cfg_attr(feature = "defmt", derive(defmt::Format))] +#[cfg_attr(feature = "rmk_protocol", derive(Schema))] +pub enum StickyKeyEffect { + /// Apply modifiers to the next key (the legacy OSM behavior). + Modifier(ModifierCombination), + /// Activate a layer for the next key (the legacy OSL behavior). + Layer(u8), + /// Tap a HID key while retaining modifiers between repetitions. + TapKey { + key: HidKeyCode, + modifiers: ModifierCombination, + }, +} + +/// Parameters for a sticky-key action. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, MaxSize)] +#[cfg_attr(feature = "defmt", derive(defmt::Format))] +#[cfg_attr(feature = "rmk_protocol", derive(Schema))] +pub struct StickyKeyAction { + pub effect: StickyKeyEffect, + /// Profile-table index. `u8::MAX` selects the default Sticky Key profile. + pub profile: u8, +} + /// A single basic action that a keyboard can execute. #[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, MaxSize)] #[cfg_attr(feature = "defmt", derive(defmt::Format))] @@ -85,4 +114,30 @@ pub enum Action { /// sent to the host as a vendor HID report. #[cfg(feature = "steno")] Steno(StenoKey), + /// Configurable sticky modifier, layer, or tap-key behavior. + /// + /// This variant is appended after the pre-existing action variants to + /// preserve their serialized discriminants. + StickyKey(StickyKeyAction), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sticky_key_action_profile_round_trips() { + let action = Action::StickyKey(StickyKeyAction { + effect: StickyKeyEffect::TapKey { + key: HidKeyCode::Tab, + modifiers: ModifierCombination::LALT, + }, + profile: 7, + }); + let mut bytes = [0; 32]; + let encoded = postcard::to_slice(&action, &mut bytes).unwrap(); + let decoded: Action = postcard::from_bytes(encoded).unwrap(); + + assert_eq!(decoded, action); + } } diff --git a/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_base.snap b/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_base.snap index 1e0cfefe0..4f382681a 100644 --- a/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_base.snap +++ b/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_base.snap @@ -8,22 +8,22 @@ behavior/get REQ 79 40 45 f9 6e 78 ce 15 RESP ac 59 82 ee ea 41 6c 64 behavior/set REQ c0 6d 36 93 9c 5a 7a b0 RESP 92 d6 0a 5d 06 93 e2 17 -combo/get REQ 81 6e 51 70 26 48 4d 13 RESP e9 17 46 d1 04 f1 20 9c -combo/set REQ 99 8e 40 7a 0d a7 73 5e RESP 2c 9b 2b 68 fe 35 21 25 +combo/get REQ 81 6e 51 70 26 48 4d 13 RESP 25 19 b2 19 b3 3a 17 dc +combo/set REQ d5 22 13 2e ad 3a 13 36 RESP 2c 9b 2b 68 fe 35 21 25 conn/set_type REQ 59 5c 7b 51 0e ff d7 12 RESP 8f e7 08 b9 4d 3f 68 d5 conn/type REQ 4d f1 b2 e7 8d ec 46 a0 RESP 02 58 66 87 39 d7 b5 b5 -encoder/get REQ 4c 0d e1 c9 58 89 4b 52 RESP be c4 4b a8 63 73 f2 ca -encoder/set REQ e0 41 c3 5f 30 00 59 c7 RESP ea a8 3d 9e dd 6e 67 c7 -fork/get REQ 21 8f a2 dc 1f e8 3a 1b RESP 31 f6 72 cb dd 34 a7 b7 -fork/set REQ 01 01 40 5b 0f a9 72 e5 RESP 0c 8a ca c0 83 a9 dc be +encoder/get REQ 4c 0d e1 c9 58 89 4b 52 RESP f2 07 95 ad c9 9c df 23 +encoder/set REQ ac 66 2e c3 fa 2c 20 0b RESP ea a8 3d 9e dd 6e 67 c7 +fork/get REQ 21 8f a2 dc 1f e8 3a 1b RESP bd 3d a8 e8 e6 61 3e 69 +fork/set REQ 0d 45 ac de ec d5 90 6f RESP 0c 8a ca c0 83 a9 dc be keymap/default_layer REQ 3b 9b e3 4e c2 47 56 de RESP 79 3f e3 4e c2 11 56 de -keymap/get REQ 9c ce 0f 70 d3 94 0f fb RESP 77 ee 2f b2 1e 62 b2 0e -keymap/set REQ 74 32 4d cd b6 e0 9b b7 RESP a7 01 c4 70 bb ea d3 b9 +keymap/get REQ 9c ce 0f 70 d3 94 0f fb RESP e7 aa 48 37 78 1b 3a c5 +keymap/set REQ 80 50 ed 41 f1 37 ba bb RESP a7 01 c4 70 bb ea d3 b9 keymap/set_default_layer REQ 6c 6c 14 62 2a 07 9d b3 RESP 2b 67 98 d3 da 4b f3 98 macro/get REQ 0a 43 62 d5 55 40 09 9d RESP 85 2c 14 7a 94 7c e9 f1 macro/set REQ f7 e6 c3 bd 4c 03 a5 e7 RESP 4e 8c 8b 52 00 fa 68 03 -morse/get REQ f5 0c 0d f1 f0 6b 74 e2 RESP 22 d0 ef 2d e6 2d a1 38 -morse/set REQ 5a b0 81 95 8a a3 59 54 RESP 40 c6 f5 18 aa 72 42 a5 +morse/get REQ f5 0c 0d f1 f0 6b 74 e2 RESP 96 bf cc f5 0b 5a c6 b8 +morse/set REQ ae dd 06 41 46 1c 9b bc RESP 40 c6 f5 18 aa 72 42 a5 status/layer/get REQ d7 6a 8a 1b 7b bb be 32 RESP 75 45 8a 1b 7b a5 be 32 status/matrix/get REQ 4b ae a1 68 0d d9 90 44 RESP 63 13 83 85 e4 e0 0b 36 sys/bootloader REQ 29 a1 89 88 85 d6 a1 26 RESP 29 a1 89 88 85 d6 a1 26 diff --git a/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_bulk.snap b/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_bulk.snap index 3ee2d34e8..928a45091 100644 --- a/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_bulk.snap +++ b/rmk-types/src/protocol/rmk/snapshots/endpoint_keys_bulk.snap @@ -6,9 +6,9 @@ # UPDATE_SNAPSHOTS=1 cargo test -p rmk-types --features rmk_protocol # Format: REQ <8-byte hex> RESP <8-byte hex> -combo/bulk_get REQ 52 b1 93 5f 86 d5 96 53 RESP 31 4f 3b b9 01 c4 dd aa -combo/bulk_set REQ 17 14 f5 fb e6 79 7b d2 RESP 83 3b 2e b1 a0 96 2f 3d -keymap/bulk_get REQ 11 21 e6 78 15 e5 8a ca RESP 8f 08 9c 69 e2 f8 f8 f0 -keymap/bulk_set REQ 09 1a 56 18 3d 08 3e a4 RESP 42 98 cc 60 91 e5 c5 f3 -morse/bulk_get REQ 46 e8 ff eb aa ed 5f db RESP ca c3 49 cd 16 a3 0b 00 -morse/bulk_set REQ 8c dc 69 57 8e 40 5d 4c RESP f7 57 bd 43 2b 0b ec b8 +combo/bulk_get REQ 52 b1 93 5f 86 d5 96 53 RESP ad 04 fb 5c 95 40 59 21 +combo/bulk_set REQ eb 4c 97 27 0c 4c 82 d4 RESP 83 3b 2e b1 a0 96 2f 3d +keymap/bulk_get REQ 11 21 e6 78 15 e5 8a ca RESP 5f 38 47 a1 f9 66 bf 9c +keymap/bulk_set REQ a9 d3 55 38 74 9b f0 82 RESP 42 98 cc 60 91 e5 c5 f3 +morse/bulk_get REQ 46 e8 ff eb aa ed 5f db RESP 9e 66 e1 59 57 3d 5d f8 +morse/bulk_set REQ 60 ff 5d 01 b2 a5 16 5c RESP f7 57 bd 43 2b 0b ec b8 diff --git a/rmk-types/src/protocol/rmk/system.rs b/rmk-types/src/protocol/rmk/system.rs index 565095172..f434ead2a 100644 --- a/rmk-types/src/protocol/rmk/system.rs +++ b/rmk-types/src/protocol/rmk/system.rs @@ -19,7 +19,7 @@ pub struct ProtocolVersion { impl ProtocolVersion { /// Current protocol version for this firmware release. - pub const CURRENT: Self = Self { major: 1, minor: 0 }; + pub const CURRENT: Self = Self { major: 1, minor: 1 }; } /// Device capabilities discovered during the connection handshake. @@ -108,6 +108,11 @@ mod tests { round_trip(&ProtocolVersion { major: 255, minor: 255 }); } + #[test] + fn current_protocol_version_includes_sticky_key_action() { + assert_eq!(ProtocolVersion::CURRENT, ProtocolVersion { major: 1, minor: 1 }); + } + #[test] fn round_trip_device_capabilities() { // Populated and all-zero edge cases. diff --git a/rmk/Cargo.toml b/rmk/Cargo.toml index 2584e2bcb..c781ffb37 100644 --- a/rmk/Cargo.toml +++ b/rmk/Cargo.toml @@ -47,6 +47,7 @@ postcard = { version = "1", features = ["experimental-derive"] } # Used in macro paste = "1" +bitfield-struct = "0.13" # Display dependencies ssd1306 = { version = "0.10", optional = true, features = ["async"] } diff --git a/rmk/src/config/behavior.rs b/rmk/src/config/behavior.rs index 88817da79..3223a9e69 100644 --- a/rmk/src/config/behavior.rs +++ b/rmk/src/config/behavior.rs @@ -1,3 +1,4 @@ +use bitfield_struct::bitfield; use embassy_time::Duration; use heapless::Vec; use rmk_types::fork::Fork; @@ -7,7 +8,7 @@ use rmk_types::morse::{Morse, MorseMode, MorseProfile}; use crate::keyboard::combo::Combo; use crate::{ AUTO_MOUSE_LAYER_MAX_NUM, COMBO_MAX_NUM, FORK_MAX_NUM, MACRO_SPACE_SIZE, MORSE_MAX_NUM, MOUSE_KEY_INTERVAL, - MOUSE_WHEEL_INTERVAL, + MOUSE_WHEEL_INTERVAL, STICKY_KEY_PROFILE_MAX_NUM, }; /// Config for configurable action behavior @@ -17,13 +18,12 @@ pub struct BehaviorConfig { pub default_layer: u8, pub tri_layer: Option<[u8; 3]>, pub tap: TapConfig, - pub one_shot: OneShotConfig, - pub one_shot_modifiers: OneShotModifiersConfig, pub combo: CombosConfig, pub fork: ForksConfig, pub morse: MorsesConfig, pub keyboard_macros: KeyboardMacrosConfig, pub mouse_key: MouseKeyConfig, + pub sticky_key: StickyKeyConfig, pub auto_mouse_layer: Vec, } @@ -142,27 +142,87 @@ impl Default for MorsesConfig { } } -/// Config for one shot behavior +#[bitfield(u8, order = Lsb, debug = false)] +#[derive(Debug, 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, + #[bits(3)] + __: u8, +} + +impl StickyKeyReleaseMode { + pub const OTHER_KEY_PRESS: Self = Self::new().with_other_key_press(true); + pub const OTHER_KEY_RELEASE: Self = Self::new().with_other_key_release(true); + pub const LAYER_ENTER: Self = Self::new().with_layer_enter(true); + pub const LAYER_EXIT: Self = Self::new().with_layer_exit(true); + pub const DOUBLE_TAP: Self = Self::new().with_double_tap(true); + + pub const fn contains(self, other: Self) -> bool { + self.into_bits() & other.into_bits() != 0 + } +} + +/// A resolved Sticky Key profile. `release_mode = None` preserves the legacy +/// shape-native release behavior for keymaps that do not opt into explicit modes. #[derive(Clone, Copy, Debug)] -pub struct OneShotConfig { - /// Timeout after which modifiers/layers are canceled/released +pub struct StickyKeyProfile { + /// Applies to every SK shape. Default 1s. pub timeout: Duration, + /// Honored only by pure-mod SK. Default false. + pub activate_on_keypress: bool, + /// 0 = infinite; governs tap-key cycling. Default 0. + pub max_repeat: u16, + /// Explicit release triggers. `None` retains legacy shape-native behavior. + pub release_mode: Option, } -impl Default for OneShotConfig { +impl Default for StickyKeyProfile { fn default() -> Self { Self { timeout: Duration::from_secs(1), + activate_on_keypress: false, + max_repeat: 0, + release_mode: None, } } } -/// Config for one-shot behavior -#[derive(Clone, Copy, Debug, Default)] -pub struct OneShotModifiersConfig { - /// Should modifiers be active from keypress (sticky modifiers) + +/// Unified Sticky Key configuration with a default profile and compact named +/// profile table. Actions retain only a `u8` profile index. +#[derive(Clone, Debug)] +pub struct StickyKeyConfig { + pub default_profile: StickyKeyProfile, + pub profiles: Vec, + /// Legacy Rust-API compatibility knobs. TOML uses `release_mode` instead. + pub timeout: Duration, pub activate_on_keypress: bool, - /// If true, OSM releases on next key press (ZMK skq); if false, on next key release (ZMK skn) + pub max_repeat: u16, pub quick_release: bool, + pub release_on_layer_change: bool, + pub tap_key_release_on_layer_change: Option, + pub one_shot_mod_release_on_layer_change: Option, + pub one_shot_layer_release_on_layer_change: Option, +} + +impl Default for StickyKeyConfig { + fn default() -> Self { + Self { + default_profile: StickyKeyProfile::default(), + profiles: Vec::new(), + timeout: Duration::from_secs(1), + activate_on_keypress: false, + max_repeat: 0, + quick_release: false, + release_on_layer_change: false, + tap_key_release_on_layer_change: None, + one_shot_mod_release_on_layer_change: None, + one_shot_layer_release_on_layer_change: None, + } + } } /// Config for combo behavior diff --git a/rmk/src/config/mod.rs b/rmk/src/config/mod.rs index 75cede564..de8feeb24 100644 --- a/rmk/src/config/mod.rs +++ b/rmk/src/config/mod.rs @@ -8,7 +8,7 @@ mod vial; pub use behavior::{ AutoMouseLayerConfig, BehaviorConfig, CombosConfig, ForksConfig, KeyboardMacrosConfig, MorsesConfig, - MouseKeyConfig, OneShotConfig, OneShotModifiersConfig, TapConfig, + MouseKeyConfig, StickyKeyConfig, StickyKeyProfile, StickyKeyReleaseMode, TapConfig, }; #[cfg(feature = "_ble")] pub use ble_battery::BleBatteryConfig; diff --git a/rmk/src/event/mod.rs b/rmk/src/event/mod.rs index e13297825..4b6e54b09 100644 --- a/rmk/src/event/mod.rs +++ b/rmk/src/event/mod.rs @@ -67,6 +67,7 @@ pub use input::{ pub use split::{CentralConnectedEvent, PeripheralConnectedEvent}; #[cfg(all(feature = "split", feature = "_ble"))] pub use split::{ClearPeerEvent, PeripheralBatteryEvent}; +pub(crate) use state::StickyKeyReleaseEvent; pub use state::{LayerChangeEvent, LedIndicatorEvent, SleepStateEvent, WpmUpdateEvent}; /// Trait for event publishers diff --git a/rmk/src/event/state.rs b/rmk/src/event/state.rs index eb38b7213..3ac637f81 100644 --- a/rmk/src/event/state.rs +++ b/rmk/src/event/state.rs @@ -3,6 +3,8 @@ use rmk_macro::event; use rmk_types::led_indicator::LedIndicator; +use crate::config::StickyKeyReleaseMode; + /// Active layer changed event #[event(channel_size = crate::LAYER_CHANGE_EVENT_CHANNEL_SIZE, pubs = crate::LAYER_CHANGE_EVENT_PUB_SIZE, subs = crate::LAYER_CHANGE_EVENT_SUB_SIZE)] #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -17,6 +19,13 @@ impl LayerChangeEvent { impl_payload_wrapper!(LayerChangeEvent, u8); +/// A layer transition that may release an active Sticky Key. +#[event(channel_size = crate::STICKY_KEY_RELEASE_EVENT_CHANNEL_SIZE, pubs = crate::STICKY_KEY_RELEASE_EVENT_PUB_SIZE, subs = crate::STICKY_KEY_RELEASE_EVENT_SUB_SIZE)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct StickyKeyReleaseEvent(pub StickyKeyReleaseMode); + +impl_payload_wrapper!(StickyKeyReleaseEvent, StickyKeyReleaseMode); + /// WPM updated event #[event(channel_size = crate::WPM_UPDATE_EVENT_CHANNEL_SIZE, pubs = crate::WPM_UPDATE_EVENT_PUB_SIZE, subs = crate::WPM_UPDATE_EVENT_SUB_SIZE)] #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/rmk/src/host/context.rs b/rmk/src/host/context.rs index 15d0bb3b6..d27327c56 100644 --- a/rmk/src/host/context.rs +++ b/rmk/src/host/context.rs @@ -225,8 +225,8 @@ impl<'a> KeyboardContext<'a> { self.keymap.combo_timeout() } - pub fn one_shot_timeout(&self) -> Duration { - self.keymap.one_shot_timeout() + pub fn sticky_key_timeout(&self) -> Duration { + self.keymap.sticky_key_timeout() } pub fn tap_interval(&self) -> u16 { @@ -253,8 +253,9 @@ impl<'a> KeyboardContext<'a> { FLASH_CHANNEL.send(FlashOperationMessage::ComboTimeout(ms)).await; } - pub async fn set_one_shot_timeout(&self, ms: u16) { - self.keymap.set_one_shot_timeout(Duration::from_millis(ms as u64)); + pub async fn set_sticky_key_timeout(&self, ms: u16) { + self.keymap.set_sticky_key_timeout(Duration::from_millis(ms as u64)); + // `OneShotTimeout` variant label is kept for storage-format stability; it persists the sticky_key timeout. #[cfg(feature = "storage")] FLASH_CHANNEL.send(FlashOperationMessage::OneShotTimeout(ms)).await; } diff --git a/rmk/src/host/via/keycode_convert.rs b/rmk/src/host/via/keycode_convert.rs index 716f03327..fbe601fcc 100644 --- a/rmk/src/host/via/keycode_convert.rs +++ b/rmk/src/host/via/keycode_convert.rs @@ -1,4 +1,4 @@ -use rmk_types::action::{Action, KeyAction, KeyboardAction}; +use rmk_types::action::{Action, KeyAction, KeyboardAction, StickyKeyAction, StickyKeyEffect}; use rmk_types::keycode::{HidKeyCode, KeyCode, SpecialKey}; use rmk_types::modifier::ModifierCombination; @@ -53,15 +53,6 @@ pub(crate) fn to_via_keycode(key_action: KeyAction) -> u16 { // 0x0 // } } - Action::OneShotLayer(l) => { - // One-shot layer - if l < 16 { 0x5280 | l as u16 } else { 0x0000 } - } - Action::OneShotModifier(m) => { - // One-shot modifier - let modifier_bits = m.into_packed_bits(); - 0x52A0 | modifier_bits as u16 - } Action::LayerOnWithModifier(l, m) => { if l < 16 { 0x5000 | ((l as u16) << 5) | ((m.into_packed_bits() & 0b11111) as u16) @@ -91,6 +82,18 @@ pub(crate) fn to_via_keycode(key_action: KeyAction) -> u16 { } }, Action::User(id) => (id as u16 & 0x1F) | 0x7E00, + Action::OneShotLayer(layer) if layer < 32 => 0x5280 | layer as u16, + Action::OneShotModifier(modifiers) => 0x52A0 | ((modifiers.into_packed_bits() & 0x1F) as u16), + Action::StickyKey(sk) if sk.profile == u8::MAX => match sk.effect { + // OSL, VIA range (same as old OneShotLayer) + StickyKeyEffect::Layer(layer) if layer < 32 => 0x5280 | layer as u16, + // OSM, VIA range (same as old OneShotModifier) + StickyKeyEffect::Modifier(modifiers) => 0x52A0 | ((modifiers.into_packed_bits() & 0x1F) as u16), + _ => { + warn!("StickyKey {:?} is not supported by VIA", sk); + 0 + } + }, _ => { warn!("Action: {:?} in vial is not supported yet", a); 0 @@ -189,16 +192,6 @@ pub(crate) fn from_via_keycode(via_keycode: u16) -> KeyAction { let layer = via_keycode as u8 & 0x0F; KeyAction::Single(Action::LayerToggle(layer)) } - 0x5280..=0x529F => { - // One-shot layer - let layer = via_keycode as u8 & 0xF; - KeyAction::Single(Action::OneShotLayer(layer)) - } - 0x52A0..=0x52BF => { - // One-shot modifier - let m = ModifierCombination::from_packed_bits((via_keycode & 0x1F) as u8); - KeyAction::Single(Action::OneShotModifier(m)) - } 0x52C0..=0x52DF => { // TODO: Layer tap toggle warn!("Layer tap toggle {:#X} not supported", via_keycode); @@ -240,6 +233,22 @@ pub(crate) fn from_via_keycode(via_keycode: u16) -> KeyAction { 0x7C77 => KeyAction::Single(Action::TriLayerLower), 0x7C78 => KeyAction::Single(Action::TriLayerUpper), 0x7C79 => KeyAction::Single(Action::Special(SpecialKey::Repeat)), + // OSL(layer) — one-shot layer (VIA range 0x5280..0x529F, matching old OneShotLayer) + 0x5280..=0x529F => { + let layer = via_keycode as u8 & 0x1F; + KeyAction::Single(Action::StickyKey(StickyKeyAction { + effect: StickyKeyEffect::Layer(layer), + profile: u8::MAX, + })) + } + // OSM(mod) — one-shot modifier (VIA range 0x52A0..0x52BF, matching old OneShotModifier) + 0x52A0..=0x52BF => { + let m = ModifierCombination::from_packed_bits((via_keycode & 0x1F) as u8); + KeyAction::Single(Action::StickyKey(StickyKeyAction { + effect: StickyKeyEffect::Modifier(m), + profile: u8::MAX, + })) + } 0x7C18 => KeyAction::TapHold( Action::KeyWithModifier(HidKeyCode::Kc9, ModifierCombination::LSHIFT), Action::Modifier(ModifierCombination::LCTRL), @@ -340,22 +349,6 @@ mod test { let via_keycode = 0x5223; assert_eq!(KeyAction::Single(Action::LayerOn(3)), from_via_keycode(via_keycode)); - // OSL(3) - let via_keycode = 0x5283; - assert_eq!( - KeyAction::Single(Action::OneShotLayer(3)), - from_via_keycode(via_keycode) - ); - - // OSM RCtrl - let via_keycode = 0x52B1; - assert_eq!( - KeyAction::Single(Action::OneShotModifier(ModifierCombination::new_from( - true, false, false, false, true - ))), - from_via_keycode(via_keycode) - ); - // DF(3) let via_keycode = 0x5243; assert_eq!( @@ -383,7 +376,6 @@ mod test { KeyAction::Single(Action::PersistentDefaultLayer(15)), from_via_keycode(via_keycode) ); - // LCtrl(A) -> WithModifier(A) let via_keycode = 0x104; assert_eq!( @@ -649,17 +641,6 @@ mod test { // ClearEeprom (QK_CLEAR_EEPROM) let a = KeyAction::Single(Action::KeyboardControl(KeyboardAction::ClearEeprom)); assert_eq!(0x7C03, to_via_keycode(a)); - - // OSL(3) - let a = KeyAction::Single(Action::OneShotLayer(3)); - assert_eq!(0x5283, to_via_keycode(a)); - - // OSM RCtrl - let a = KeyAction::Single(Action::OneShotModifier(ModifierCombination::new_from( - true, false, false, false, true, - ))); - assert_eq!(0x52B1, to_via_keycode(a)); - // DF(3) let a = KeyAction::Single(Action::DefaultLayer(3)); assert_eq!(0x5243, to_via_keycode(a)); @@ -671,7 +652,6 @@ mod test { // PDF(15) let a = KeyAction::Single(Action::PersistentDefaultLayer(15)); assert_eq!(0x52EF, to_via_keycode(a)); - // LCtrl(A) -> WithModifier(A) let a = KeyAction::Single(Action::KeyWithModifier( HidKeyCode::A, @@ -891,4 +871,73 @@ mod test { assert_eq!(to_ascii(keycode, shifted), ascii); assert_eq!(from_ascii(ascii), (keycode, shifted)); } + + #[test] + fn test_vial_osm_round_trip() { + // OSM(LCtrl) — VIA range 0x52A0 + packed_bits + let osm_ctrl = KeyAction::Single(Action::StickyKey(StickyKeyAction { + effect: StickyKeyEffect::Modifier(ModifierCombination::LCTRL), + profile: u8::MAX, + })); + let via = to_via_keycode(osm_ctrl); + assert_eq!(via, 0x52A1); // 0x52A0 | LCtrl packed bits (0x01) + let roundtrip = from_via_keycode(via); + assert_eq!(roundtrip, osm_ctrl); + + // OSM(LShift) + let osm_shift = KeyAction::Single(Action::StickyKey(StickyKeyAction { + effect: StickyKeyEffect::Modifier(ModifierCombination::LSHIFT), + profile: u8::MAX, + })); + let via = to_via_keycode(osm_shift); + assert_eq!(via, 0x52A2); // 0x52A0 | LShift packed bits (0x02) + let roundtrip = from_via_keycode(via); + assert_eq!(roundtrip, osm_shift); + + // OSM(LAlt) — uses VIA range 0x52A0, round-trips through packed bits cleanly now + let osm_alt = KeyAction::Single(Action::StickyKey(StickyKeyAction { + effect: StickyKeyEffect::Modifier(ModifierCombination::LALT), + profile: u8::MAX, + })); + let via = to_via_keycode(osm_alt); + assert_eq!(via, 0x52A4); // 0x52A0 | LAlt packed bits (0x04) + let roundtrip = from_via_keycode(via); + assert_eq!(roundtrip, osm_alt); + } + + #[test] + fn test_vial_osl_round_trip() { + // OSL(0) — VIA range 0x5280 + layer + let osl_0 = KeyAction::Single(Action::StickyKey(StickyKeyAction { + effect: StickyKeyEffect::Layer(0), + profile: u8::MAX, + })); + let via = to_via_keycode(osl_0); + assert_eq!(via, 0x5280); + let roundtrip = from_via_keycode(via); + assert_eq!(roundtrip, osl_0); + + // OSL(5) + let osl_5 = KeyAction::Single(Action::StickyKey(StickyKeyAction { + effect: StickyKeyEffect::Layer(5), + profile: u8::MAX, + })); + let via = to_via_keycode(osl_5); + assert_eq!(via, 0x5285); + let roundtrip = from_via_keycode(via); + assert_eq!(roundtrip, osl_5); + } + + #[test] + fn test_vial_does_not_convert_tap_key_sticky_key_to_osm() { + let tap_key_sticky_key = KeyAction::Single(Action::StickyKey(StickyKeyAction { + effect: StickyKeyEffect::TapKey { + key: HidKeyCode::Tab, + modifiers: ModifierCombination::LALT, + }, + profile: u8::MAX, + })); + + assert_eq!(to_via_keycode(tap_key_sticky_key), 0); + } } diff --git a/rmk/src/host/via/vial.rs b/rmk/src/host/via/vial.rs index 0f7c01000..4053f4994 100644 --- a/rmk/src/host/via/vial.rs +++ b/rmk/src/host/via/vial.rs @@ -125,8 +125,8 @@ pub(crate) async fn process_vial<'a>( LittleEndian::write_u16(&mut report.input_data[1..3], tapping_term); } SettingKey::OneShotTimeout => { - let one_shot_timeout = ctx.one_shot_timeout().as_millis() as u16; - LittleEndian::write_u16(&mut report.input_data[1..3], one_shot_timeout); + let sticky_key_timeout = ctx.sticky_key_timeout().as_millis() as u16; + LittleEndian::write_u16(&mut report.input_data[1..3], sticky_key_timeout); } SettingKey::TapInterval => { let tap_interval = ctx.tap_interval(); @@ -183,7 +183,7 @@ pub(crate) async fn process_vial<'a>( } SettingKey::OneShotTimeout => { let timeout_time = u16::from_le_bytes([report.output_data[4], report.output_data[5]]); - ctx.set_one_shot_timeout(timeout_time).await; + ctx.set_sticky_key_timeout(timeout_time).await; } SettingKey::TapInterval => { let tap_interval = u16::from_le_bytes([report.output_data[4], report.output_data[5]]); diff --git a/rmk/src/keyboard.rs b/rmk/src/keyboard.rs index f97d868b2..fedbaf14a 100644 --- a/rmk/src/keyboard.rs +++ b/rmk/src/keyboard.rs @@ -1,13 +1,12 @@ use core::fmt::Debug; -#[cfg(all(feature = "split", feature = "_ble"))] -use embassy_futures::select::{Either, select}; +use embassy_futures::select::{Either, Either3, select, select3}; use embassy_futures::yield_now; #[cfg(feature = "_ble")] use embassy_sync::signal::Signal; use embassy_time::{Duration, Instant, Timer, with_deadline}; use heapless::Vec; -use rmk_types::action::{Action, KeyAction, KeyboardAction}; +use rmk_types::action::{Action, KeyAction, KeyboardAction, StickyKeyAction, StickyKeyEffect}; use rmk_types::fork::StateBits; use rmk_types::keycode::{ConsumerKey, HidKeyCode, KeyCode, SpecialKey, SystemControlKey}; use rmk_types::led_indicator::LedIndicator; @@ -21,16 +20,17 @@ use crate::core_traits::Runnable; #[cfg(all(feature = "split", feature = "_ble"))] use crate::event::ClearPeerEvent; use crate::event::{ - ActionEvent, KeyboardEvent, KeyboardEventPos, ModifierEvent, SubscribableEvent, publish_event, publish_event_async, + ActionEvent, KeyboardEvent, KeyboardEventPos, ModifierEvent, StickyKeyReleaseEvent, SubscribableEvent, + publish_event, publish_event_async, }; use crate::hid::{KeyboardReport, Report}; use crate::keyboard::combo::Combo; use crate::keyboard::fork::ActiveFork; use crate::keyboard::held_buffer::{HeldBuffer, HeldKey, KeyState}; use crate::keyboard::mouse::{MouseAction, MouseState}; -use crate::keyboard::oneshot::OneShotState; +use crate::keyboard::sticky_key::StickyKeyState; use crate::keyboard_macros::MacroOperation; -use crate::keymap::KeyMap; +use crate::keymap::{KeyMap, StickyKeyShape}; #[cfg(all(feature = "split", feature = "_ble"))] use crate::split::ble::central::update_activity_time; use crate::{COMBO_MAX_NUM, FORK_MAX_NUM, MACRO_SPACE_SIZE, boot}; @@ -41,9 +41,9 @@ pub(crate) mod fork; pub(crate) mod held_buffer; pub(crate) mod morse; pub(crate) mod mouse; -pub(crate) mod oneshot; #[cfg(feature = "steno")] pub(crate) mod steno; +pub(crate) mod sticky_key; use crate::keymap::HOLD_BUFFER_SIZE; @@ -142,8 +142,11 @@ impl Runnable for Keyboard<'_> { /// The report is sent using `send_report`. async fn run(&mut self) -> ! { loop { - // TODO: Now the unprocessed_events is only used in one-shot keys and clear peer key. - // Maybe it can be removed in the future? + // `unprocessed_events` is still required: the Clear Peer BLE path + // (`#[cfg(feature = "split")]`, see below) pushes events here for re-processing. + // Do NOT delete the queue or this consumer. + // (The OSM/OSL producers were removed once those behaviors moved to the SK engine, + // whose timeout is now driven by the inline race below.) if !self.unprocessed_events.is_empty() { // Process unprocessed events let e = self.unprocessed_events.remove(0); @@ -153,22 +156,51 @@ impl Runnable for Keyboard<'_> { // Process buffered held key self.process_buffered_key(key).await } else { - // If mouse repeat is pending, race subscriber against deadline - let event = if let Some(deadline) = self.mouse.next_deadline() { - match with_deadline(deadline, self.keyboard_event_subscriber.next_message_pure()).await { - Ok(event) => event, - Err(_) => { - // Repeat deadline expired, fire repeat - self.fire_mouse_repeat().await; - continue; + // Race subscriber against the nearest pending deadline. + let deadline = self + .sticky_key_state + .deadline() + .into_iter() + .chain(self.mouse.next_deadline()) + .reduce(|a, b| a.min(b)); + if let Some(deadline) = deadline { + match select3( + self.keyboard_event_subscriber.next_message_pure(), + self.sticky_key_release_event_subscriber.next_message_pure(), + Timer::at(deadline), + ) + .await + { + Either3::First(event) => { + self.process_inner(event).await; + } + Either3::Second(layer_event) => { + self.release_sticky_key_on_layer_event(layer_event.0).await; } + Either3::Third(_) => {} } } else { - // No repeat pending, wait indefinitely - self.keyboard_event_subscriber.next_message_pure().await - }; - self.process_inner(event).await + match select( + self.keyboard_event_subscriber.next_message_pure(), + self.sticky_key_release_event_subscriber.next_message_pure(), + ) + .await + { + Either::First(event) => self.process_inner(event).await, + Either::Second(layer_event) => { + self.release_sticky_key_on_layer_event(layer_event.0).await; + } + } + } }; + + // Check deadlines after processing / timeout. + if self.sticky_key_state.deadline().is_some_and(|d| Instant::now() >= d) { + self.release_sticky_key_if_active_on_timeout().await; + } + if self.mouse.next_deadline().is_some_and(|d| Instant::now() >= d) { + self.fire_mouse_repeat().await; + } } } } @@ -187,6 +219,15 @@ pub struct Keyboard<'a> { { crate::KEYBOARD_EVENT_PUB_SIZE }, >, + sticky_key_release_event_subscriber: embassy_sync::pubsub::Subscriber< + 'static, + crate::RawMutex, + StickyKeyReleaseEvent, + { crate::STICKY_KEY_RELEASE_EVENT_CHANNEL_SIZE }, + { crate::STICKY_KEY_RELEASE_EVENT_SUB_SIZE }, + { crate::STICKY_KEY_RELEASE_EVENT_PUB_SIZE }, + >, + /// Unprocessed events pub unprocessed_events: Vec, @@ -201,11 +242,8 @@ pub struct Keyboard<'a> { /// Used in repeat-key last_key_code: HidKeyCode, - /// Oneshot Layer state - osl_state: OneShotState, - - /// Oneshot Modifier state - osm_state: OneShotState, + /// StickyKey state — holds a modifier+key combination across key presses + sticky_key_state: StickyKeyState, /// Caps Word state machine caps_word: CapsWordState, @@ -257,9 +295,9 @@ impl<'a> Keyboard<'a> { Keyboard { keymap, keyboard_event_subscriber: KeyboardEvent::subscriber(), + sticky_key_release_event_subscriber: StickyKeyReleaseEvent::subscriber(), last_press_time: Instant::now(), - osl_state: OneShotState::default(), - osm_state: OneShotState::default(), + sticky_key_state: StickyKeyState::default(), caps_word: CapsWordState::default(), with_modifiers: ModifierCombination::default(), macro_texting: false, @@ -1216,6 +1254,40 @@ impl<'a> Keyboard<'a> { }) .await; + // Release the tap-key StickyKey when any non-SK, non-modifier key is pressed. + // + // Pure-mod SKs are deliberately NOT released here: the modifier must remain applied + // THROUGH the terminating key's report (and is then consumed by `update_sticky_key` + // in `process_action_key`, per `quick_release`). Only the tap-key shape releases its + // held modifier cleanly before the foreign key registers. + let mut release_tap_key_after_action = false; + if self.sticky_key_state.is_tap_key() { + let is_sk_or_modifier = match action { + Action::StickyKey(_) | Action::OneShotModifier(_) | Action::OneShotLayer(_) | Action::Modifier(_) => { + true + } + Action::Key(KeyCode::Hid(hid_key)) if hid_key.is_modifier() => true, + _ => false, + }; + let release_mode = self.sticky_key_state.profile().and_then(|index| { + self.keymap + .sticky_key_profile(index, StickyKeyShape::TapKey) + .release_mode + }); + let should_release = match release_mode { + Some(mode) if event.pressed => mode.contains(crate::config::StickyKeyReleaseMode::OTHER_KEY_PRESS), + Some(mode) => mode.contains(crate::config::StickyKeyReleaseMode::OTHER_KEY_RELEASE), + None => event.pressed, + }; + if !is_sk_or_modifier && should_release { + if event.pressed { + self.release_sticky_key_if_active().await; + } else { + release_tap_key_after_action = true; + } + } + } + match action { Action::No => {} Action::Key(key) => match key { @@ -1223,28 +1295,34 @@ impl<'a> Keyboard<'a> { // Consumer/system keys with no HID alias are dispatched directly here. KeyCode::Consumer(consumer) => { self.process_action_consumer_control(consumer, event).await; - self.update_osm(event); - self.update_osl(event); + self.update_sticky_key(event); } KeyCode::SystemControl(system_control) => { self.process_action_system_control(system_control, event).await; - self.update_osm(event); - self.update_osl(event); + self.update_sticky_key(event); } _ => warn!("KeyCode variant not supported: {:?}", key), }, - Action::LayerOn(layer_num) => self.process_action_layer_switch(layer_num, event), + Action::LayerOn(layer_num) => self.process_action_layer_switch(layer_num, event).await, Action::LayerOff(layer_num) => { // Turn off a layer temporarily when the key is pressed // Reactivate the layer after the key is released - if event.pressed { - self.keymap.deactivate_layer(layer_num); + if event.pressed && self.keymap.deactivate_layer(layer_num) { + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_EXIT) + .await; } } Action::LayerToggle(layer_num) => { - // Toggle a layer when the key is release - if !event.pressed { - self.keymap.toggle_layer(layer_num); + // Toggle a layer when the key is released + if !event.pressed + && let Some(active) = self.keymap.toggle_layer(layer_num) + { + let mode = if active { + crate::config::StickyKeyReleaseMode::LAYER_ENTER + } else { + crate::config::StickyKeyReleaseMode::LAYER_EXIT + }; + self.release_sticky_key_on_layer_event(mode).await; } } Action::LayerToggleOnly(layer_num) => { @@ -1253,22 +1331,41 @@ impl<'a> Keyboard<'a> { // Disable all layers except the default layer let default_layer = self.keymap.get_default_layer(); let (_, _, num_layer) = self.keymap.get_keymap_config(); + let mut exited = false; for i in 0..num_layer as u8 { if i != default_layer { - self.keymap.deactivate_layer(i); + exited |= self.keymap.deactivate_layer(i); } } // Activate the target layer - self.keymap.activate_layer(layer_num); + let entered = self.keymap.activate_layer(layer_num); + if exited { + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_EXIT) + .await; + } + if entered { + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_ENTER) + .await; + } } } Action::DefaultLayer(layer_num) => { // Set the default layer - self.keymap.set_default_layer(layer_num); + if event.pressed && self.keymap.set_default_layer(layer_num) { + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_EXIT) + .await; + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_ENTER) + .await; + } } Action::PersistentDefaultLayer(layer_num) => { // Set the default layer and persist it so it survives a reboot - self.keymap.set_default_layer(layer_num); + if event.pressed && self.keymap.set_default_layer(layer_num) { + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_EXIT) + .await; + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_ENTER) + .await; + } // Persist only if the layer was valid (set_default_layer rejects out-of-range) #[cfg(feature = "storage")] if event.pressed && self.keymap.get_default_layer() == layer_num { @@ -1285,7 +1382,6 @@ impl<'a> Keyboard<'a> { } //report the modifier press/release in its own hid report self.send_keyboard_report_with_resolved_modifiers(event.pressed).await; - self.update_osl(event); } Action::TriggerMacro(macro_idx) => self.execute_macro(macro_idx, event).await, Action::KeyWithModifier(key_code, modifiers) => { @@ -1310,18 +1406,31 @@ impl<'a> Keyboard<'a> { // they will be "released" the same time as the key (in same hid report) self.held_modifiers &= !(modifiers); } - self.process_action_layer_switch(layer_num, event); + self.process_action_layer_switch(layer_num, event).await; self.send_keyboard_report_with_resolved_modifiers(event.pressed).await } - Action::OneShotLayer(l) => { - self.process_action_osl(l, event).await; - // Process OSM to avoid the OSL state stuck when an OSL is followed by an OSM - self.update_osm(event); + Action::StickyKey(params) => { + self.process_action_sticky_key(params, event).await; } - Action::OneShotModifier(m) => { - self.process_action_osm(m, event).await; - // Process OSL to avoid the OSM state stuck when an OSM is followed by an OSL - self.update_osl(event); + Action::OneShotModifier(modifiers) => { + self.process_action_sticky_key( + StickyKeyAction { + effect: StickyKeyEffect::Modifier(modifiers), + profile: u8::MAX, + }, + event, + ) + .await; + } + Action::OneShotLayer(layer) => { + self.process_action_sticky_key( + StickyKeyAction { + effect: StickyKeyEffect::Layer(layer), + profile: u8::MAX, + }, + event, + ) + .await; } Action::OneShotKey(_k) => warn!("One-shot key is not supported: {:?}", action), Action::Light(_light_action) => warn!("Light controll is not supported"), @@ -1330,12 +1439,12 @@ impl<'a> Keyboard<'a> { Action::User(id) => self.process_user(id, event).await, Action::TriLayerLower => { // Tri-layer lower, turn layer 1 on and update layer state - self.process_action_layer_switch(1, event); + self.process_action_layer_switch(1, event).await; self.keymap.update_fn_layer_state(); } Action::TriLayerUpper => { // Tri-layer upper, turn layer 2 on and update layer state - self.process_action_layer_switch(2, event); + self.process_action_layer_switch(2, event).await; self.keymap.update_fn_layer_state(); } #[cfg(feature = "steno")] @@ -1346,6 +1455,10 @@ impl<'a> Keyboard<'a> { } _ => warn!("Action variant not supported: {:?}", action), } + + if release_tap_key_after_action { + self.release_sticky_key_if_active().await; + } } /// Tap action, send a key when the key is pressed, then release the key. @@ -1375,21 +1488,27 @@ impl<'a> Keyboard<'a> { /// - registered modifiers /// - one-shot modifiers pub fn resolve_explicit_modifiers(&self, pressed: bool) -> ModifierCombination { - // if a one-shot modifier is active, decorate the hid report of keypress with those modifiers + // if a sticky key is active, decorate the hid report of keypress with its modifiers let mut result = self.held_modifiers; - // OneShotState::Held keeps the temporary modifiers active until the key is released - if pressed { - if let Some(osm) = self.osm_state.value() { - result |= *osm; + // Add StickyKey modifiers. + // + // - Tap-key shape (alt-tab): the modifier is held continuously between presses, so + // it is included on both press and release reports (its own HID key is what gets + // registered/unregistered). + // - Pure-mod shape (OSM): the modifier usually applies only on the terminating key's + // press report and is "released" together with the key release — except in held + // mode (key pressed while SK still physically held), where the modifier behaves + // like a normal held modifier and stays applied until the SK itself is released. + if let Some(mods) = self.sticky_key_state.value().copied() { + if self.sticky_key_state.is_pure_mod() || self.sticky_key_state.is_layer() { + if pressed || self.sticky_key_state.is_held() { + result |= mods; + } + } else { + result |= mods; } - } else if let OneShotState::Held(osm) = self.osm_state { - // One shot modifiers usually "released" together with the key release, - // except when oneshot is in "held mode" (to allow Alt+Tab like use cases) - // In this later case Held -> None state change will report - // the "modifier released" change in a separate hid report - result |= osm; - }; + } result } @@ -1579,22 +1698,33 @@ impl<'a> Keyboard<'a> { true }; - // Consume any pending one-shot; on quick-release of a basic key, re-send the report. - let quick_release = self.keymap.one_shot_modifiers_config().quick_release; - let osm_consumed = self.update_osm(event); - if quick_release && osm_consumed && is_basic_keyboard_key && event.pressed { + // Consume any pending one-shot StickyKey. A press-triggered release needs a + // follow-up report after the terminating key has been registered. + let press_release = self.sticky_key_state.profile().is_some_and(|index| { + self.keymap + .sticky_key_profile(index, StickyKeyShape::PureMod) + .release_mode + .is_some_and(|mode| mode.contains(crate::config::StickyKeyReleaseMode::OTHER_KEY_PRESS)) + }); + let sk_consumed = self.update_sticky_key(event); + if press_release && sk_consumed && is_basic_keyboard_key && event.pressed { self.send_keyboard_report_with_resolved_modifiers(true).await; } - self.update_osl(event); } /// Process layer switch action. - fn process_action_layer_switch(&mut self, layer_num: u8, event: KeyboardEvent) { + async fn process_action_layer_switch(&mut self, layer_num: u8, event: KeyboardEvent) { // Change layer state only when the key's state is changed if event.pressed { - self.keymap.activate_layer(layer_num); + if self.keymap.activate_layer(layer_num) { + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_ENTER) + .await; + } } else { - self.keymap.deactivate_layer(layer_num); + if self.keymap.deactivate_layer(layer_num) { + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_EXIT) + .await; + } } } @@ -2237,7 +2367,9 @@ mod test { let mut keyboard = create_test_keyboard(); // Activate layer 1 - keyboard.process_action_layer_switch(1, KeyboardEvent::key(0, 0, true)); + keyboard + .process_action_layer_switch(1, KeyboardEvent::key(0, 0, true)) + .await; // Press Transparent key (Q on lower layer) keyboard.process_inner(KeyboardEvent::key(1, 1, true)).await; diff --git a/rmk/src/keyboard/auto_mouse_layer.rs b/rmk/src/keyboard/auto_mouse_layer.rs index 781db4449..0fe8f1f03 100644 --- a/rmk/src/keyboard/auto_mouse_layer.rs +++ b/rmk/src/keyboard/auto_mouse_layer.rs @@ -24,10 +24,11 @@ use rmk_types::keycode::{HidKeyCode, KeyCode}; use rmk_types::modifier::ModifierCombination; use crate::AUTO_MOUSE_LAYER_MAX_NUM; -use crate::config::AutoMouseLayerConfig; +use crate::config::{AutoMouseLayerConfig, StickyKeyReleaseMode}; use crate::core_traits::Runnable; use crate::event::{ - ActionEvent, Axis, AxisValType, EventSubscriber, LayerChangeEvent, PointingEvent, SubscribableEvent, + ActionEvent, Axis, AxisValType, EventSubscriber, LayerChangeEvent, PointingEvent, StickyKeyReleaseEvent, + SubscribableEvent, publish_event, }; use crate::keymap::KeyMap; use crate::processor::Processor; @@ -105,6 +106,9 @@ impl<'a, 'k> AutoMouseLayerRunner<'a, 'k> { } let target_layer = self.entries[idx].config.target_layer; let activated_by_us = self.keymap.activate_layer_if_inactive(target_layer); + if activated_by_us { + publish_event(StickyKeyReleaseEvent(StickyKeyReleaseMode::LAYER_ENTER)); + } if pointing_step(&mut self.entries, idx, Instant::now(), activated_by_us) == PointingOutcome::OverlapFirstSeen { warn!( "auto_mouse_layer: layer {} is already active when motion was detected; \ @@ -138,7 +142,9 @@ impl<'a, 'k> AutoMouseLayerRunner<'a, 'k> { return; } for layer in keypress_step(&mut self.entries, event.action, Instant::now()) { - self.keymap.deactivate_layer_if_active(layer); + if self.keymap.deactivate_layer_if_active(layer) { + publish_event(StickyKeyReleaseEvent(StickyKeyReleaseMode::LAYER_EXIT)); + } } } } @@ -172,7 +178,9 @@ impl AutoMouseLayerRunner<'_, '_> { async fn on_deadline(&mut self) { for layer in timeout_step(&mut self.entries, Instant::now()) { - self.keymap.deactivate_layer_if_active(layer); + if self.keymap.deactivate_layer_if_active(layer) { + publish_event(StickyKeyReleaseEvent(StickyKeyReleaseMode::LAYER_EXIT)); + } } } } diff --git a/rmk/src/keyboard/oneshot.rs b/rmk/src/keyboard/oneshot.rs deleted file mode 100644 index aa8b1b75f..000000000 --- a/rmk/src/keyboard/oneshot.rs +++ /dev/null @@ -1,194 +0,0 @@ -use embassy_futures::select::{Either, select}; -use embassy_time::Timer; -use rmk_types::modifier::ModifierCombination; - -use crate::event::KeyboardEvent; -use crate::keyboard::Keyboard; - -/// State machine for one shot keys -#[derive(Default)] -pub enum OneShotState { - /// First one shot key press - Initial(T), - /// One shot key was released before any other key, normal one shot behavior - Single(T), - /// Another key was pressed before one shot key was released, treat as a normal modifier/layer - Held(T), - /// One shot inactive - #[default] - None, -} - -impl OneShotState { - /// Get the current one shot value if any - pub fn value(&self) -> Option<&T> { - match self { - OneShotState::Initial(v) | OneShotState::Single(v) | OneShotState::Held(v) => Some(v), - OneShotState::None => None, - } - } -} - -impl<'a> Keyboard<'a> { - pub(crate) async fn process_action_osm(&mut self, new_modifiers: ModifierCombination, event: KeyboardEvent) { - let activate_on_keypress = self.keymap.one_shot_modifiers_config().activate_on_keypress; - - // Update one shot state - if event.pressed { - let mut was_active = false; - // Add new modifier combination to existing one shot or init if none - self.osm_state = match self.osm_state { - OneShotState::None => OneShotState::Initial(new_modifiers), - OneShotState::Initial(cur_modifiers) => OneShotState::Initial(cur_modifiers | new_modifiers), - OneShotState::Single(cur_modifiers) => { - was_active = cur_modifiers & new_modifiers == new_modifiers; - - if was_active { - let result = cur_modifiers & !new_modifiers; - // Remove the matching event from unprocessed_events queue - self.unprocessed_events.retain(|e| e.pos != event.pos); - // Send report for current osm_state modifiers - self.send_keyboard_report_with_resolved_modifiers(true).await; - - if result.into_bits() == 0 { - OneShotState::None - } else { - OneShotState::Single(result) - } - } else { - OneShotState::Single(cur_modifiers | new_modifiers) - } - } - OneShotState::Held(cur_modifiers) => OneShotState::Held(cur_modifiers | new_modifiers), - }; - - self.update_osl(event); - - // Send report for updated osm_state modifiers - if was_active || activate_on_keypress { - self.send_keyboard_report_with_resolved_modifiers(true).await; - } - } else { - match self.osm_state { - OneShotState::Initial(cur_modifiers) | OneShotState::Single(cur_modifiers) => { - self.osm_state = OneShotState::Single(cur_modifiers); - let timeout = Timer::after(self.keymap.one_shot_timeout()); - match select(timeout, self.keyboard_event_subscriber.next_message_pure()).await { - Either::First(_) => { - // Timeout, release modifiers - self.update_osl(event); - self.osm_state = OneShotState::None; - - // Send release report because modifiers were held - if activate_on_keypress { - self.send_keyboard_report_with_resolved_modifiers(false).await; - } - } - Either::Second(e) => { - // New event, send it to queue - if self.unprocessed_events.push(e).is_err() { - warn!("Unprocessed event queue is full, dropping event"); - } - } - } - } - OneShotState::Held(cur_modifiers) => { - let was_active = cur_modifiers & new_modifiers == new_modifiers; - - if !was_active { - return; - } - - // Release modifier - self.update_osl(event); - self.osm_state = OneShotState::None; - - // This sends a separate hid report with the - // currently registered modifiers except the - // one shot modifiers -> this way "releasing" them. - self.send_keyboard_report_with_resolved_modifiers(false).await; - } - _ => (), - }; - } - } - - pub(crate) async fn process_action_osl(&mut self, layer_num: u8, event: KeyboardEvent) { - // Update one shot state - if event.pressed { - // Deactivate old layer if any - if let Some(&l) = self.osl_state.value() { - self.keymap.deactivate_layer(l); - } - - // Update layer of one shot - self.osl_state = match self.osl_state { - OneShotState::None => OneShotState::Initial(layer_num), - OneShotState::Initial(_) => OneShotState::Initial(layer_num), - OneShotState::Single(_) => OneShotState::Single(layer_num), - OneShotState::Held(_) => OneShotState::Held(layer_num), - }; - - // Activate new layer - self.keymap.activate_layer(layer_num); - } else { - match self.osl_state { - OneShotState::Initial(l) | OneShotState::Single(l) => { - self.osl_state = OneShotState::Single(l); - - let timeout = embassy_time::Timer::after(self.keymap.one_shot_timeout()); - match select(timeout, self.keyboard_event_subscriber.next_message_pure()).await { - Either::First(_) => { - // Timeout, deactivate layer - self.keymap.deactivate_layer(layer_num); - self.osl_state = OneShotState::None; - } - Either::Second(e) => { - // New event, send it to queue - if self.unprocessed_events.push(e).is_err() { - warn!("Unprocessed event queue is full, dropping event"); - } - } - } - } - OneShotState::Held(layer_num) => { - self.osl_state = OneShotState::None; - self.keymap.deactivate_layer(layer_num); - } - _ => (), - }; - } - } - - /// Update OSM state based on the keyboard event. - /// Returns `true` if the OSM was consumed (transitioned from Single to None). - pub(crate) fn update_osm(&mut self, event: KeyboardEvent) -> bool { - let quick_release = self.keymap.one_shot_modifiers_config().quick_release; - match self.osm_state { - OneShotState::Initial(m) => { - self.osm_state = OneShotState::Held(m); - false - } - OneShotState::Single(_) if quick_release && event.pressed => { - self.osm_state = OneShotState::None; - true - } - OneShotState::Single(_) if !quick_release && !event.pressed => { - self.osm_state = OneShotState::None; - true - } - _ => false, - } - } - - pub(crate) fn update_osl(&mut self, event: KeyboardEvent) { - match self.osl_state { - OneShotState::Initial(l) => self.osl_state = OneShotState::Held(l), - OneShotState::Single(layer_num) if !event.pressed => { - self.keymap.deactivate_layer(layer_num); - self.osl_state = OneShotState::None; - } - _ => (), - } - } -} diff --git a/rmk/src/keyboard/sticky_key.rs b/rmk/src/keyboard/sticky_key.rs new file mode 100644 index 000000000..99a2816a0 --- /dev/null +++ b/rmk/src/keyboard/sticky_key.rs @@ -0,0 +1,477 @@ +//! StickyKey action implementation. +//! +//! A unified one-shot action engine covering pure-mod (OSM), tap-key, and layer (OSL) shapes. +//! The effect is represented explicitly by the `StickyKeyAction` payload. +//! Runtime state and its lifecycle are represented by `StickyKeyState`. +//! +//! Timeout is driven solely by the run-loop deadline race (see `Keyboard::run`); there is +//! no inline `select` in this module. On expiry the run loop calls +//! [`Keyboard::release_sticky_key_if_active`]. + +use embassy_time::{Duration, Instant}; +use rmk_types::action::{StickyKeyAction, StickyKeyEffect}; +use rmk_types::keycode::HidKeyCode; +use rmk_types::modifier::ModifierCombination; + +use crate::config::StickyKeyReleaseMode; +use crate::event::{KeyboardEvent, KeyboardEventPos}; +use crate::keyboard::Keyboard; +use crate::keymap::StickyKeyShape; + +fn deadline_from_timeout(timeout: Duration) -> Option { + (timeout != Duration::MAX).then(|| Instant::now() + timeout) +} + +/// The operation performed while a Sticky Key is active. +#[derive(Clone, Copy, Debug)] +enum ActiveEffect { + Modifier, + Layer(u8), + TapKey(HidKeyCode), +} + +/// Data carried through each active Sticky Key lifecycle state. +#[derive(Clone, Copy, Debug)] +pub(crate) struct ActiveStickyKey { + /// Physical key that owns this latch. + source: KeyboardEventPos, + mods: ModifierCombination, + effect: ActiveEffect, + /// Selected Sticky Key profile (`u8::MAX` means default profile). + profile: u8, + repeat_count: u16, + deadline: Option, +} + +/// Lifecycle of a Sticky Key. +#[derive(Clone, Copy, Debug, Default)] +pub(crate) enum StickyKeyState { + /// No Sticky Key is active. + #[default] + None, + /// The physical Sticky Key is down and no foreign key has been pressed. + Pressed(ActiveStickyKey), + /// The physical Sticky Key was released and is armed for a foreign key. + Latched(ActiveStickyKey), + /// A foreign key was pressed while the physical Sticky Key remained down. + Held(ActiveStickyKey), +} + +enum ReleaseTransition { + Ignored, + Latched, + Held, +} + +impl StickyKeyState { + pub fn value(&self) -> Option<&ModifierCombination> { + self.active().map(|active| &active.mods) + } + + pub fn is_active(&self) -> bool { + !matches!(self, StickyKeyState::None) + } + + pub fn deadline(&self) -> Option { + self.active().and_then(|active| active.deadline) + } + + pub fn is_pure_mod(&self) -> bool { + self.active() + .is_some_and(|active| matches!(active.effect, ActiveEffect::Modifier)) + } + + pub fn is_tap_key(&self) -> bool { + self.active() + .is_some_and(|active| matches!(active.effect, ActiveEffect::TapKey(_))) + } + + pub fn is_layer(&self) -> bool { + self.active() + .is_some_and(|active| matches!(active.effect, ActiveEffect::Layer(_))) + } + + pub(crate) fn profile(&self) -> Option { + self.active().map(|active| active.profile) + } + + pub(crate) fn shape(&self) -> Option { + if self.is_pure_mod() { + Some(StickyKeyShape::PureMod) + } else if self.is_layer() { + Some(StickyKeyShape::Layer) + } else if self.is_tap_key() { + Some(StickyKeyShape::TapKey) + } else { + None + } + } + + pub(crate) fn is_held(&self) -> bool { + matches!(self, Self::Held(_)) + } + + fn active(&self) -> Option<&ActiveStickyKey> { + match self { + Self::Pressed(active) | Self::Latched(active) | Self::Held(active) => Some(active), + Self::None => None, + } + } +} + +impl Keyboard<'_> { + fn transition_on_release( + &mut self, + owner: Option, + deadline: Option, + ) -> ReleaseTransition { + match self.sticky_key_state { + StickyKeyState::Pressed(mut active) if owner.is_none_or(|owner| active.source == owner) => { + active.deadline = deadline; + self.sticky_key_state = StickyKeyState::Latched(active); + ReleaseTransition::Latched + } + StickyKeyState::Held(active) if owner.is_none_or(|owner| active.source == owner) => { + self.sticky_key_state = StickyKeyState::None; + ReleaseTransition::Held + } + _ => ReleaseTransition::Ignored, + } + } + + pub(crate) async fn release_sticky_key_on_layer_event(&mut self, event: StickyKeyReleaseMode) { + let (Some(index), Some(shape)) = (self.sticky_key_state.profile(), self.sticky_key_state.shape()) else { + return; + }; + if self + .keymap + .sticky_key_profile(index, shape) + .release_mode + .is_some_and(|mode| mode.contains(event)) + { + self.release_sticky_key_if_active().await; + } + } + + pub(crate) async fn process_action_sticky_key(&mut self, params: StickyKeyAction, event: KeyboardEvent) { + let shape = match params.effect { + StickyKeyEffect::Modifier(_) => StickyKeyShape::PureMod, + StickyKeyEffect::Layer(_) => StickyKeyShape::Layer, + StickyKeyEffect::TapKey { .. } => StickyKeyShape::TapKey, + }; + + if event.pressed + && matches!( + self.sticky_key_state, + StickyKeyState::Latched(ActiveStickyKey { source, .. }) if source == event.pos + ) + && self + .keymap + .sticky_key_profile(params.profile, shape) + .release_mode + .is_some_and(|mode| mode.double_tap()) + { + self.release_sticky_key_if_active().await; + return; + } + + match params.effect { + StickyKeyEffect::Modifier(modifiers) => { + self.process_sticky_pure_mod(modifiers, params.profile, event).await + } + StickyKeyEffect::Layer(layer) => self.process_sticky_layer(layer, params.profile, event).await, + StickyKeyEffect::TapKey { key, modifiers } => { + self.process_sticky_tap_key(key, modifiers, params.profile, event).await + } + } + } + + /// Pure-mod (OSM) shape: accumulate the modifier across taps, apply it through the + /// terminating key, honor `activate_on_keypress`/`quick_release`. + async fn process_sticky_pure_mod( + &mut self, + modifiers: ModifierCombination, + profile_index: u8, + event: KeyboardEvent, + ) { + let profile = self.keymap.sticky_key_profile(profile_index, StickyKeyShape::PureMod); + let deadline = deadline_from_timeout(profile.timeout); + + if event.pressed { + if self.sticky_key_state.is_active() + && !self.sticky_key_state.is_pure_mod() + && !self.sticky_key_state.is_layer() + { + self.release_sticky_key_if_active().await; + } + + self.sticky_key_state = match self.sticky_key_state.active().copied() { + None => StickyKeyState::Pressed(ActiveStickyKey { + source: event.pos, + mods: modifiers, + effect: ActiveEffect::Modifier, + profile: profile_index, + repeat_count: 1, + deadline, + }), + Some(mut active) => { + active.source = event.pos; + active.mods |= modifiers; + active.profile = profile_index; + active.deadline = deadline; + StickyKeyState::Pressed(active) + } + }; + + if profile.activate_on_keypress { + self.send_keyboard_report_with_resolved_modifiers(true).await; + } + } else { + // Combo outputs may be released by a different constituent position, + // so modifier actions cannot require the original source position. + if matches!(self.transition_on_release(None, deadline), ReleaseTransition::Held) { + self.send_keyboard_report_with_resolved_modifiers(false).await; + } + } + } + + /// Layer (OSL) shape: activate the layer for the next foreign key. The layer carries + /// no modifier, so consuming it emits no HID report. + async fn process_sticky_layer(&mut self, layer_num: u8, profile_index: u8, event: KeyboardEvent) { + let profile = self.keymap.sticky_key_profile(profile_index, StickyKeyShape::Layer); + let deadline = deadline_from_timeout(profile.timeout); + + if event.pressed { + if self.sticky_key_state.is_tap_key() { + self.release_sticky_key_if_active().await; + } + + let existing_mods = match self.sticky_key_state.active().copied() { + Some(active) => { + if let ActiveEffect::Layer(previous_layer) = active.effect { + self.keymap.deactivate_layer(previous_layer); + } + active.mods + } + None => ModifierCombination::new(), + }; + + self.keymap.activate_layer(layer_num); + self.sticky_key_state = StickyKeyState::Pressed(ActiveStickyKey { + source: event.pos, + mods: existing_mods, + effect: ActiveEffect::Layer(layer_num), + profile: profile_index, + repeat_count: 1, + deadline, + }); + } else { + if matches!( + self.transition_on_release(Some(event.pos), deadline), + ReleaseTransition::Held + ) { + self.keymap.deactivate_layer(layer_num); + } + } + } + + /// Tap-key (alt-tab) shape: send `keep` mods + `key` on every press, hold the mods + /// between presses, cycle on each press (`max_repeat`). Ignores + /// `activate_on_keypress`/`quick_release`. + async fn process_sticky_tap_key( + &mut self, + key: HidKeyCode, + modifiers: ModifierCombination, + profile_index: u8, + event: KeyboardEvent, + ) { + let profile = self.keymap.sticky_key_profile(profile_index, StickyKeyShape::TapKey); + let deadline = deadline_from_timeout(profile.timeout); + + if event.pressed { + let is_different_tap_key = self + .sticky_key_state + .active() + .is_some_and(|active| active.source != event.pos); + if self.sticky_key_state.is_active() && (!self.sticky_key_state.is_tap_key() || is_different_tap_key) { + self.release_sticky_key_if_active().await; + } + + let mut should_deactivate = false; + self.sticky_key_state = match self.sticky_key_state.active().copied() { + None => StickyKeyState::Pressed(ActiveStickyKey { + source: event.pos, + mods: modifiers, + effect: ActiveEffect::TapKey(key), + profile: profile_index, + repeat_count: 1, + deadline, + }), + Some(mut active) => { + active.repeat_count = active.repeat_count.saturating_add(1); + if profile.max_repeat > 0 && active.repeat_count > profile.max_repeat { + should_deactivate = true; + StickyKeyState::None + } else { + active.deadline = deadline; + StickyKeyState::Pressed(active) + } + } + }; + + if should_deactivate { + self.send_keyboard_report_with_resolved_modifiers(false).await; + } else { + self.register_key(key, event); + self.send_keyboard_report_with_resolved_modifiers(true).await; + } + } else if let StickyKeyState::Pressed(mut active) = self.sticky_key_state + && active.source == event.pos + { + if active.deadline.is_none() { + active.deadline = deadline; + } + self.unregister_key(key, event); + self.send_keyboard_report_with_resolved_modifiers(false).await; + self.sticky_key_state = StickyKeyState::Latched(active); + } + } + + /// Foreign-key hook for the pure-mod shape, mirroring the former `update_osm`. + /// Called from `process_action_key` for every basic key. Drives the OSM-style + /// phase transitions on the terminating key and returns `true` when the latch was + /// consumed (so the caller can emit a quick-release report). + /// + /// Tap-key shape is untouched here — it is consumed elsewhere. + /// + /// Called only from `process_action_key` (basic keys), so a bare `Action::Modifier` + /// no longer consumes a latched OSL the way the former `update_osl` did from the + /// modifier path — only a non-modifier key, a layer change, or timeout consumes it. + /// This narrowing is intentional (a held modifier is not a "terminating key") and + /// matches how tap-key SKs already ignore bare modifiers. + pub(crate) fn update_sticky_key(&mut self, event: KeyboardEvent) -> bool { + if !self.sticky_key_state.is_pure_mod() && !self.sticky_key_state.is_layer() { + return false; + } + let mode = self + .sticky_key_state + .profile() + .zip(self.sticky_key_state.shape()) + .and_then(|(index, shape)| self.keymap.sticky_key_profile(index, shape).release_mode); + match self.sticky_key_state { + StickyKeyState::Pressed(mut active) => { + active.deadline = None; + self.sticky_key_state = StickyKeyState::Held(active); + false + } + StickyKeyState::Latched(active) => { + let release_on_press = + event.pressed && mode.is_some_and(|mode| mode.contains(StickyKeyReleaseMode::OTHER_KEY_PRESS)); + let release_on_release = !event.pressed + && (mode.is_none() + || mode.is_some_and(|mode| mode.contains(StickyKeyReleaseMode::OTHER_KEY_RELEASE))); + if !release_on_press && !release_on_release { + return false; + } + + if let ActiveEffect::Layer(layer) = active.effect { + self.keymap.deactivate_layer(layer); + self.sticky_key_state = StickyKeyState::None; + release_on_press + } else { + self.sticky_key_state = StickyKeyState::None; + true + } + } + StickyKeyState::None | StickyKeyState::Held(_) => false, + } + } + + /// Release a StickyKey whose timeout has elapsed. + /// + /// A physical key release must still be able to observe the active state, so a timeout that + /// fires while the key is held only clears its deadline. Explicit cleanup (for a replacement + /// key or layer change) uses `release_sticky_key_if_active` and must not be deferred. + pub(crate) async fn release_sticky_key_if_active_on_timeout(&mut self) { + if !self.sticky_key_state.is_active() { + return; + } + + // If the SK is still physically held, the deadline fired but the + // key hasn't been released yet. Don't clear the latch — the physical release + // handler (process_sticky_*) will transition Held→None cleanly. For pure-mod, + // the deadline was set on press (→ Held on any other key press), so this can + // only happen when the key is held and idle. For layer and tap-key shapes, the + // deadline fires in the same scenario. + // Clear the deadline to avoid busy-looping on every iteration. + if let StickyKeyState::Pressed(active) = &mut self.sticky_key_state { + debug!( + "StickyKey timeout fired while key is still held — clearing deadline, deferring to physical release" + ); + active.deadline = None; + return; + } + + self.release_sticky_key_if_active().await; + } + + pub(crate) async fn release_sticky_key_if_active(&mut self) { + if !self.sticky_key_state.is_active() { + return; + } + + debug!("Releasing StickyKey"); + + // Decide whether the release needs its own HID report. A report is only meaningful + // when the sticky modifier was actually visible in the last report: + // - tap-key shape: the modifier is always live between presses → always report. + // - pure-mod shape: only when promoted to Held, or when `activate_on_keypress` + // emitted the modifier early. A bare Latched pure-mod that times out before any + // key (and without early activation) never emitted the modifier, so releasing it + // must NOT produce a spurious empty report. Mirrors the former OSM timeout path. + // - layer shape: deactivating a layer emits nothing → never report. + let needs_report = if self.sticky_key_state.is_pure_mod() { + let activate_on_keypress = self.sticky_key_state.profile().is_some_and(|index| { + self.keymap + .sticky_key_profile(index, StickyKeyShape::PureMod) + .activate_on_keypress + }); + self.sticky_key_state.is_held() || activate_on_keypress + } else { + // tap-key shape always reports; layer shape never does (deactivating emits nothing). + !self.sticky_key_state.is_layer() + }; + + // A tap-key may still have its HID key registered when it is displaced by a different + // StickyKey while physically held. Unregister it before clearing the latch so it cannot + // remain stuck in the report. + if let Some(ActiveStickyKey { + effect: ActiveEffect::TapKey(hid_key), + source, + .. + }) = self.sticky_key_state.active().copied() + { + self.unregister_key( + hid_key, + KeyboardEvent { + pressed: false, + pos: source, + }, + ); + } + + // For the layer shape, deactivate the active layer before clearing the latch. + if let Some(ActiveStickyKey { + effect: ActiveEffect::Layer(layer_num), + .. + }) = self.sticky_key_state.active().copied() + { + self.keymap.deactivate_layer(layer_num); + } + + self.sticky_key_state = StickyKeyState::None; + if needs_report { + self.send_keyboard_report_with_resolved_modifiers(false).await; + } + } +} diff --git a/rmk/src/keymap.rs b/rmk/src/keymap.rs index c42d013dd..539ed039d 100644 --- a/rmk/src/keymap.rs +++ b/rmk/src/keymap.rs @@ -11,7 +11,7 @@ use { }; use crate::MACRO_SPACE_SIZE; -use crate::config::{BehaviorConfig, Hand, MouseKeyConfig, OneShotModifiersConfig, PositionalConfig}; +use crate::config::{BehaviorConfig, Hand, MouseKeyConfig, PositionalConfig, StickyKeyProfile, StickyKeyReleaseMode}; use crate::event::{KeyboardEvent, KeyboardEventPos, LayerChangeEvent, publish_event}; use crate::input_device::rotary_encoder::Direction; use crate::keyboard::combo::Combo; @@ -21,6 +21,13 @@ use crate::matrix::MatrixState; pub(crate) const HOLD_BUFFER_SIZE: usize = 16; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum StickyKeyShape { + PureMod, + TapKey, + Layer, +} + /// All allocated data needed to build a [`KeyMap`]. pub struct KeymapData { /// Per-layer key actions @@ -142,15 +149,19 @@ impl KeyMapInner<'_> { self.behavior.default_layer } - fn set_default_layer(&mut self, layer_num: u8) { + fn set_default_layer(&mut self, layer_num: u8) -> bool { if layer_num as usize >= self.num_layer { warn!( "Not a valid default layer {}, keyboard supports only {} layers", layer_num, self.num_layer ); - return; + return false; + } + if self.behavior.default_layer == layer_num { + return false; } self.behavior.default_layer = layer_num; + true } fn get_action_at(&self, pos: KeyboardEventPos, layer_num: usize) -> KeyAction { @@ -293,40 +304,50 @@ impl KeyMapInner<'_> { publish_event(LayerChangeEvent::new(layer)); } - fn activate_layer(&mut self, layer_num: u8) { + fn activate_layer(&mut self, layer_num: u8) -> bool { if layer_num as usize >= self.num_layer { warn!( "Not a valid layer {}, keyboard supports only {} layers", layer_num, self.num_layer ); - return; + return false; + } + if self.layer_state[layer_num as usize] { + return false; } self.layer_state[layer_num as usize] = true; self.update_tri_layer(); + true } - fn deactivate_layer(&mut self, layer_num: u8) { + fn deactivate_layer(&mut self, layer_num: u8) -> bool { if layer_num as usize >= self.num_layer { warn!( "Not a valid layer {}, keyboard supports only {} layers", layer_num, self.num_layer ); - return; + return false; + } + if !self.layer_state[layer_num as usize] { + return false; } self.layer_state[layer_num as usize] = false; self.update_tri_layer(); + true } - fn toggle_layer(&mut self, layer_num: u8) { + fn toggle_layer(&mut self, layer_num: u8) -> Option { if layer_num as usize >= self.num_layer { warn!( "Not a valid layer {}, keyboard supports only {} layers", layer_num, self.num_layer ); - return; + return None; } self.layer_state[layer_num as usize] = !self.layer_state[layer_num as usize]; + let active = self.layer_state[layer_num as usize]; self.update_tri_layer(); + Some(active) } } @@ -458,16 +479,17 @@ impl<'a> KeyMap<'a> { // ── Layers ── - pub(crate) fn activate_layer(&self, layer_num: u8) { - self.inner.borrow_mut().activate_layer(layer_num); + pub(crate) fn activate_layer(&self, layer_num: u8) -> bool { + self.inner.borrow_mut().activate_layer(layer_num) } - pub(crate) fn deactivate_layer(&self, layer_num: u8) { - self.inner.borrow_mut().deactivate_layer(layer_num); + pub(crate) fn deactivate_layer(&self, layer_num: u8) -> bool { + self.inner.borrow_mut().deactivate_layer(layer_num) } - pub(crate) fn toggle_layer(&self, layer_num: u8) { - self.inner.borrow_mut().toggle_layer(layer_num); + /// Toggle a valid layer, returning its new active state. + pub(crate) fn toggle_layer(&self, layer_num: u8) -> Option { + self.inner.borrow_mut().toggle_layer(layer_num) } /// Activate `layer_num` only if it is currently inactive. @@ -491,14 +513,15 @@ impl<'a> KeyMap<'a> { /// deactivates when the layer is currently active. Skips the /// `update_tri_layer` call (which would publish a `LayerChangeEvent`) when /// the layer is already inactive, avoiding a redundant event publish. - pub(crate) fn deactivate_layer_if_active(&self, layer_num: u8) { + pub(crate) fn deactivate_layer_if_active(&self, layer_num: u8) -> bool { let mut inner = self.inner.borrow_mut(); let idx = layer_num as usize; if idx >= inner.num_layer || !inner.layer_state[idx] { - return; + return false; } inner.layer_state[idx] = false; inner.update_tri_layer(); + true } pub(crate) fn auto_mouse_layer_configs( @@ -529,8 +552,8 @@ impl<'a> KeyMap<'a> { self.inner.borrow().get_default_layer() } - pub(crate) fn set_default_layer(&self, layer_num: u8) { - self.inner.borrow_mut().set_default_layer(layer_num); + pub(crate) fn set_default_layer(&self, layer_num: u8) -> bool { + self.inner.borrow_mut().set_default_layer(layer_num) } pub(crate) fn update_fn_layer_state(&self) { @@ -563,12 +586,47 @@ impl<'a> KeyMap<'a> { self.inner.borrow().behavior.combo.prior_idle_time } - pub(crate) fn one_shot_timeout(&self) -> Duration { - self.inner.borrow().behavior.one_shot.timeout + pub(crate) fn sticky_key_timeout(&self) -> Duration { + self.inner.borrow().behavior.sticky_key.default_profile.timeout } - pub(crate) fn one_shot_modifiers_config(&self) -> OneShotModifiersConfig { - self.inner.borrow().behavior.one_shot_modifiers + pub(crate) fn sticky_key_profile(&self, index: u8, shape: StickyKeyShape) -> StickyKeyProfile { + let config = &self.inner.borrow().behavior.sticky_key; + if let Some(profile) = config.profiles.get(index as usize) { + return *profile; + } + let mut profile = config.default_profile; + // Keep the resolved default profile canonical. The remaining fields are + // a compatibility shim for Rust callers using the legacy struct-update + // API: only non-default legacy values override the canonical profile. + if config.timeout != Duration::from_secs(1) { + profile.timeout = config.timeout; + } + if config.activate_on_keypress { + profile.activate_on_keypress = true; + } + if config.max_repeat != 0 { + profile.max_repeat = config.max_repeat; + } + if profile.release_mode.is_none() { + let mut mode = 0; + if shape == StickyKeyShape::PureMod && config.quick_release { + mode |= StickyKeyReleaseMode::OTHER_KEY_PRESS.into_bits(); + } + let layer_release = match shape { + StickyKeyShape::PureMod => config.one_shot_mod_release_on_layer_change, + StickyKeyShape::Layer => config.one_shot_layer_release_on_layer_change, + StickyKeyShape::TapKey => config.tap_key_release_on_layer_change, + } + .unwrap_or(config.release_on_layer_change); + if layer_release { + mode |= StickyKeyReleaseMode::LAYER_ENTER.into_bits() | StickyKeyReleaseMode::LAYER_EXIT.into_bits(); + } + if mode != 0 { + profile.release_mode = Some(StickyKeyReleaseMode::from_bits(mode)); + } + } + profile } pub(crate) fn tap_interval(&self) -> u16 { @@ -609,8 +667,12 @@ impl<'a> KeyMap<'a> { self.inner.borrow_mut().behavior.combo.timeout = timeout; } - pub(crate) fn set_one_shot_timeout(&self, timeout: Duration) { - self.inner.borrow_mut().behavior.one_shot.timeout = timeout; + pub(crate) fn set_sticky_key_timeout(&self, timeout: Duration) { + let mut inner = self.inner.borrow_mut(); + inner.behavior.sticky_key.default_profile.timeout = timeout; + // Keep the legacy Rust-API compatibility mirror synchronized so it + // cannot override a Vial runtime update during profile resolution. + inner.behavior.sticky_key.timeout = timeout; } pub(crate) fn set_tap_interval(&self, interval: u16) { @@ -785,11 +847,13 @@ impl<'a> KeyMap<'a> { #[cfg(test)] mod test { + use embassy_time::Duration; use rmk_types::fork::{Fork, StateBits}; use rmk_types::modifier::ModifierCombination; + use crate::config::{BehaviorConfig, PositionalConfig, StickyKeyProfile, StickyKeyReleaseMode}; use crate::keyboard::combo::{Combo, ComboConfig}; - use crate::keymap::fill_vec; + use crate::keymap::{KeyMap, KeymapData, StickyKeyShape, fill_vec}; use crate::{COMBO_MAX_NUM, FORK_MAX_NUM, k}; #[test] @@ -843,9 +907,6 @@ mod test { #[test] fn is_layer_active_reports_individual_layer_state() { - use crate::config::{BehaviorConfig, PositionalConfig}; - use crate::keymap::{KeyMap, KeymapData}; - let mut data = KeymapData::<1, 1, 4>::new([[[k!(A)]], [[k!(B)]], [[k!(C)]], [[k!(D)]]]); let mut behavior = BehaviorConfig::default(); let positional = PositionalConfig::<1, 1>::default(); @@ -863,16 +924,106 @@ mod test { assert!(!keymap.is_layer_active(3)); assert!(!keymap.activate_layer_if_inactive(2)); - keymap.deactivate_layer_if_active(2); + assert!(keymap.deactivate_layer_if_active(2)); assert!(!keymap.is_layer_active(2)); - keymap.deactivate_layer_if_active(2); + assert!(!keymap.deactivate_layer_if_active(2)); assert!(!keymap.is_layer_active(2)); // Mirrors the auto-mouse Either3::Third guard. assert!(keymap.activate_layer_if_inactive(2)); let self_activated = true; assert!(!(self_activated && !keymap.is_layer_active(2))); - keymap.deactivate_layer_if_active(2); + assert!(keymap.deactivate_layer_if_active(2)); assert!(self_activated && !keymap.is_layer_active(2)); } + + #[test] + fn layer_mutations_report_only_actual_transitions() { + let mut data = KeymapData::<1, 1, 2>::new([[[k!(A)]], [[k!(B)]]]); + let mut behavior = BehaviorConfig::default(); + let positional = PositionalConfig::<1, 1>::default(); + let keymap = KeyMap::build(&mut data, &mut behavior, &positional); + + assert!(keymap.activate_layer(1)); + assert!(!keymap.activate_layer(1)); + assert_eq!(keymap.toggle_layer(1), Some(false)); + assert_eq!(keymap.toggle_layer(1), Some(true)); + assert_eq!(keymap.toggle_layer(9), None); + assert!(keymap.set_default_layer(1)); + assert!(!keymap.set_default_layer(1)); + assert!(!keymap.set_default_layer(9)); + } + + #[test] + fn canonical_default_profile_is_not_overwritten_by_legacy_defaults() { + let mut data = KeymapData::<1, 1, 1>::new([[[k!(A)]]]); + let mut behavior = BehaviorConfig::default(); + behavior.sticky_key.default_profile.timeout = Duration::from_millis(275); + behavior.sticky_key.default_profile.max_repeat = 3; + let positional = PositionalConfig::<1, 1>::default(); + let keymap = KeyMap::build(&mut data, &mut behavior, &positional); + + let profile = keymap.sticky_key_profile(u8::MAX, StickyKeyShape::TapKey); + assert_eq!(profile.timeout, Duration::from_millis(275)); + assert_eq!(profile.max_repeat, 3); + } + + #[test] + fn runtime_timeout_update_changes_the_canonical_default_profile() { + let mut data = KeymapData::<1, 1, 1>::new([[[k!(A)]]]); + let mut behavior = BehaviorConfig::default(); + behavior.sticky_key.timeout = Duration::from_millis(50); + let positional = PositionalConfig::<1, 1>::default(); + let keymap = KeyMap::build(&mut data, &mut behavior, &positional); + + keymap.set_sticky_key_timeout(Duration::from_millis(640)); + + assert_eq!(keymap.sticky_key_timeout(), Duration::from_millis(640)); + assert_eq!( + keymap.sticky_key_profile(u8::MAX, StickyKeyShape::PureMod).timeout, + Duration::from_millis(640) + ); + } + + #[test] + fn named_profiles_ignore_legacy_default_overrides() { + let mut data = KeymapData::<1, 1, 1>::new([[[k!(A)]]]); + let mut behavior = BehaviorConfig::default(); + behavior.sticky_key.timeout = Duration::from_millis(50); + behavior.sticky_key.quick_release = true; + behavior + .sticky_key + .profiles + .push(StickyKeyProfile { + timeout: Duration::from_millis(900), + activate_on_keypress: false, + max_repeat: 4, + release_mode: Some(StickyKeyReleaseMode::OTHER_KEY_RELEASE), + }) + .unwrap(); + let positional = PositionalConfig::<1, 1>::default(); + let keymap = KeyMap::build(&mut data, &mut behavior, &positional); + + let profile = keymap.sticky_key_profile(0, StickyKeyShape::PureMod); + assert_eq!(profile.timeout, Duration::from_millis(900)); + assert_eq!(profile.max_repeat, 4); + assert_eq!(profile.release_mode, Some(StickyKeyReleaseMode::OTHER_KEY_RELEASE)); + } + + #[test] + fn legacy_release_overrides_are_shape_specific() { + let mut data = KeymapData::<1, 1, 1>::new([[[k!(A)]]]); + let mut behavior = BehaviorConfig::default(); + behavior.sticky_key.one_shot_mod_release_on_layer_change = Some(true); + behavior.sticky_key.tap_key_release_on_layer_change = Some(false); + let positional = PositionalConfig::<1, 1>::default(); + let keymap = KeyMap::build(&mut data, &mut behavior, &positional); + + let pure_mod = keymap.sticky_key_profile(u8::MAX, StickyKeyShape::PureMod); + let tap_key = keymap.sticky_key_profile(u8::MAX, StickyKeyShape::TapKey); + assert!(pure_mod.release_mode.is_some_and(|mode| { + mode.contains(StickyKeyReleaseMode::LAYER_ENTER) && mode.contains(StickyKeyReleaseMode::LAYER_EXIT) + })); + assert_eq!(tap_key.release_mode, None); + } } diff --git a/rmk/src/layout_macro.rs b/rmk/src/layout_macro.rs index d75e5a3cf..e15ad280a 100644 --- a/rmk/src/layout_macro.rs +++ b/rmk/src/layout_macro.rs @@ -311,47 +311,111 @@ macro_rules! thp { }; } -/// Create a one-shot layer action. -/// -/// This macro creates a key that activates a layer for the next keypress only. -/// After the next key is pressed, the layer automatically deactivates. +/// Create a StickyKey tap-key action (alt-tab shape). /// /// # Parameters -/// - `$x`: Layer number (0-255) +/// - `$key`: HID keycode identifier (e.g., `Tab`, `A`) +/// - `$keep`: `ModifierCombination` held between presses /// /// # Example /// ```ignore -/// osl!(1) // Next key will be from layer 1, then return to current layer -/// osl!(2) // Next key will be from layer 2, then return to current layer +/// sk!(Tab, ModifierCombination::LALT) // SK(Tab, [LAlt]) /// ``` #[macro_export] -macro_rules! osl { - ($x: literal) => { - $crate::types::action::KeyAction::Single($crate::types::action::Action::OneShotLayer($x)) +macro_rules! sk { + ($key:ident, $keep:expr) => { + $crate::sk!($key, $keep, ::core::primitive::u8::MAX) + }; + ($key:ident, $keep:expr, $profile:expr) => { + $crate::types::action::KeyAction::Single($crate::types::action::Action::StickyKey( + $crate::types::action::StickyKeyAction { + effect: $crate::types::action::StickyKeyEffect::TapKey { + key: $crate::types::keycode::HidKeyCode::$key, + modifiers: $keep, + }, + profile: $profile, + }, + )) }; } -/// Create a one-shot modifier action. -/// -/// This macro creates a key that applies modifiers for the next keypress only. -/// They automatically deactivate if: -/// - other key that sends keyboard report is pressed, -/// - timeout has passed before next key is triggered. +/// Create a StickyKey pure-modifier action (one-shot modifier shape). /// /// # Parameters /// - `$m`: `ModifierCombination` to apply for the next keypress /// /// # Example /// ```ignore -/// // Next key will be shifted -/// osm!(ModifierCombination::LSHIFT) -/// // Next key will have both Shift and Ctrl applied -/// osm!(ModifierCombination::LSHIFT | ModifierCombination::LCTRL) +/// sk_mod!(ModifierCombination::LSHIFT) // SK(LShift) +/// ``` +#[macro_export] +macro_rules! sk_mod { + ($m:expr) => { + $crate::sk_mod!($m, ::core::primitive::u8::MAX) + }; + ($m:expr, $profile:expr) => { + $crate::types::action::KeyAction::Single($crate::types::action::Action::StickyKey( + $crate::types::action::StickyKeyAction { + effect: $crate::types::action::StickyKeyEffect::Modifier($m), + profile: $profile, + }, + )) + }; +} + +/// Create a StickyKey layer action (one-shot layer shape). +/// +/// # Parameters +/// - `$n`: Layer number (0-255) +/// +/// # Example +/// ```ignore +/// sk_layer!(1) // SK(MO(1)) +/// ``` +#[macro_export] +macro_rules! sk_layer { + ($n:literal) => { + $crate::sk_layer!($n, ::core::primitive::u8::MAX) + }; + ($n:literal, $profile:expr) => { + $crate::types::action::KeyAction::Single($crate::types::action::Action::StickyKey( + $crate::types::action::StickyKeyAction { + effect: $crate::types::action::StickyKeyEffect::Layer($n), + profile: $profile, + }, + )) + }; +} + +/// Create a one-shot modifier action (alias for `sk_mod!`). +/// +/// # Example +/// ```ignore +/// osm!(ModifierCombination::LSHIFT) // equivalent to sk_mod!(ModifierCombination::LSHIFT) /// ``` #[macro_export] macro_rules! osm { - ($m: expr) => { - $crate::types::action::KeyAction::Single($crate::types::action::Action::OneShotModifier($m)) + ($m:expr) => { + $crate::sk_mod!($m) + }; + ($m:expr, $profile:expr) => { + $crate::sk_mod!($m, $profile) + }; +} + +/// Create a one-shot layer action (alias for `sk_layer!`). +/// +/// # Example +/// ```ignore +/// osl!(1) // equivalent to sk_layer!(1) +/// ``` +#[macro_export] +macro_rules! osl { + ($n:literal) => { + $crate::sk_layer!($n) + }; + ($n:literal, $profile:expr) => { + $crate::sk_layer!($n, $profile) }; } diff --git a/rmk/src/storage/mod.rs b/rmk/src/storage/mod.rs index fa2e669d3..973884d91 100644 --- a/rmk/src/storage/mod.rs +++ b/rmk/src/storage/mod.rs @@ -141,7 +141,7 @@ pub(crate) enum FlashOperationMessage { ConnectionType(ConnectionType), // Timeout time for combos ComboTimeout(u16), - // Timeout time for one-shot keys + // Timeout time for sticky keys (variant name kept for storage-format stability) OneShotTimeout(u16), // Interval for tap actions TapInterval(u16), @@ -306,8 +306,8 @@ pub(crate) struct BehaviorConfig { // Timeout time for combos pub(crate) combo_timeout: u16, - // Timeout time for one-shot keys - pub(crate) one_shot_timeout: u16, + // Timeout time for sticky (one-shot) keys + pub(crate) sticky_key_timeout: u16, // Interval for tap actions pub(crate) tap_interval: u16, // Interval for tapping capslock. @@ -334,7 +334,7 @@ impl From<&config::BehaviorConfig> for StorageData { prior_idle_time: behavior.morse.prior_idle_time.as_millis() as u16, morse_default_profile: behavior.morse.default_profile, combo_timeout: behavior.combo.timeout.as_millis() as u16, - one_shot_timeout: behavior.one_shot.timeout.as_millis() as u16, + sticky_key_timeout: behavior.sticky_key.default_profile.timeout.as_millis() as u16, tap_interval: behavior.tap.tap_interval, tap_capslock_interval: behavior.tap.tap_capslock_interval, }) @@ -513,7 +513,9 @@ impl { update_storage_field!(&mut self.flash, &mut self.buffer, BehaviorConfig, combo_timeout) } - FlashOperationMessage::OneShotTimeout(one_shot_timeout) => { - update_storage_field!(&mut self.flash, &mut self.buffer, BehaviorConfig, one_shot_timeout) + FlashOperationMessage::OneShotTimeout(sticky_key_timeout) => { + update_storage_field!(&mut self.flash, &mut self.buffer, BehaviorConfig, sticky_key_timeout) } FlashOperationMessage::TapInterval(tap_interval) => { update_storage_field!(&mut self.flash, &mut self.buffer, BehaviorConfig, tap_interval) diff --git a/rmk/tests/keyboard_combo_test.rs b/rmk/tests/keyboard_combo_test.rs index 306e6b9e9..3660e7cba 100644 --- a/rmk/tests/keyboard_combo_test.rs +++ b/rmk/tests/keyboard_combo_test.rs @@ -3,9 +3,7 @@ pub mod common; use embassy_futures::select::{Either, select}; use embassy_time::{Duration, Instant, Timer}; use rmk::channel::USB_REPORT_CHANNEL; -use rmk::config::{ - BehaviorConfig, CombosConfig, MorsesConfig, OneShotConfig, OneShotModifiersConfig, PositionalConfig, -}; +use rmk::config::{BehaviorConfig, CombosConfig, MorsesConfig, PositionalConfig, StickyKeyConfig}; use rmk::core_traits::Runnable; use rmk::event::{AsyncEventPublisher, AsyncPublishableEvent, KeyboardEvent}; use rmk::hid::Report; @@ -16,7 +14,7 @@ use rmk::types::action::KeyAction; use rmk::types::connection::UsbState; use rmk::types::keycode::HidKeyCode; use rmk::types::modifier::ModifierCombination; -use rmk::{a, k, layer, osm, th, wm}; +use rmk::{a, k, layer, sk_mod, th, wm}; use rmk_types::morse::{MorseMode, MorseProfile}; use crate::common::test_block_on::test_block_on; @@ -51,7 +49,7 @@ pub fn get_combos_config() -> CombosConfig { k!(T), //1,5 ] .to_vec(), - osm!(ModifierCombination::new_from(false, false, false, true, false)), // one-shot LShift + sk_mod!(ModifierCombination::new_from(false, false, false, true, false)), // one-shot LShift Some(0), ))), Some(Combo::new(ComboConfig::new( @@ -162,7 +160,7 @@ fn test_combo_with_one_shot_modifier() { key_sequence_test! { keyboard: create_test_keyboard_with_config(BehaviorConfig { combo: get_combos_config(), - one_shot: OneShotConfig { + sticky_key: StickyKeyConfig { timeout: Duration::from_millis(300), ..Default::default() }, @@ -467,11 +465,8 @@ fn test_combo_with_one_shot_modifier_quick_release() { key_sequence_test! { keyboard: create_test_keyboard_with_config(BehaviorConfig { combo: get_combos_config(), - one_shot: OneShotConfig { + sticky_key: StickyKeyConfig { timeout: Duration::from_millis(300), - ..Default::default() - }, - one_shot_modifiers: OneShotModifiersConfig { quick_release: true, ..Default::default() }, @@ -498,7 +493,7 @@ fn test_overlapped_combo_quick_release() { key_sequence_test! { keyboard: create_test_keyboard_with_config(BehaviorConfig { combo: get_combos_config(), - one_shot_modifiers: OneShotModifiersConfig { + sticky_key: StickyKeyConfig { quick_release: true, ..Default::default() }, diff --git a/rmk/tests/keyboard_one_shot_test.rs b/rmk/tests/keyboard_one_shot_test.rs index b169d1381..f22d55a2d 100644 --- a/rmk/tests/keyboard_one_shot_test.rs +++ b/rmk/tests/keyboard_one_shot_test.rs @@ -1,14 +1,14 @@ pub mod common; use embassy_time::Duration; -use rmk::config::{BehaviorConfig, OneShotModifiersConfig}; +use rmk::config::{BehaviorConfig, StickyKeyConfig}; use rmk::types::modifier::ModifierCombination; mod one_shot_test { - use rmk::config::{OneShotConfig, PositionalConfig}; + use rmk::config::PositionalConfig; use rmk::keyboard::Keyboard; use rmk::types::action::KeyAction; - use rmk::{k, osl, osm, th, wm}; + use rmk::{k, sk_layer, sk_mod, th, wm}; use super::*; use crate::common::{KC_LCTRL, KC_LGUI, KC_LSHIFT, wrap_keymap}; @@ -20,21 +20,21 @@ mod one_shot_test { const KEYMAP: [[[KeyAction; 6]; 1]; 2] = [ [[ // Layer 0 - osm!(ModifierCombination::new_from(false, false, false, true, false)), // OSM LShift - osl!(1), // OSL Layer 1 - k!(A), // Regular key A - th!(B, C), // Tap-hold key B, C - osm!(ModifierCombination::new_from(false, false, false, false, true)), // OSM LCtrl - wm!(B, ModifierCombination::new_from(false, true, false, false, false)), // WM B with LGUI + sk_mod!(ModifierCombination::new_from(false, false, false, true, false)), // OSM LShift + sk_layer!(1), // OSL Layer 1 + k!(A), // Regular key A + th!(B, C), // Tap-hold key B, C + sk_mod!(ModifierCombination::new_from(false, false, false, false, true)), // OSM LCtrl + wm!(B, ModifierCombination::new_from(false, true, false, false, false)), // WM B with LGUI ]], [[ // Layer 1 - osm!(ModifierCombination::new_from(false, false, false, true, true)), // OSM LShift + LCtrl - k!(No), // No action - k!(C), // Layer 1 key C - k!(D), // Layer 1 key D - k!(E), // Layer 1 key E - k!(F), // Layer 1 key F + sk_mod!(ModifierCombination::new_from(false, false, false, true, true)), // OSM LShift + LCtrl + k!(No), // No action + k!(C), // Layer 1 key C + k!(D), // Layer 1 key D + k!(E), // Layer 1 key E + k!(F), // Layer 1 key F ]], ]; @@ -50,9 +50,9 @@ mod one_shot_test { Keyboard::new(wrap_keymap(KEYMAP, per_key_config, behavior_config)) } - fn create_test_keyboard_with_one_shot_modifiers_config(config: OneShotModifiersConfig) -> Keyboard<'static> { + fn create_test_keyboard_with_sticky_key_config(config: StickyKeyConfig) -> Keyboard<'static> { let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig { - one_shot_modifiers: config, + sticky_key: config, ..BehaviorConfig::default() })); let per_key_config: &'static PositionalConfig<1, 6> = Box::leak(Box::new(PositionalConfig::default())); @@ -109,9 +109,9 @@ mod one_shot_test { key_sequence_test! { keyboard: create_test_keyboard_with_behavior_config( BehaviorConfig { - one_shot: OneShotConfig { + sticky_key: StickyKeyConfig { timeout: Duration::from_millis(100), - ..OneShotConfig::default() + ..StickyKeyConfig::default() }, ..BehaviorConfig::default() } @@ -328,9 +328,9 @@ mod one_shot_test { #[test] fn test_osm_activate_on_keypress() { key_sequence_test! { - keyboard: create_test_keyboard_with_one_shot_modifiers_config(OneShotModifiersConfig { + keyboard: create_test_keyboard_with_sticky_key_config(StickyKeyConfig { activate_on_keypress: true, - ..OneShotModifiersConfig::default() + ..StickyKeyConfig::default() }), sequence: [ [0, 0, true, 10], // Press OSM LShift @@ -365,9 +365,9 @@ mod one_shot_test { #[test] fn test_osm_combined_modifiers_with_activate_on_keypress() { key_sequence_test! { - keyboard: create_test_keyboard_with_one_shot_modifiers_config(OneShotModifiersConfig { + keyboard: create_test_keyboard_with_sticky_key_config(StickyKeyConfig { activate_on_keypress: true, - ..OneShotModifiersConfig::default() + ..StickyKeyConfig::default() }), sequence: [ // Press and Release OSM LShift @@ -429,12 +429,9 @@ mod one_shot_test { key_sequence_test! { keyboard: create_test_keyboard_with_behavior_config( BehaviorConfig { - one_shot: OneShotConfig { + sticky_key: StickyKeyConfig { timeout: Duration::from_millis(100), - ..OneShotConfig::default() - }, - one_shot_modifiers: OneShotModifiersConfig { - ..OneShotModifiersConfig::default() + ..StickyKeyConfig::default() }, ..BehaviorConfig::default() } @@ -486,7 +483,7 @@ mod one_shot_test { [0, 2, false, 10], // Release key ], expected_reports: [ - [0, [kc_to_u8!(C), 0, 0, 0, 0, 0]], // C from layer 1 with LShift + [KC_LSHIFT, [kc_to_u8!(C), 0, 0, 0, 0, 0]], // C from layer 1 with LShift [0, [0, 0, 0, 0, 0, 0]], // All released ] }; @@ -499,13 +496,13 @@ mod one_shot_test { sequence: [ [0, 1, true, 10], // Press OSL Layer 1 [0, 1, false, 10], // Release OSL Layer 1 - [0, 0, true, 10], // Press OSM LShift (from layer 1, but No action) - [0, 0, false, 10], // Release OSM LShift (gets from layer 0 due to transparent) - [0, 2, true, 10], // Press key at (0,2), should get A from layer 0 with shift + ctrl + [0, 0, true, 10], // Press OSM LShift|LCtrl from layer 1 (layer 1 latched by previous OSL) + [0, 0, false, 10], // Release OSM LShift|LCtrl + [0, 2, true, 10], // Press key at (0,2), should get C from layer 1 with shift + ctrl [0, 2, false, 10], // Release key ], expected_reports: [ - [KC_LSHIFT | KC_LCTRL, [kc_to_u8!(A), 0, 0, 0, 0, 0]], // A from layer 0 with shift + ctrl + [KC_LSHIFT | KC_LCTRL, [kc_to_u8!(C), 0, 0, 0, 0, 0]], // C from layer 1 with LShift+LCtrl [0, [0, 0, 0, 0, 0, 0]], // All released ] }; @@ -516,12 +513,9 @@ mod one_shot_test { key_sequence_test! { keyboard: create_test_keyboard_with_behavior_config( BehaviorConfig { - one_shot: OneShotConfig { + sticky_key: StickyKeyConfig { timeout: Duration::from_millis(100), - ..OneShotConfig::default() - }, - one_shot_modifiers: OneShotModifiersConfig { - ..OneShotModifiersConfig::default() + ..StickyKeyConfig::default() }, ..BehaviorConfig::default() } @@ -545,9 +539,9 @@ mod one_shot_test { #[test] fn test_osm_chain_mode_basic() { key_sequence_test! { - keyboard: create_test_keyboard_with_one_shot_modifiers_config(OneShotModifiersConfig { + keyboard: create_test_keyboard_with_sticky_key_config(StickyKeyConfig { quick_release: false, - ..OneShotModifiersConfig::default() + ..StickyKeyConfig::default() }), sequence: [ [0, 0, true, 10], // Press OSM LShift @@ -566,9 +560,9 @@ mod one_shot_test { #[test] fn test_osm_chain_mode_multiple_keys() { key_sequence_test! { - keyboard: create_test_keyboard_with_one_shot_modifiers_config(OneShotModifiersConfig { + keyboard: create_test_keyboard_with_sticky_key_config(StickyKeyConfig { quick_release: false, - ..OneShotModifiersConfig::default() + ..StickyKeyConfig::default() }), sequence: [ [0, 0, true, 10], // Press OSM LShift @@ -591,10 +585,10 @@ mod one_shot_test { #[test] fn test_osm_chain_mode_activate_on_keypress() { key_sequence_test! { - keyboard: create_test_keyboard_with_one_shot_modifiers_config(OneShotModifiersConfig { + keyboard: create_test_keyboard_with_sticky_key_config(StickyKeyConfig { activate_on_keypress: true, quick_release: false, - ..OneShotModifiersConfig::default() + ..StickyKeyConfig::default() }), sequence: [ [0, 0, true, 10], // Press OSM LShift @@ -615,9 +609,9 @@ mod one_shot_test { #[test] fn test_osm_quick_release_basic() { key_sequence_test! { - keyboard: create_test_keyboard_with_one_shot_modifiers_config(OneShotModifiersConfig { + keyboard: create_test_keyboard_with_sticky_key_config(StickyKeyConfig { quick_release: true, - ..OneShotModifiersConfig::default() + ..StickyKeyConfig::default() }), sequence: [ [0, 0, true, 10], // Press OSM LShift @@ -636,9 +630,9 @@ mod one_shot_test { #[test] fn test_osm_quick_release_multiple_keys() { key_sequence_test! { - keyboard: create_test_keyboard_with_one_shot_modifiers_config(OneShotModifiersConfig { + keyboard: create_test_keyboard_with_sticky_key_config(StickyKeyConfig { quick_release: true, - ..OneShotModifiersConfig::default() + ..StickyKeyConfig::default() }), sequence: [ [0, 0, true, 10], // Press OSM LShift @@ -664,9 +658,9 @@ mod one_shot_test { #[test] fn test_osm_quick_release_combined_modifiers() { key_sequence_test! { - keyboard: create_test_keyboard_with_one_shot_modifiers_config(OneShotModifiersConfig { + keyboard: create_test_keyboard_with_sticky_key_config(StickyKeyConfig { quick_release: true, - ..OneShotModifiersConfig::default() + ..StickyKeyConfig::default() }), sequence: [ [0, 0, true, 10], // Press OSM LShift @@ -687,9 +681,9 @@ mod one_shot_test { #[test] fn test_osm_quick_release_with_wm() { key_sequence_test! { - keyboard: create_test_keyboard_with_one_shot_modifiers_config(OneShotModifiersConfig { + keyboard: create_test_keyboard_with_sticky_key_config(StickyKeyConfig { quick_release: true, - ..OneShotModifiersConfig::default() + ..StickyKeyConfig::default() }), sequence: [ [0, 0, true, 10], // Press OSM LShift @@ -710,10 +704,10 @@ mod one_shot_test { #[test] fn test_osm_quick_release_activate_on_keypress() { key_sequence_test! { - keyboard: create_test_keyboard_with_one_shot_modifiers_config(OneShotModifiersConfig { + keyboard: create_test_keyboard_with_sticky_key_config(StickyKeyConfig { activate_on_keypress: true, quick_release: true, - ..OneShotModifiersConfig::default() + ..StickyKeyConfig::default() }), sequence: [ [0, 0, true, 10], // Press OSM LShift @@ -733,10 +727,10 @@ mod one_shot_test { #[test] fn test_osm_quick_release_combined_activate_on_keypress() { key_sequence_test! { - keyboard: create_test_keyboard_with_one_shot_modifiers_config(OneShotModifiersConfig { + keyboard: create_test_keyboard_with_sticky_key_config(StickyKeyConfig { activate_on_keypress: true, quick_release: true, - ..OneShotModifiersConfig::default() + ..StickyKeyConfig::default() }), sequence: [ [0, 0, true, 10], // Press OSM LShift diff --git a/rmk/tests/keyboard_sticky_key_test.rs b/rmk/tests/keyboard_sticky_key_test.rs new file mode 100644 index 000000000..42c104941 --- /dev/null +++ b/rmk/tests/keyboard_sticky_key_test.rs @@ -0,0 +1,1220 @@ +pub mod common; + +use embassy_time::Duration; +use rmk::config::{BehaviorConfig, PositionalConfig, StickyKeyConfig, StickyKeyProfile, StickyKeyReleaseMode}; +use rmk::keyboard::Keyboard; +use rmk::types::action::KeyAction; +use rmk::types::modifier::ModifierCombination; +use rmk::{a, k, mo, sk, sk_layer, sk_mod}; + +use crate::common::{KC_LALT, KC_LCTRL, KC_LGUI, KC_LSHIFT, wrap_keymap}; + +// KEYMAP (release_on_layer_change=true is set in the helper config, not per-key) +// Layer 0: A B C MO(1) LShift No +// Layer 1: SK(Tab,LAlt) SK(Tab,LCtrl) SK(Tab,LCtrl|LShift) Transparent Transparent No + +const KEYMAP: [[[KeyAction; 6]; 1]; 2] = [ + [[ + // Layer 0 + k!(A), // col 0: A + k!(B), // col 1: B + k!(C), // col 2: C + mo!(1), // col 3: MO(1) — momentary layer + k!(LShift), // col 4: LShift + a!(No), // col 5: No + ]], + [[ + // Layer 1 + sk!(Tab, ModifierCombination::LALT), // col 0: SK(Tab, LAlt) + sk!(Tab, ModifierCombination::LCTRL), // col 1: SK(Tab, LCtrl) + sk!( + Tab, + ModifierCombination::new_from_vals(true, true, false, false, false, false, false, false) + ), // col 2: SK(Tab, LCtrl|LShift) + a!(Transparent), // col 3: Transparent + a!(Transparent), // col 4: Transparent → LShift + a!(No), // col 5: No + ]], +]; + +// KEYMAP_MAX_REPEAT: used with the max_repeat=2 helper config (max_repeat is global, not per-key) +const KEYMAP_MAX_REPEAT: [[[KeyAction; 6]; 1]; 2] = [ + [[k!(A), k!(B), k!(C), mo!(1), k!(LShift), a!(No)]], + [[ + sk!(Tab, ModifierCombination::LALT), // col 0 + sk!(Tab, ModifierCombination::LCTRL), // col 1 + sk!(Tab, ModifierCombination::LCTRL), // col 2 + a!(Transparent), + a!(Transparent), + a!(No), + ]], +]; + +// KEYMAP_NO_EXIT: used with the default helper config (release_on_layer_change=false → SK survives MO release) +const KEYMAP_NO_EXIT: [[[KeyAction; 6]; 1]; 2] = [ + [[k!(A), k!(B), k!(C), mo!(1), k!(LShift), a!(No)]], + [[ + sk!(Tab, ModifierCombination::LALT), // col 0 + sk!(Tab, ModifierCombination::LCTRL), // col 1 + sk!(Tab, ModifierCombination::LCTRL), // col 2 + a!(Transparent), + a!(Transparent), + a!(No), + ]], +]; + +// KEYMAP_PUREMOD: pure-mod SKs (OSM shape) on the base layer for the absorbed-OSM regressions. +// Layer 0: SK(LGui) SK(LCtrl) SK(LShift) P No No +const KEYMAP_PUREMOD: [[[KeyAction; 6]; 1]; 1] = [[[ + sk_mod!(ModifierCombination::LGUI), // col 0: pure-mod SK(LGui) + sk_mod!(ModifierCombination::LCTRL), // col 1: pure-mod SK(LCtrl) + sk_mod!(ModifierCombination::LSHIFT), // col 2: pure-mod SK(LShift) + k!(P), // col 3: P + a!(No), // col 4 + a!(No), // col 5 +]]]; + +fn create_test_keyboard_puremod() -> Keyboard<'static> { + let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig::default())); + let per_key_config: &'static PositionalConfig<1, 6> = Box::leak(Box::new(PositionalConfig::default())); + Keyboard::new(wrap_keymap(KEYMAP_PUREMOD, per_key_config, behavior_config)) +} + +fn sticky_key_config_with_release_mode(release_mode: StickyKeyReleaseMode) -> StickyKeyConfig { + StickyKeyConfig { + default_profile: StickyKeyProfile { + release_mode: Some(release_mode), + ..StickyKeyProfile::default() + }, + ..StickyKeyConfig::default() + } +} + +// KEYMAP_MIXED: all three SK shapes on layer 0, used to exercise the mutually-exclusive +// latch (pressing a different-shape SK while one is latched REPLACES it, never merges). +// Layer 0: SK(LGui) SK(Tab,LAlt) SK(MO(1)) P No No +// Layer 1: Trns Trns Trns Z No No +// (cols 0-2 fall through to layer 0 so the SKs stay pressable while layer 1 is +// latched; col 3 = Z is a detector — it only resolves when layer 1 leaked.) +const KEYMAP_MIXED: [[[KeyAction; 6]; 1]; 2] = [ + [[ + sk_mod!(ModifierCombination::LGUI), // col 0: pure-mod SK(LGui) + sk!(Tab, ModifierCombination::LALT), // col 1: tap-key SK(Tab, LAlt) + sk_layer!(1), // col 2: layer SK(MO(1)) + k!(P), // col 3: P (layer-0 terminating key) + a!(No), // col 4 + a!(No), // col 5 + ]], + [[ + a!(Transparent), // col 0 → layer-0 SK(LGui) + a!(Transparent), // col 1 → layer-0 SK(Tab, LAlt) + a!(Transparent), // col 2 → layer-0 SK(MO(1)) + k!(Z), // col 3: Z — detector for a leaked layer 1 + a!(No), // col 4 + a!(No), // col 5 + ]], +]; + +fn create_test_keyboard_mixed() -> Keyboard<'static> { + let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig::default())); + let per_key_config: &'static PositionalConfig<1, 6> = Box::leak(Box::new(PositionalConfig::default())); + Keyboard::new(wrap_keymap(KEYMAP_MIXED, per_key_config, behavior_config)) +} + +// Layer-change policy keymaps. The transparent layer positions keep each action +// reachable while another layer is active, and the A/B/C keys reveal which +// sticky layer remains selected after a momentary layer change. +const KEYMAP_PURE_MOD_LAYER_CHANGE: [[[KeyAction; 3]; 1]; 2] = [ + [[sk_mod!(ModifierCombination::LSHIFT), mo!(1), k!(A)]], + [[a!(Transparent), a!(Transparent), a!(Transparent)]], +]; + +const KEYMAP_OSL_LAYER_CHANGE: [[[KeyAction; 3]; 1]; 3] = [ + [[sk_layer!(1), mo!(2), k!(A)]], + [[a!(Transparent), a!(Transparent), k!(B)]], + [[a!(Transparent), a!(Transparent), k!(C)]], +]; + +fn create_pure_mod_layer_change_keyboard(sticky_key: StickyKeyConfig) -> Keyboard<'static> { + let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig { + sticky_key, + ..BehaviorConfig::default() + })); + let per_key_config: &'static PositionalConfig<1, 3> = Box::leak(Box::new(PositionalConfig::default())); + Keyboard::new(wrap_keymap( + KEYMAP_PURE_MOD_LAYER_CHANGE, + per_key_config, + behavior_config, + )) +} + +fn create_osl_layer_change_keyboard(sticky_key: StickyKeyConfig) -> Keyboard<'static> { + let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig { + sticky_key, + ..BehaviorConfig::default() + })); + let per_key_config: &'static PositionalConfig<1, 3> = Box::leak(Box::new(PositionalConfig::default())); + Keyboard::new(wrap_keymap(KEYMAP_OSL_LAYER_CHANGE, per_key_config, behavior_config)) +} + +fn create_test_keyboard() -> Keyboard<'static> { + static BEHAVIOR_CONFIG: static_cell::StaticCell = static_cell::StaticCell::new(); + let behavior_config = BEHAVIOR_CONFIG.init(BehaviorConfig { + sticky_key: StickyKeyConfig { + release_on_layer_change: true, + ..StickyKeyConfig::default() + }, + ..BehaviorConfig::default() + }); + static KEY_CONFIG: static_cell::StaticCell> = static_cell::StaticCell::new(); + let per_key_config = KEY_CONFIG.init(PositionalConfig::default()); + Keyboard::new(wrap_keymap(KEYMAP, per_key_config, behavior_config)) +} + +fn create_test_keyboard_max_repeat() -> Keyboard<'static> { + let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig { + sticky_key: StickyKeyConfig { + max_repeat: 2, + ..StickyKeyConfig::default() + }, + ..BehaviorConfig::default() + })); + let per_key_config: &'static PositionalConfig<1, 6> = Box::leak(Box::new(PositionalConfig::default())); + Keyboard::new(wrap_keymap(KEYMAP_MAX_REPEAT, per_key_config, behavior_config)) +} + +fn create_test_keyboard_no_exit() -> Keyboard<'static> { + let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig::default())); + let per_key_config: &'static PositionalConfig<1, 6> = Box::leak(Box::new(PositionalConfig::default())); + Keyboard::new(wrap_keymap(KEYMAP_NO_EXIT, per_key_config, behavior_config)) +} + +fn create_test_keyboard_with_behavior_config(config: BehaviorConfig) -> Keyboard<'static> { + let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(config)); + let per_key_config: &'static PositionalConfig<1, 6> = Box::leak(Box::new(PositionalConfig::default())); + Keyboard::new(wrap_keymap(KEYMAP, per_key_config, behavior_config)) +} + +#[test] +fn sticky_key_config_reserves_the_bounded_profile_table() { + assert!(core::mem::size_of::() >= core::mem::size_of::()); +} + +/// A tap-key override can enable layer-change release while the global fallback is disabled. +#[test] +fn tap_key_layer_change_override_enables_release() { + key_sequence_test! { + keyboard: create_test_keyboard_with_behavior_config(BehaviorConfig { + sticky_key: StickyKeyConfig { + release_on_layer_change: false, + tap_key_release_on_layer_change: Some(true), + ..StickyKeyConfig::default() + }, + ..BehaviorConfig::default() + }), + sequence: [ + [0, 3, true, 10], + [0, 0, true, 10], + [0, 0, false, 10], + [0, 3, false, 10], + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], + [KC_LALT, [0, 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + ] + }; +} + +/// A tap-key override can disable layer-change release while the global fallback is enabled. +#[test] +fn tap_key_layer_change_override_disables_release() { + key_sequence_test! { + keyboard: create_test_keyboard_with_behavior_config(BehaviorConfig { + sticky_key: StickyKeyConfig { + release_on_layer_change: true, + tap_key_release_on_layer_change: Some(false), + ..StickyKeyConfig::default() + }, + ..BehaviorConfig::default() + }), + sequence: [ + [0, 3, true, 10], + [0, 0, true, 10], + [0, 0, false, 10], + [0, 3, false, 10], + [0, 0, true, 10], + [0, 0, false, 10], + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], + [KC_LALT, [0, 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + [0, [kc_to_u8!(A), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + ] + }; +} + +/// The one-shot-mod override wins over an enabled global fallback. +#[test] +fn pure_mod_layer_change_override_disables_release() { + key_sequence_test! { + keyboard: create_pure_mod_layer_change_keyboard(StickyKeyConfig { + release_on_layer_change: true, + one_shot_mod_release_on_layer_change: Some(false), + ..StickyKeyConfig::default() + }), + sequence: [ + [0, 0, true, 10], + [0, 0, false, 10], + [0, 1, true, 10], + [0, 1, false, 10], + [0, 2, true, 10], + [0, 2, false, 10], + ], + expected_reports: [ + [KC_LSHIFT, [kc_to_u8!(A), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + ] + }; +} + +/// The one-shot-mod override can enable release over a disabled global fallback. +#[test] +fn pure_mod_layer_change_override_enables_release() { + key_sequence_test! { + keyboard: create_pure_mod_layer_change_keyboard(StickyKeyConfig { + release_on_layer_change: false, + one_shot_mod_release_on_layer_change: Some(true), + ..StickyKeyConfig::default() + }), + sequence: [ + [0, 0, true, 10], + [0, 0, false, 10], + [0, 1, true, 10], + [0, 1, false, 10], + [0, 2, true, 10], + [0, 2, false, 10], + ], + expected_reports: [ + [0, [kc_to_u8!(A), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + ] + }; +} + +/// The layer-shape override wins over an enabled global fallback. +#[test] +fn osl_layer_change_override_disables_release() { + key_sequence_test! { + keyboard: create_osl_layer_change_keyboard(StickyKeyConfig { + release_on_layer_change: true, + one_shot_layer_release_on_layer_change: Some(false), + ..StickyKeyConfig::default() + }), + sequence: [ + [0, 0, true, 10], + [0, 0, false, 10], + [0, 1, true, 10], + [0, 1, false, 10], + [0, 2, true, 10], + [0, 2, false, 10], + ], + expected_reports: [ + [0, [kc_to_u8!(B), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + ] + }; +} + +/// The layer-shape override can enable release over a disabled global fallback. +#[test] +fn osl_layer_change_override_enables_release() { + key_sequence_test! { + keyboard: create_osl_layer_change_keyboard(StickyKeyConfig { + release_on_layer_change: false, + one_shot_layer_release_on_layer_change: Some(true), + ..StickyKeyConfig::default() + }), + sequence: [ + [0, 0, true, 10], + [0, 0, false, 10], + [0, 1, true, 10], + [0, 1, false, 10], + [0, 2, true, 10], + [0, 2, false, 10], + ], + expected_reports: [ + [0, [kc_to_u8!(A), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + ] + }; +} + +/// StickyKey Test 1: Basic SK flow — press SK twice while MO held +/// +/// Sequence: +/// - Press MO(1) → layer activates, no report +/// - Press SK(Tab,LAlt) → [KC_LALT, [Tab, ...]] +/// - Release SK → [KC_LALT, [0, ...]] (modifier held) +/// - Press SK again → [KC_LALT, [Tab, ...]] +/// - Release SK → [KC_LALT, [0, ...]] +/// - Release MO(1) → [0, [0, ...]] (layer deactivation cleans up SK) +#[test] +fn test_sk_basic_flow_press_twice() { + key_sequence_test! { + keyboard: create_test_keyboard(), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SK(Tab, LAlt) + [0, 0, false, 10], // Release SK + [0, 0, true, 10], // Press SK again + [0, 0, false, 10], // Release SK + [0, 3, false, 10], // Release MO(1) + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press again: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held + [0, [0, 0, 0, 0, 0, 0]], // MO release: SK cleaned up + ] + }; +} + +/// StickyKey Test 2: Layer change cleanup (exit_on_layer_change=true) +/// +/// Sequence: +/// - Press MO(1), press SK(Tab,LAlt), release SK, release MO(1) +/// +/// Expected: +/// - SK press: Alt+Tab +/// - SK release: Alt held +/// - MO release: cleans up SK (exit_on_layer_change=true), sends [0, [0,...]] +#[test] +fn test_sk_layer_change_cleanup() { + key_sequence_test! { + keyboard: create_test_keyboard(), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SK(Tab, LAlt) + [0, 0, false, 10], // Release SK + [0, 3, false, 10], // Release MO(1) → triggers SK cleanup (exit_on_layer_change=true) + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held + [0, [0, 0, 0, 0, 0, 0]], // MO release: SK cleaned up + ] + }; +} + +/// StickyKey Test 3: Shift does NOT release SK +/// +/// Sequence: +/// - Press MO(1), press SK(Tab,LCtrl), release SK +/// - Press LShift (col 4, transparent → LShift) — should NOT release SK +/// - Press SK again, release SK +/// - Release LShift, release MO(1) +/// +/// Expected: +/// - SK press: Ctrl+Tab +/// - SK release: Ctrl held +/// - Shift press: Ctrl+Shift held (SK not released) +/// - SK press: Ctrl+Shift+Tab +/// - SK release: Ctrl+Shift held +/// - Shift release: Ctrl held +/// - MO release: SK cleaned up +#[test] +fn test_sk_shift_does_not_release_sk() { + key_sequence_test! { + keyboard: create_test_keyboard(), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 1, true, 10], // Press SK(Tab, LCtrl) + [0, 1, false, 10], // Release SK + [0, 4, true, 10], // Press LShift (Transparent → LShift on L0) + [0, 1, true, 10], // Press SK again + [0, 1, false, 10], // Release SK + [0, 4, false, 10], // Release LShift + [0, 3, false, 10], // Release MO(1) + ], + expected_reports: [ + [KC_LCTRL, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Ctrl+Tab + [KC_LCTRL, [0, 0, 0, 0, 0, 0]], // SK release: Ctrl held + [KC_LCTRL | KC_LSHIFT, [0, 0, 0, 0, 0, 0]], // Shift press: Ctrl+Shift (SK not released) + [KC_LCTRL | KC_LSHIFT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Ctrl+Shift+Tab + [KC_LCTRL | KC_LSHIFT, [0, 0, 0, 0, 0, 0]], // SK release: Ctrl+Shift held + [KC_LCTRL, [0, 0, 0, 0, 0, 0]], // Shift release: Ctrl held + [0, [0, 0, 0, 0, 0, 0]], // MO release: SK cleaned up + ] + }; +} + +/// StickyKey Test 4: Rapid presses — 3x SK press/release while MO held +/// +/// Sequence: +/// - Press MO(1), then 3x (press SK, release SK), release MO(1) +/// +/// Expected: Each SK press sends Alt+Tab; each release holds Alt; MO release cleans up. +#[test] +fn test_sk_rapid_three_presses() { + key_sequence_test! { + keyboard: create_test_keyboard(), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SK #1 + [0, 0, false, 10], // Release SK #1 + [0, 0, true, 10], // Press SK #2 + [0, 0, false, 10], // Release SK #2 + [0, 0, true, 10], // Press SK #3 + [0, 0, false, 10], // Release SK #3 + [0, 3, false, 10], // Release MO(1) + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK #1 press + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK #1 release + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK #2 press + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK #2 release + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK #3 press + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK #3 release + [0, [0, 0, 0, 0, 0, 0]], // MO release: SK cleaned up + ] + }; +} + +/// StickyKey Test 5: Combined modifiers LCtrl|LShift +/// +/// Sequence: +/// - Press MO(1), press SK(Tab,LCtrl|LShift) at col 2, release SK, release MO(1) +/// +/// Expected: +/// - SK press: Ctrl+Shift+Tab +/// - SK release: Ctrl+Shift held +/// - MO release: SK cleaned up +#[test] +fn test_sk_combined_modifiers() { + key_sequence_test! { + keyboard: create_test_keyboard(), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 2, true, 10], // Press SK(Tab, LCtrl|LShift) + [0, 2, false, 10], // Release SK + [0, 3, false, 10], // Release MO(1) + ], + expected_reports: [ + [KC_LCTRL | KC_LSHIFT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Ctrl+Shift+Tab + [KC_LCTRL | KC_LSHIFT, [0, 0, 0, 0, 0, 0]], // SK release: Ctrl+Shift held + [0, [0, 0, 0, 0, 0, 0]], // MO release: SK cleaned up + ] + }; +} + +/// StickyKey Test 6: Timeout — modifier auto-releases after inactivity +/// +/// Config: global timeout = 100ms +/// +/// Sequence: +/// - Press MO(1), press SK(Tab,LAlt), release SK → timer starts (100ms) +/// - Wait 150ms → timer fires, Alt auto-released +/// - Release MO(1) (SK already inactive — no cleanup report) +/// - Press C on layer 0 (no modifier), release C +/// +/// Note: MO(1) must be released before pressing the verification key so that +/// col 2 resolves to k!(C) on layer 0 rather than SK(Tab,LCtrl|LShift) on layer 1. +#[test] +fn test_sk_timeout() { + key_sequence_test! { + keyboard: create_test_keyboard_with_behavior_config(BehaviorConfig { + sticky_key: StickyKeyConfig { + timeout: Duration::from_millis(100), + release_on_layer_change: true, + ..StickyKeyConfig::default() + }, + ..BehaviorConfig::default() + }), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SK(Tab, LAlt) + [0, 0, false, 10], // Release SK → timer starts (100ms) + [0, 3, false, 150], // Wait 150ms (timer fires!), then release MO(1) + [0, 2, true, 10], // Press C on layer 0 (no modifier) + [0, 2, false, 10], // Release C + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held, timer starts + [0, [0, 0, 0, 0, 0, 0]], // Timeout: Alt auto-released + // MO(1) release: SK already inactive, no report + [0, [kc_to_u8!(C), 0, 0, 0, 0, 0]], // C press: no modifier + [0, [0, 0, 0, 0, 0, 0]], // C release + ] + }; +} + +/// StickyKey Test 7: Timeout resets on each SK press +/// +/// Config: global timeout = 100ms +/// +/// Sequence: +/// - Press MO(1), press SK #1, release SK #1 → T1 starts (100ms) +/// - At 50ms: press SK #2 → T1 cancelled, SK #2 processed from unprocessed queue +/// - Release SK #2 → T2 starts (100ms reset) +/// - Wait 150ms → T2 fires, Alt auto-released +/// - Release MO(1) (SK already inactive — no cleanup report) +/// - Press C on layer 0 (no modifier), release C +#[test] +fn test_sk_timeout_resets_on_press() { + key_sequence_test! { + keyboard: create_test_keyboard_with_behavior_config(BehaviorConfig { + sticky_key: StickyKeyConfig { + timeout: Duration::from_millis(100), + release_on_layer_change: true, + ..StickyKeyConfig::default() + }, + ..BehaviorConfig::default() + }), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SK #1 + [0, 0, false, 10], // Release SK #1 → T1 starts (100ms) + [0, 0, true, 50], // At 50ms: press SK #2 → T1 cancelled + [0, 0, false, 10], // Release SK #2 → T2 starts (100ms reset) + [0, 3, false, 150], // Wait 150ms (T2 fires!), then release MO(1) + [0, 2, true, 10], // Press C on layer 0 (no modifier) + [0, 2, false, 10], // Release C + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK #1 press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK #1 release: Alt held (T1 starts) + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK #2 press: Alt+Tab (T1 cancelled) + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK #2 release: Alt held (T2 starts) + [0, [0, 0, 0, 0, 0, 0]], // T2 fires: Alt auto-released + // MO(1) release: SK already inactive, no report + [0, [kc_to_u8!(C), 0, 0, 0, 0, 0]], // C press: no modifier + [0, [0, 0, 0, 0, 0, 0]], // C release + ] + }; +} + +/// StickyKey Test 8: max_repeat — SK releases after N presses +/// +/// Config: KEYMAP_MAX_REPEAT, SK at col 0 has max_repeat=2 +/// +/// Sequence: +/// - Press MO(1), press SK ×3, release MO(1) +/// +/// Expected: +/// - Press 1: fire (Alt+Tab, Alt held) +/// - Press 2: fire (Alt+Tab, Alt held) — this is the max_repeat=2 press +/// - Press 3: max_repeat reached, SK deactivates silently (no new report beyond empty) +#[test] +fn test_sk_max_repeat() { + key_sequence_test! { + keyboard: create_test_keyboard_max_repeat(), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SK #1 + [0, 0, false, 10], // Release SK #1 + [0, 0, true, 10], // Press SK #2 + [0, 0, false, 10], // Release SK #2 + [0, 0, true, 10], // Press SK #3 → max_repeat reached, deactivate + [0, 0, false, 10], // Release SK #3 + [0, 3, false, 10], // Release MO(1) + [0, 0, true, 10], // Press A on layer 0 — SK deactivated, no modifier + [0, 0, false, 10], // Release A + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK #1 press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK #1 release: Alt held + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK #2 press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK #2 release: Alt held + [0, [0, 0, 0, 0, 0, 0]], // SK #3: max_repeat reached, SK deactivated + [0, [kc_to_u8!(A), 0, 0, 0, 0, 0]], // A press: no modifier (SK deactivated cleanly) + [0, [0, 0, 0, 0, 0, 0]], // A release + ] + }; +} + +// per-key timeout removed this round (deferred, spec Section 4); see parity catalogue + +/// StickyKey Test 10: exit_on_layer_change=true — SK exits on MO release +/// +/// This is the same as Test 2 — verifying the explicit exit_on_layer_change=true +/// setting (the default KEYMAP uses exit=true). +/// +/// Sequence: MO↓ SK(exit=true)↓ SK↑ MO↑ +/// Expected: Alt+Tab, Alt, empty. +#[test] +fn test_sk_exits_on_layer_change() { + key_sequence_test! { + keyboard: create_test_keyboard(), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SK(Tab, LAlt, exit=true) + [0, 0, false, 10], // Release SK + [0, 3, false, 10], // Release MO(1) → SK exits (exit_on_layer_change=true) + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held + [0, [0, 0, 0, 0, 0, 0]], // MO release: SK exits + ] + }; +} + +/// StickyKey Test 11: exit_on_layer_change=false — SK survives layer change +/// +/// Config: KEYMAP_NO_EXIT (exit_on_layer_change=false) +/// +/// Sequence: +/// - Press MO(1), press SK(exit=false), release SK +/// - Release MO(1) — SK does NOT exit (exit_on_layer_change=false) +/// - Press A on layer 0 — A press releases SK first, then sends A +/// - Release A +/// +/// Expected: +/// - SK press: Alt+Tab +/// - SK release: Alt held +/// - (MO release: no report — SK still active) +/// - A press: SK released first → [0, [0, ...]], then A registered → [0, [A, ...]] +/// - A release: [0, [0, ...]] +#[test] +fn test_sk_survives_layer_change() { + key_sequence_test! { + keyboard: create_test_keyboard_no_exit(), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SK(Tab, LAlt, exit=false) + [0, 0, false, 10], // Release SK + [0, 3, false, 10], // Release MO(1) — SK does NOT exit + [0, 0, true, 10], // Press A on layer 0 — releases SK, sends A + [0, 0, false, 10], // Release A + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held (SK still active after MO release) + [0, [0, 0, 0, 0, 0, 0]], // A press: SK release report (Alt released) + [0, [kc_to_u8!(A), 0, 0, 0, 0, 0]], // A press: A registered + [0, [0, 0, 0, 0, 0, 0]], // A release + ] + }; +} + +/// StickyKey Test 3b (regression): pure-mod SK applies its modifier THROUGH the +/// terminating key, then clears. Mirrors `test_osm_basic_single_behavior` via the +/// unified SK engine. Pins the absorbed OSM terminating-key behavior. +/// +/// Sequence: tap SK(LGui) (col 0), tap P (col 3) +/// Expected: P with LGui, then all released. +#[test] +fn test_sk_puremod_terminating_key() { + key_sequence_test! { + keyboard: create_test_keyboard_puremod(), + sequence: [ + [0, 0, true, 10], // Press SK(LGui) + [0, 0, false, 10], // Release SK(LGui) + [0, 3, true, 10], // Press P + [0, 3, false, 10], // Release P + ], + expected_reports: [ + [KC_LGUI, [kc_to_u8!(P), 0, 0, 0, 0, 0]], // P with LGui + [0, [0, 0, 0, 0, 0, 0]], // All released + ] + }; +} + +/// StickyKey Test 3c (regression): two pure-mod SK taps accumulate onto one +/// terminating key. Mirrors `test_osm_combined_modifiers` via the SK engine. +/// +/// Sequence: tap SK(LCtrl) (col 1), tap SK(LShift) (col 2), tap P (col 3) +/// Expected: P with LCtrl|LShift, then all released. +#[test] +fn test_sk_puremod_cross_tap_accumulation() { + key_sequence_test! { + keyboard: create_test_keyboard_puremod(), + sequence: [ + [0, 1, true, 10], // Press SK(LCtrl) + [0, 1, false, 10], // Release SK(LCtrl) + [0, 2, true, 10], // Press SK(LShift) + [0, 2, false, 10], // Release SK(LShift) + [0, 3, true, 10], // Press P + [0, 3, false, 10], // Release P + ], + expected_reports: [ + [KC_LCTRL | KC_LSHIFT, [kc_to_u8!(P), 0, 0, 0, 0, 0]], // P with LCtrl|LShift + [0, [0, 0, 0, 0, 0, 0]], // All released + ] + }; +} + +#[test] +fn pure_mod_double_tap_releases_latch() { + let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig { + sticky_key: sticky_key_config_with_release_mode(StickyKeyReleaseMode::DOUBLE_TAP), + ..BehaviorConfig::default() + })); + let per_key_config: &'static PositionalConfig<1, 6> = Box::leak(Box::new(PositionalConfig::default())); + + key_sequence_test! { + keyboard: Keyboard::new(wrap_keymap(KEYMAP_PUREMOD, per_key_config, behavior_config)), + sequence: [ + [0, 0, true, 0], + [0, 0, false, 0], + [0, 0, true, 0], + [0, 0, false, 0], + [0, 3, true, 0], + [0, 3, false, 0], + ], + expected_reports: [ + [0, [kc_to_u8!(P), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + ] + }; +} + +#[test] +fn sticky_layer_double_tap_deactivates_layer() { + key_sequence_test! { + keyboard: create_osl_layer_change_keyboard( + sticky_key_config_with_release_mode(StickyKeyReleaseMode::DOUBLE_TAP) + ), + sequence: [ + [0, 0, true, 0], + [0, 0, false, 0], + [0, 0, true, 0], + [0, 0, false, 0], + [0, 2, true, 0], + [0, 2, false, 0], + ], + expected_reports: [ + [0, [kc_to_u8!(A), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + ] + }; +} + +/// StickyKey Test 12 (regression): a tap-key SK pressed while a PURE-MOD SK is latched +/// REPLACES it — the latch is mutually exclusive, so the old modifier is dropped, not +/// merged. Without the replacement guard the tap-key press would OR the pure-mod's LGui +/// onto the report, yielding LGui+LAlt+Tab instead of just LAlt+Tab. +/// +/// Sequence: tap SK(LGui) (col 0), press/release SK(Tab,LAlt) (col 1) +/// Expected: LAlt+Tab (LGui dropped), then LAlt held. +#[test] +fn test_sk_tap_key_replaces_pure_mod() { + key_sequence_test! { + keyboard: create_test_keyboard_mixed(), + sequence: [ + [0, 0, true, 10], // Press SK(LGui) + [0, 0, false, 10], // Release SK(LGui) → pure-mod latched (no report) + [0, 1, true, 10], // Press SK(Tab, LAlt) → replaces pure-mod + [0, 1, false, 10], // Release SK + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // tap-key press: LAlt+Tab (LGui dropped) + [KC_LALT, [0, 0, 0, 0, 0, 0]], // tap-key release: LAlt held + ] + }; +} + +/// StickyKey Test 13 (regression): a pure-mod SK pressed while a TAP-KEY SK is latched +/// REPLACES it. The tap-key's held LAlt is released (its own report) and the next basic +/// key gets the new pure-mod's LGui applied through it — OSM terminating-key behavior — +/// not the stale LAlt. Without the guard the pure-mod's LGui would merge onto the tap-key +/// latch, leaving the shape as tap-key and applying LAlt+LGui. +/// +/// Sequence: press/release SK(Tab,LAlt) (col 1), tap SK(LGui) (col 0), tap P (col 3) +/// Expected: LAlt+Tab, LAlt held, LAlt released, LGui+P, all released. +#[test] +fn test_sk_pure_mod_replaces_tap_key() { + key_sequence_test! { + keyboard: create_test_keyboard_mixed(), + sequence: [ + [0, 1, true, 10], // Press SK(Tab, LAlt) + [0, 1, false, 10], // Release SK → tap-key latched (LAlt held) + [0, 0, true, 10], // Press SK(LGui) → replaces tap-key (drops LAlt) + [0, 0, false, 10], // Release SK(LGui) → pure-mod latched + [0, 3, true, 10], // Press P → LGui applied through it + [0, 3, false, 10], // Release P + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // tap-key press: LAlt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // tap-key release: LAlt held + [0, [0, 0, 0, 0, 0, 0]], // pure-mod press: tap-key released (LAlt dropped) + [KC_LGUI, [kc_to_u8!(P), 0, 0, 0, 0, 0]], // P with LGui (terminating key) + [0, [0, 0, 0, 0, 0, 0]], // P release: all clear + ] + }; +} + +/// StickyKey Test 14 (regression): a tap-key SK pressed while a LAYER SK is latched +/// REPLACES it — the orphaned-layer bug. The latched layer must be deactivated, so the +/// later basic key resolves on layer 0 (P), not the leaked layer 1 (Z). Without the guard +/// the tap-key press would bump the layer latch's repeat_count, leaving layer 1 active +/// forever and sending the key with no modifier. +/// +/// Sequence: press/release SK(MO(1)) (col 2), press/release SK(Tab,LAlt) (col 1), tap P (col 3) +/// Expected: LAlt+Tab, LAlt held, then P resolves on LAYER 0 (the tap-key early-releases +/// its LAlt before the foreign key, per the tap-key terminating-key rule, so P is sent +/// clean) — crucially P, not the leaked layer-1 Z. +#[test] +fn test_sk_tap_key_replaces_layer() { + key_sequence_test! { + keyboard: create_test_keyboard_mixed(), + sequence: [ + [0, 2, true, 10], // Press SK(MO(1)) → layer 1 active + [0, 2, false, 10], // Release SK → layer latched + [0, 1, true, 10], // Press SK(Tab, LAlt) (col 1 Trns → layer-0 tap-key) → replaces layer + [0, 1, false, 10], // Release SK → tap-key latched (LAlt held) + [0, 3, true, 10], // Press col 3 → resolves to P on layer 0 (layer 1 deactivated) + [0, 3, false, 10], // Release + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // tap-key press: LAlt+Tab (layer dropped, no report) + [KC_LALT, [0, 0, 0, 0, 0, 0]], // tap-key release: LAlt held + [0, [0, 0, 0, 0, 0, 0]], // P press: tap-key early-releases LAlt + [0, [kc_to_u8!(P), 0, 0, 0, 0, 0]], // P sent clean on layer 0 (NOT Z) — layer 1 gone + [0, [0, 0, 0, 0, 0, 0]], // P release + ] + }; +} + +/// A layer-shaped SK must release a physically held tap-key before replacing the +/// shared latch; otherwise the displaced tap key remains registered indefinitely. +#[test] +fn test_sk_layer_replaces_held_tap_key_without_sticking() { + key_sequence_test! { + keyboard: create_test_keyboard_mixed(), + sequence: [ + [0, 1, true, 0], // Press SK(Tab, LAlt) + [0, 2, true, 0], // Press SK(MO(1)) while Tab is still held → releases Tab first + [0, 1, false, 0], // Release displaced tap-key: must be ignored + [0, 2, false, 0], // Release layer SK → layer 1 is latched + [0, 3, true, 0], // Resolves to Z on the latched layer + [0, 3, false, 0], // Releases Z and consumes the layer latch + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + [0, [kc_to_u8!(Z), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + ] + }; +} + +/// StickyKey Test 15: `activate_on_keypress` is IGNORED for tap-key SKs. +/// +/// Docs: `activate_on_keypress` is "honored only for pure-mod SKs" and is +/// "silently ignored for tap-key SKs". A tap-key already sends its modifier +/// eagerly on the first press, so the flag has nothing to tune. With +/// activate_on_keypress=true the report stream must be identical to the +/// default tap-key flow (cf. test_sk_basic_flow_press_twice). +#[test] +fn test_sk_tap_key_ignores_activate_on_keypress() { + key_sequence_test! { + keyboard: create_test_keyboard_with_behavior_config(BehaviorConfig { + sticky_key: StickyKeyConfig { + activate_on_keypress: true, // pure-mod-only knob — must be ignored here + release_on_layer_change: true, // match create_test_keyboard so MO release cleans up + ..StickyKeyConfig::default() + }, + ..BehaviorConfig::default() + }), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SK(Tab, LAlt) + [0, 0, false, 10], // Release SK + [0, 0, true, 10], // Press SK again + [0, 0, false, 10], // Release SK + [0, 3, false, 10], // Release MO(1) + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press again: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held + [0, [0, 0, 0, 0, 0, 0]], // MO release: SK cleaned up + ] + }; +} + +/// KEYMAP_PUREMOD_SK: pure-mod SK at col 4, basic keys at cols 0-2, for testing "timeout while held". +const KEYMAP_PUREMOD_SK: [[[KeyAction; 6]; 1]; 1] = [[[ + k!(A), // col 0: A + k!(B), // col 1: B + k!(C), // col 2: C + a!(No), // col 3: No + sk_mod!(ModifierCombination::LSHIFT), // col 4: SK(LShift) + a!(No), // col 5: No +]]]; + +fn create_test_keyboard_puremod_sk() -> Keyboard<'static> { + let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig { + sticky_key: StickyKeyConfig { + timeout: Duration::from_millis(10), + ..StickyKeyConfig::default() + }, + ..BehaviorConfig::default() + })); + let per_key_config: &'static PositionalConfig<1, 6> = Box::leak(Box::new(PositionalConfig::default())); + Keyboard::new(wrap_keymap(KEYMAP_PUREMOD_SK, per_key_config, behavior_config)) +} + +// KEYMAP_TAP_SK: tap-key SK at col 0 and a basic key at col 1, for testing a timeout while held. +const KEYMAP_TAP_SK: [[[KeyAction; 2]; 1]; 1] = [[[sk!(Tab, ModifierCombination::LALT), k!(A)]]]; + +const KEYMAP_PROFILED_TAP_SK: [[[KeyAction; 2]; 1]; 1] = [[[sk!(Tab, ModifierCombination::LALT, 0), k!(A)]]]; + +const KEYMAP_PROFILED_PURE_MODS: [[[KeyAction; 3]; 1]; 1] = [[[ + sk_mod!(ModifierCombination::LSHIFT, 0), + sk_mod!(ModifierCombination::LCTRL, 1), + k!(A), +]]]; + +fn create_test_keyboard_tap_sk() -> Keyboard<'static> { + let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig { + sticky_key: StickyKeyConfig { + timeout: Duration::from_millis(10), + ..StickyKeyConfig::default() + }, + ..BehaviorConfig::default() + })); + let per_key_config: &'static PositionalConfig<1, 2> = Box::leak(Box::new(PositionalConfig::default())); + Keyboard::new(wrap_keymap(KEYMAP_TAP_SK, per_key_config, behavior_config)) +} + +fn create_profiled_tap_sk_keyboard(profile: StickyKeyProfile) -> Keyboard<'static> { + let mut sticky_key = StickyKeyConfig::default(); + sticky_key.profiles.push(profile).unwrap(); + let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig { + sticky_key, + ..BehaviorConfig::default() + })); + let per_key_config: &'static PositionalConfig<1, 2> = Box::leak(Box::new(PositionalConfig::default())); + Keyboard::new(wrap_keymap(KEYMAP_PROFILED_TAP_SK, per_key_config, behavior_config)) +} + +#[test] +fn tap_key_other_key_release_keeps_modifier_through_release_report() { + key_sequence_test! { + keyboard: create_profiled_tap_sk_keyboard(StickyKeyProfile { + release_mode: Some(StickyKeyReleaseMode::OTHER_KEY_RELEASE), + ..StickyKeyProfile::default() + }), + sequence: [ + [0, 0, true, 0], + [0, 0, false, 0], + [0, 1, true, 0], + [0, 1, false, 0], + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], + [KC_LALT, [0, 0, 0, 0, 0, 0]], + [KC_LALT, [kc_to_u8!(A), 0, 0, 0, 0, 0]], + [KC_LALT, [0, 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + ] + }; +} + +#[test] +fn tap_key_double_tap_releases_instead_of_cycling() { + key_sequence_test! { + keyboard: create_profiled_tap_sk_keyboard(StickyKeyProfile { + release_mode: Some(StickyKeyReleaseMode::DOUBLE_TAP), + ..StickyKeyProfile::default() + }), + sequence: [ + [0, 0, true, 0], + [0, 0, false, 0], + [0, 0, true, 0], + [0, 0, false, 0], + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], + [KC_LALT, [0, 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + ] + }; +} + +#[test] +fn latest_accumulated_pure_mod_profile_owns_release_behavior() { + let mut sticky_key = StickyKeyConfig::default(); + sticky_key + .profiles + .push(StickyKeyProfile { + release_mode: Some(StickyKeyReleaseMode::OTHER_KEY_RELEASE), + ..StickyKeyProfile::default() + }) + .unwrap(); + sticky_key + .profiles + .push(StickyKeyProfile { + release_mode: Some(StickyKeyReleaseMode::OTHER_KEY_PRESS), + ..StickyKeyProfile::default() + }) + .unwrap(); + let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig { + sticky_key, + ..BehaviorConfig::default() + })); + let per_key_config: &'static PositionalConfig<1, 3> = Box::leak(Box::new(PositionalConfig::default())); + + key_sequence_test! { + keyboard: Keyboard::new(wrap_keymap( + KEYMAP_PROFILED_PURE_MODS, + per_key_config, + behavior_config, + )), + sequence: [ + [0, 0, true, 0], + [0, 0, false, 0], + [0, 1, true, 0], + [0, 1, false, 0], + [0, 2, true, 0], + [0, 2, false, 0], + ], + expected_reports: [ + [KC_LSHIFT | KC_LCTRL, [kc_to_u8!(A), 0, 0, 0, 0, 0]], + [0, [kc_to_u8!(A), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + ] + }; +} + +// KEYMAP_TWO_TAP_SK: two tap-key SKs for verifying that a second physical key replaces the +// first latch instead of reusing its key and modifiers. +const KEYMAP_TWO_TAP_SK: [[[KeyAction; 2]; 1]; 1] = [[[ + sk!(Tab, ModifierCombination::LALT), + sk!(Enter, ModifierCombination::LCTRL), +]]]; + +fn create_test_keyboard_two_tap_sk() -> Keyboard<'static> { + let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig::default())); + let per_key_config: &'static PositionalConfig<1, 2> = Box::leak(Box::new(PositionalConfig::default())); + Keyboard::new(wrap_keymap(KEYMAP_TWO_TAP_SK, per_key_config, behavior_config)) +} + +/// StickyKey Test 17: Timeout fires while SK is still physically held (Pressed phase). +/// +/// The guard in `release_sticky_key_if_active()` must prevent the latch from being +/// cleared. After release the SK transitions to Latched (deadline refreshed), so the +/// next key press still gets the modifier applied. Then the next key press after that +/// does NOT have the modifier (latched-only-for-one-key behavior). +/// +/// Sequence: +/// - Press SK(LShift) → Active(Pressed, deadline = t+10ms, no report since activate_on_keypress=false) +/// - Hold 20ms (past 10ms timeout) → timeout fires, guard clears deadline, returns; state stays +/// - Release SK → Pressed→Latched, deadline refreshed +/// - Press A → register A, modifier applied (pressed=true), report: LShift + A +/// - Release A → update_sticky_key consumes Latched (quick_release=false, !pressed), state→None +/// - Press B → no modifier, report: B only +/// - Release B +#[test] +fn test_sk_timeout_while_held() { + key_sequence_test! { + keyboard: create_test_keyboard_puremod_sk(), + sequence: [ + [0, 4, true, 0], // Press SK(LShift) — state=Pressed, deadline=t+10ms + [0, 4, false, 20], // Hold 20ms (>10ms timeout), then release + [0, 0, true, 0], // Press A + [0, 0, false, 0], // Release A + [0, 1, true, 0], // Press B + [0, 1, false, 0], // Release B + ], + expected_reports: [ + [KC_LSHIFT, [kc_to_u8!(A), 0, 0, 0, 0, 0]], // A press: LShift applied through terminating key + [0, [0, 0, 0, 0, 0, 0]], // A release: LShift consumed with the key + [0, [kc_to_u8!(B), 0, 0, 0, 0, 0]], // B press: no modifier (SK already consumed) + [0, [0, 0, 0, 0, 0, 0]], // B release + ] + }; +} + +/// StickyKey Test 17b: Timeout fires while a tap-key SK is still physically held. +/// +/// Tap-key SKs use the `Latched` phase while their physical key is down, so phase alone cannot +/// tell the timeout handler whether clearing the state is safe. The physical-press flag must keep +/// the state alive until release so that release unregisters Tab, retains the latched Alt, and +/// re-arms Alt's timeout. +#[test] +fn test_tap_sk_timeout_while_held() { + key_sequence_test! { + keyboard: create_test_keyboard_tap_sk(), + sequence: [ + [0, 0, true, 0], // Press SK(Tab, LAlt) + [0, 0, false, 20], // Hold past timeout, then release + [0, 1, true, 20], // Wait past the re-armed timeout, then press A + [0, 1, false, 0], + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], + [KC_LALT, [0, 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + [0, [kc_to_u8!(A), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + ] + }; +} + +/// StickyKey Test 17c: A second physical tap-key gets its own state. +/// +/// The second key replaces the first active latch, so it uses LCtrl+Enter rather than the +/// first key's LAlt+Tab state. Releasing the displaced first key must not affect the second. +#[test] +fn test_second_tap_sk_replaces_first_while_held() { + key_sequence_test! { + keyboard: create_test_keyboard_two_tap_sk(), + sequence: [ + [0, 0, true, 0], // Press SK(Tab, LAlt) + [0, 1, true, 0], // Press SK(Enter, LCtrl) while first is held + [0, 0, false, 0], // Release displaced first SK + [0, 1, false, 0], // Release active second SK + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + [KC_LCTRL, [kc_to_u8!(Enter), 0, 0, 0, 0, 0]], + [KC_LCTRL, [0, 0, 0, 0, 0, 0]], + ] + }; +} + +/// StickyKey Test 16: `quick_release` is IGNORED for tap-key SKs. +/// +/// Docs: `quick_release` is "honored only for pure-mod SKs" and is "silently +/// ignored for tap-key SKs". Its pure-mod semantics (release the modifier on +/// the next key *press*) have nothing to tune on a tap-key, which deliberately +/// holds its modifier across repeats. With quick_release=true the report stream +/// must be identical to the default tap-key flow (cf. test_sk_basic_flow_press_twice). +#[test] +fn test_sk_tap_key_ignores_quick_release() { + key_sequence_test! { + keyboard: create_test_keyboard_with_behavior_config(BehaviorConfig { + sticky_key: StickyKeyConfig { + quick_release: true, // pure-mod-only knob — must be ignored here + release_on_layer_change: true, // match create_test_keyboard so MO release cleans up + ..StickyKeyConfig::default() + }, + ..BehaviorConfig::default() + }), + sequence: [ + [0, 3, true, 10], // Press MO(1) + [0, 0, true, 10], // Press SK(Tab, LAlt) + [0, 0, false, 10], // Release SK + [0, 0, true, 10], // Press SK again + [0, 0, false, 10], // Release SK + [0, 3, false, 10], // Release MO(1) + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], // SK press again: Alt+Tab + [KC_LALT, [0, 0, 0, 0, 0, 0]], // SK release: Alt held + [0, [0, 0, 0, 0, 0, 0]], // MO release: SK cleaned up + ] + }; +} From 354b22e3262919ad965d13cebd4e45f68dcf3bdf Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Mon, 20 Jul 2026 07:11:39 -0500 Subject: [PATCH 103/119] refactor(sticky-key): align lifecycle with RMK patterns --- docs/docs/main/docs/configuration/behavior.md | 9 +- docs/docs/main/docs/configuration/event.md | 2 +- .../main/docs/configuration/rmk_config.md | 7 +- .../use_config/esp32_ble_split/keyboard.toml | 4 +- examples/use_config/esp32c3_ble/keyboard.toml | 4 +- examples/use_config/esp32c6_ble/keyboard.toml | 4 +- examples/use_config/esp32s3_ble/keyboard.toml | 4 +- .../use_config/nrf52832_ble/keyboard.toml | 4 +- .../nrf52840_ble_split/keyboard.toml | 2 +- .../keyboard.toml | 2 +- .../nrf52840_ble_split_dongle/keyboard.toml | 2 +- .../use_config/pi_pico_w_ble/keyboard.toml | 2 +- .../pi_pico_w_ble_split/keyboard.toml | 4 +- examples/use_config/rp2040/keyboard.toml | 2 +- .../rp2040_direct_pin/keyboard.toml | 4 +- examples/use_config/rp2040_oled/keyboard.toml | 2 +- .../use_config/rp2040_split/keyboard.toml | 2 +- .../use_config/rp2040_split_pio/keyboard.toml | 2 +- examples/use_config/stm32f1/keyboard.toml | 2 +- examples/use_config/stm32f4/keyboard.toml | 4 +- examples/use_config/stm32h7/keyboard.toml | 4 +- .../src/default_config/event_default.toml | 2 +- rmk-config/src/keymap.pest | 5 +- rmk-config/src/layout.rs | 126 +--- rmk-config/src/lib.rs | 44 +- rmk-config/src/resolved/behavior.rs | 5 +- rmk-config/src/resolved/build_constants.rs | 42 +- rmk-macro/src/codegen/action_parser.rs | 31 +- rmk-macro/src/codegen/behavior.rs | 56 +- rmk-types/src/protocol/rmk/mod.rs | 5 + rmk/src/config/behavior.rs | 61 +- rmk/src/config/mod.rs | 3 +- rmk/src/event/mod.rs | 2 +- rmk/src/event/state.rs | 17 +- rmk/src/keyboard.rs | 68 +- rmk/src/keyboard/auto_mouse_layer.rs | 12 +- rmk/src/keyboard/sticky_key.rs | 714 ++++++++++-------- rmk/src/keymap.rs | 85 +-- rmk/src/storage/mod.rs | 1 - rmk/tests/keyboard_combo_test.rs | 13 +- rmk/tests/keyboard_one_shot_test.rs | 64 +- rmk/tests/keyboard_sticky_key_test.rs | 202 ++++- 42 files changed, 946 insertions(+), 684 deletions(-) diff --git a/docs/docs/main/docs/configuration/behavior.md b/docs/docs/main/docs/configuration/behavior.md index 986e24f92..5ffffdccc 100644 --- a/docs/docs/main/docs/configuration/behavior.md +++ b/docs/docs/main/docs/configuration/behavior.md @@ -70,6 +70,11 @@ press with a release. Holding an `SK` key longer than the configured timeout will not synthesize a key release; releasing the physical key then completes the action normally. +Modifier and layer Sticky Keys have independent latches. They can be active at +the same time, retain their own profile, deadline, and release policy, and expire +without clearing each other. Tap-key Sticky Keys are exclusive because starting +a different tap-key sequence replaces the key/modifier pair being cycled. + For example, an Alt+Tab profile can release on another key press or either direction of a layer transition: @@ -117,7 +122,7 @@ For keymap usage, see `SK(...)` in the [keymap configuration](./layout#keyboard- ### Migration from OSM / OSL -`OSM(mod)` and `OSL(n)` are **still supported** as aliases — they desugar to `SK(mod)` and `SK(MO(n))` respectively, so existing keymaps keep working unchanged. The `SK` forms are the canonical spelling; use whichever you prefer. The old 5-positional `SK` form and the `[behavior.one_shot]` / `[behavior.one_shot_modifiers]` config tables, however, are **removed** — using them is a build error. +`OSM(mod)` and `OSL(n)` are **still supported** as aliases — they desugar to `SK(mod)` and `SK(MO(n))` respectively, so existing keymaps keep working unchanged. The `SK` forms are the canonical spelling; use whichever you prefer. The old 5-positional `SK` form and the `[behavior.one_shot]` / `[behavior.one_shot_modifiers]` TOML tables, however, are **removed** — using them in `keyboard.toml` is a build error. Rust configurations retain the legacy `BehaviorConfig::one_shot` and `BehaviorConfig::one_shot_modifiers` fields as a compatibility adapter; RMK normalizes them into the default Sticky Key profile once when the keymap is built. | Old | New (canonical) | Alias still accepted | |-----|-----------------|----------------------| @@ -131,7 +136,7 @@ For keymap usage, see `SK(...)` in the [keymap configuration](./layout#keyboard- Accepted breaking changes: - The old 5-positional `SK(key, [mod], max_repeat, timeout_ms, exit_on_layer_change)` form is **removed** → build error. The trailing knobs now live in `[behavior.sticky_key]`. -- The `[behavior.one_shot]` and `[behavior.one_shot_modifiers]` config tables are **removed** → use `[behavior.sticky_key]`. +- The `[behavior.one_shot]` and `[behavior.one_shot_modifiers]` TOML tables are **removed** → use `[behavior.sticky_key]`. The equivalent Rust `BehaviorConfig` fields remain source-compatible. - The former `quick_release` and layer-change settings are replaced by `release_mode`; use one or more of `other_key_press`, `other_key_release`, `layer_enter`, `layer_exit`, and `double_tap`. - Tap-key (alt-tab) SKs now have a **1s default timeout** (previously they had no timeout). Set `timeout` higher or rely on the default. diff --git a/docs/docs/main/docs/configuration/event.md b/docs/docs/main/docs/configuration/event.md index 8b5fd949e..6403e4ebf 100644 --- a/docs/docs/main/docs/configuration/event.md +++ b/docs/docs/main/docs/configuration/event.md @@ -55,7 +55,7 @@ peripheral_battery.subs = 4 | `pointing` | `PointingEvent` | channel_size=8 | | **State Events** | | | | `layer_change` | `LayerChangeEvent` | subs=4 | -| `sticky_key_release` | Internal Sticky Key event | channel_size=2 | +| `layer_transition` | Internal layer transition | channel_size=2 | | `wpm_update` | `WpmUpdateEvent` | | | `led_indicator` | `LedIndicatorEvent` | | | `sleep_state` | `SleepStateEvent` | | diff --git a/docs/docs/main/docs/configuration/rmk_config.md b/docs/docs/main/docs/configuration/rmk_config.md index d8e6d99a0..ce0ab69ca 100644 --- a/docs/docs/main/docs/configuration/rmk_config.md +++ b/docs/docs/main/docs/configuration/rmk_config.md @@ -18,8 +18,9 @@ combo_max_length = 4 fork_max_num = 8 # Maximum number of morse keys keyboard can store (max 256) morse_max_num = 8 -# Maximum number of named Sticky Key profiles (max 255) -sticky_key_profile_max_num = 16 +# Optional maximum number of named Sticky Key profiles (max 255). +# Omit this to derive the capacity from [behavior.sticky_key.profiles]. +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. @@ -61,7 +62,7 @@ Increasing the number of combos, forks, morses (tap dances), and macros will inc - `combo_max_length`: Maximum number of keys that can be pressed simultaneously in a combo, default value is 4. - `fork_max_num`: Maximum number of forks for conditional key actions, default value is 8. This value must be between 0 and 256. - `morse_max_num`: Maximum number of morses that can be stored, default value is 8. This value must be between 0 and 256. -- `sticky_key_profile_max_num`: Capacity of the named Sticky Key profile table, default value is 16. This value must be between 0 and 255. +- `sticky_key_profile_max_num`: Optional capacity override for the named Sticky Key profile table. When omitted, TOML configurations derive the capacity from the configured profile count; Rust-only configurations reserve 4 entries. Set it to `0` to opt out or to a larger value when profiles are added at runtime. 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. (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. diff --git a/examples/use_config/esp32_ble_split/keyboard.toml b/examples/use_config/esp32_ble_split/keyboard.toml index 3ddfb3691..8ef0e3db0 100644 --- a/examples/use_config/esp32_ble_split/keyboard.toml +++ b/examples/use_config/esp32_ble_split/keyboard.toml @@ -20,7 +20,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], @@ -68,4 +68,4 @@ col_pins = ["GPIO3"] [dependency] # Whether to enable defmt, set to false for reducing binary size -defmt_log = false \ No newline at end of file +defmt_log = false diff --git a/examples/use_config/esp32c3_ble/keyboard.toml b/examples/use_config/esp32c3_ble/keyboard.toml index 0ebe277fc..db899a6e3 100644 --- a/examples/use_config/esp32c3_ble/keyboard.toml +++ b/examples/use_config/esp32c3_ble/keyboard.toml @@ -24,7 +24,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], @@ -49,4 +49,4 @@ keymap = [ enabled = true [ble] -enabled = true \ No newline at end of file +enabled = true diff --git a/examples/use_config/esp32c6_ble/keyboard.toml b/examples/use_config/esp32c6_ble/keyboard.toml index 35b41a8e7..a1a42831f 100644 --- a/examples/use_config/esp32c6_ble/keyboard.toml +++ b/examples/use_config/esp32c6_ble/keyboard.toml @@ -24,7 +24,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], @@ -49,4 +49,4 @@ keymap = [ # enabled = false [ble] -enabled = true \ No newline at end of file +enabled = true diff --git a/examples/use_config/esp32s3_ble/keyboard.toml b/examples/use_config/esp32s3_ble/keyboard.toml index 4fd94bdc9..87471231c 100644 --- a/examples/use_config/esp32s3_ble/keyboard.toml +++ b/examples/use_config/esp32s3_ble/keyboard.toml @@ -23,7 +23,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], @@ -48,4 +48,4 @@ keymap = [ # enabled = false [ble] -enabled = true \ No newline at end of file +enabled = true diff --git a/examples/use_config/nrf52832_ble/keyboard.toml b/examples/use_config/nrf52832_ble/keyboard.toml index d1f3ec1b9..532ed34b3 100644 --- a/examples/use_config/nrf52832_ble/keyboard.toml +++ b/examples/use_config/nrf52832_ble/keyboard.toml @@ -23,7 +23,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], @@ -49,4 +49,4 @@ keymap = [ # enabled = false [ble] -enabled = true \ No newline at end of file +enabled = true diff --git a/examples/use_config/nrf52840_ble_split/keyboard.toml b/examples/use_config/nrf52840_ble_split/keyboard.toml index 1cf088a20..02a29c477 100644 --- a/examples/use_config/nrf52840_ble_split/keyboard.toml +++ b/examples/use_config/nrf52840_ble_split/keyboard.toml @@ -15,7 +15,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["TD(1)", "TT(1)", "TG(2)"], diff --git a/examples/use_config/nrf52840_ble_split_direct_pin/keyboard.toml b/examples/use_config/nrf52840_ble_split_direct_pin/keyboard.toml index 2d253a575..da49b93e3 100644 --- a/examples/use_config/nrf52840_ble_split_direct_pin/keyboard.toml +++ b/examples/use_config/nrf52840_ble_split_direct_pin/keyboard.toml @@ -15,7 +15,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], diff --git a/examples/use_config/nrf52840_ble_split_dongle/keyboard.toml b/examples/use_config/nrf52840_ble_split_dongle/keyboard.toml index f3dc5f900..97cf795a8 100644 --- a/examples/use_config/nrf52840_ble_split_dongle/keyboard.toml +++ b/examples/use_config/nrf52840_ble_split_dongle/keyboard.toml @@ -15,7 +15,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["TD(1)", "TT(1)", "TG(2)"], diff --git a/examples/use_config/pi_pico_w_ble/keyboard.toml b/examples/use_config/pi_pico_w_ble/keyboard.toml index de270815c..5cf21bddb 100644 --- a/examples/use_config/pi_pico_w_ble/keyboard.toml +++ b/examples/use_config/pi_pico_w_ble/keyboard.toml @@ -22,7 +22,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], diff --git a/examples/use_config/pi_pico_w_ble_split/keyboard.toml b/examples/use_config/pi_pico_w_ble_split/keyboard.toml index 9e032dc96..a8a8313ce 100644 --- a/examples/use_config/pi_pico_w_ble_split/keyboard.toml +++ b/examples/use_config/pi_pico_w_ble_split/keyboard.toml @@ -15,7 +15,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], @@ -56,4 +56,4 @@ ble_addr = [0x7e, 0xfe, 0x73, 0x9e, 0x66, 0xe3] [split.peripheral.matrix] matrix_type = "normal" row_pins = ["PIN_8", "PIN_9"] -col_pins = ["PIN_19"] \ No newline at end of file +col_pins = ["PIN_19"] diff --git a/examples/use_config/rp2040/keyboard.toml b/examples/use_config/rp2040/keyboard.toml index e0bae4c67..d24fb13ca 100644 --- a/examples/use_config/rp2040/keyboard.toml +++ b/examples/use_config/rp2040/keyboard.toml @@ -35,7 +35,7 @@ keymap = [ "LShift", ], [ - "SK(MO(1))", + "OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)", ], diff --git a/examples/use_config/rp2040_direct_pin/keyboard.toml b/examples/use_config/rp2040_direct_pin/keyboard.toml index 3c5ad1a59..ff8db86ef 100644 --- a/examples/use_config/rp2040_direct_pin/keyboard.toml +++ b/examples/use_config/rp2040_direct_pin/keyboard.toml @@ -25,7 +25,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "_", "_"], - ["SK(MO(1))", "LT(2, Kc9)", "_"] + ["OSL(1)", "LT(2, Kc9)", "_"] ], [ ["_", "TT(1)", "TG(2)"], @@ -49,4 +49,4 @@ keymap = [ [storage] # Storage feature is enabled by default -# enabled = false \ No newline at end of file +# enabled = false diff --git a/examples/use_config/rp2040_oled/keyboard.toml b/examples/use_config/rp2040_oled/keyboard.toml index 1986afd29..f77446524 100644 --- a/examples/use_config/rp2040_oled/keyboard.toml +++ b/examples/use_config/rp2040_oled/keyboard.toml @@ -35,7 +35,7 @@ keymap = [ "LShift", ], [ - "SK(MO(1))", + "OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)", ], diff --git a/examples/use_config/rp2040_split/keyboard.toml b/examples/use_config/rp2040_split/keyboard.toml index 3b5b8f235..e855a1179 100644 --- a/examples/use_config/rp2040_split/keyboard.toml +++ b/examples/use_config/rp2040_split/keyboard.toml @@ -15,7 +15,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], diff --git a/examples/use_config/rp2040_split_pio/keyboard.toml b/examples/use_config/rp2040_split_pio/keyboard.toml index 01c0cf675..b5d288caf 100644 --- a/examples/use_config/rp2040_split_pio/keyboard.toml +++ b/examples/use_config/rp2040_split_pio/keyboard.toml @@ -15,7 +15,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], diff --git a/examples/use_config/stm32f1/keyboard.toml b/examples/use_config/stm32f1/keyboard.toml index 2825fbed0..a23b4ce92 100644 --- a/examples/use_config/stm32f1/keyboard.toml +++ b/examples/use_config/stm32f1/keyboard.toml @@ -22,7 +22,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], diff --git a/examples/use_config/stm32f4/keyboard.toml b/examples/use_config/stm32f4/keyboard.toml index c43f59a50..68cabca07 100644 --- a/examples/use_config/stm32f4/keyboard.toml +++ b/examples/use_config/stm32f4/keyboard.toml @@ -22,7 +22,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], @@ -46,4 +46,4 @@ capslock.low_active = false [storage] # Storage feature is enabled by default -# enabled = false \ No newline at end of file +# enabled = false diff --git a/examples/use_config/stm32h7/keyboard.toml b/examples/use_config/stm32h7/keyboard.toml index 354bfa3b9..3167f6a25 100644 --- a/examples/use_config/stm32h7/keyboard.toml +++ b/examples/use_config/stm32h7/keyboard.toml @@ -22,7 +22,7 @@ keymap = [ ["A", "B", "C"], ["Kc1", "Kc2", "Kc3"], ["LCtrl", "MO(1)", "LShift"], - ["SK(MO(1))", "LT(2, Kc9)", "LM(1, LShift | LGui)"] + ["OSL(1)", "LT(2, Kc9)", "LM(1, LShift | LGui)"] ], [ ["_", "TT(1)", "TG(2)"], @@ -47,4 +47,4 @@ scrolllock.low_active = false enabled = false [dependency] -defmt_log = false \ No newline at end of file +defmt_log = false diff --git a/rmk-config/src/default_config/event_default.toml b/rmk-config/src/default_config/event_default.toml index 66e629fa3..577b5ee79 100644 --- a/rmk-config/src/default_config/event_default.toml +++ b/rmk-config/src/default_config/event_default.toml @@ -24,7 +24,7 @@ channel_size = 1 pubs = 2 subs = 1 -[event.sticky_key_release] +[event.layer_transition] channel_size = 2 pubs = 1 subs = 1 diff --git a/rmk-config/src/keymap.pest b/rmk-config/src/keymap.pest index 1a77d75dd..2852d2bb5 100644 --- a/rmk-config/src/keymap.pest +++ b/rmk-config/src/keymap.pest @@ -18,6 +18,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+ } @@ -97,7 +98,7 @@ layer_action = _{ // `keycode_name` fallback (not `simple_keycode`) keeps a lone `,` from being // accepted as a slot argument. nestable_action = _{ - wm_action | osm_action | shifted_action | trigger_macro_action | + wm_action | osm_action | shifted_action | trigger_macro_action | alias_ref | df_action | mo_action | lm_action | osl_action | tg_action | to_action | keycode_name } @@ -134,7 +135,7 @@ sk_action = { // 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 | osm_action | osl_action | layer_action | mt_action | th_action | shifted_action | morse_action | trigger_macro_action | sk_action | no_action | transparent_action | simple_keycode + wm_action | osm_action | osl_action | layer_action | mt_action | th_action | shifted_action | morse_action | trigger_macro_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/layout.rs b/rmk-config/src/layout.rs index 74b2440e5..65494d64b 100644 --- a/rmk-config/src/layout.rs +++ b/rmk-config/src/layout.rs @@ -244,103 +244,38 @@ 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; // Keep track of where we are in current_keys - - while let Some(at_index) = current_keys[last_index..].find('@') { - let start_index = last_index + at_index; - - // Append the text before the '@' - next_keys.push_str(¤t_keys[last_index..start_index]); - - // Check if it's a valid alias start (@ followed by a non whitespace) - if let Some(first_char) = current_keys.as_bytes().get(start_index + 1) { - if !first_char.is_ascii_whitespace() { - // Find the end of the alias identifier - let mut end_index = start_index + 2; - while let Some(c) = current_keys.as_bytes().get(end_index) { - if c.is_ascii_whitespace() { - break; - } else { - end_index += 1; - } - } - - // Extract the alias key (except the starting '@') - let alias_key = ¤t_keys[start_index + 1..end_index]; - - // Look up and replace - match aliases.get(alias_key) { - Some(value) => { - next_keys.push_str(value); - made_replacement = true; - } - None => { - // Sticky-key profiles use the same `@name` - // spelling as keymap aliases, but occur as the - // final argument of SK/OSM/OSL. Preserve that - // reference for the action parser instead of - // trying to resolve it as an alias. - let profile_end = current_keys[start_index + 1..] - .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) - .map(|offset| start_index + 1 + offset) - .unwrap_or(current_keys.len()); - let profile_name = ¤t_keys[start_index + 1..profile_end]; - let follows_comma = current_keys[..start_index].trim_end().ends_with(','); - let closes_action = current_keys[profile_end..].trim_start().starts_with(')'); - let valid_profile_name = profile_name - .as_bytes() - .first() - .is_some_and(|c| c.is_ascii_alphabetic() || *c == b'_'); - - if follows_comma && closes_action && valid_profile_name { - next_keys.push_str(¤t_keys[start_index..profile_end]); - last_index = profile_end; - continue; - } - - return Err(format!("Undefined alias: {}", alias_key)); - } - } - last_index = end_index; // Move past the processed alias - } else { - // Not a valid alias start, treat '@' literally - next_keys.push('@'); - last_index = start_index + 1; - } - } else { - // '@' was the last character, treat it literally - next_keys.push('@'); - last_index = start_index + 1; - break; // No more characters after '@' - } + for _ in 0..MAX_ALIAS_RESOLUTION_DEPTH { + let pairs = ConfigParser::parse(Rule::key_map, ¤t_keys) + .map_err(|error| format!("Invalid keymap format: {error}"))?; + let mut references = Vec::new(); + for pair in pairs { + Self::collect_alias_spans(pair, &mut references); } - - // Append any remaining part of the string after the last '@' or if no '@' was found - next_keys.push_str(¤t_keys[last_index..]); - - // Check for termination conditions - 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 - )); // Show original keys for context + if references.is_empty() { + return Ok(current_keys); } - if !made_replacement { - break; // No more replacements needed + for (start, end, name) in references.into_iter().rev() { + let value = aliases.get(&name).ok_or_else(|| format!("Undefined alias: {name}"))?; + current_keys.replace_range(start..end, value); } - - // Prepare for the next iteration - current_keys = next_keys; } - Ok(current_keys) + Err(format!( + "Alias resolution exceeded maximum depth ({}), potential infinite loop detected in '{}'", + MAX_ALIAS_RESOLUTION_DEPTH, keys + )) + } + + fn collect_alias_spans(pair: pest::iterators::Pair, out: &mut Vec<(usize, usize, String)>) { + if pair.as_rule() == Rule::alias_ref { + let span = pair.as_span(); + out.push((span.start(), span.end(), pair.as_str()[1..].to_string())); + return; + } + for inner in pair.into_inner() { + Self::collect_alias_spans(inner, out); + } } /// Reconstruct an action string from a parsed pair, resolving every named @@ -859,13 +794,16 @@ mod tests { #[test] fn test_keymap_aliases_still_resolve_next_to_sticky_profile_refs() { - let aliases = HashMap::from([("copy".to_string(), "WM(C, LCtrl)".to_string())]); + let aliases = HashMap::from([ + ("copy".to_string(), "WM(C, LCtrl)".to_string()), + ("osm".to_string(), "A".to_string()), + ]); let layer_names = HashMap::new(); - let keymap = "@copy SK(LGui, @osm)"; + let keymap = "@copy @osm SK(LGui, @osm)"; let result = KeyboardTomlConfig::keymap_parser(keymap, &aliases, &layer_names); assert!(result.is_ok()); - assert_eq!(result.unwrap(), vec!["WM(C, LCtrl)", "SK(LGui, @osm)"]); + assert_eq!(result.unwrap(), vec!["WM(C, LCtrl)", "A", "SK(LGui, @osm)"]); } } diff --git a/rmk-config/src/lib.rs b/rmk-config/src/lib.rs index f9ea80613..d3d508957 100644 --- a/rmk-config/src/lib.rs +++ b/rmk-config/src/lib.rs @@ -204,8 +204,29 @@ 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() + } } +/// Small fallback capacity for users who configure Sticky Keys directly in Rust. +/// +/// TOML configurations derive their required capacity from the number of named +/// profiles, while `[rmk] sticky_key_profile_max_num` remains an explicit +/// override (including `0` to opt out). +const DEFAULT_STICKY_KEY_PROFILE_MAX_NUM: usize = 4; + /// Keyboard constants configuration for performance and hardware limits #[serde_inline_default] #[derive(Clone, Debug, Deserialize)] @@ -233,9 +254,11 @@ pub(crate) struct RmkConstantsConfig { #[serde(deserialize_with = "check_morse_max_num")] pub morse_max_num: usize, /// Capacity of the named Sticky Key profile table (maximum 255). - #[serde_inline_default(16)] - #[serde(deserialize_with = "check_sticky_key_profile_max_num")] - pub sticky_key_profile_max_num: usize, + /// + /// When omitted, this is derived from the configured profile count with a + /// small fallback for Rust-only configurations. + #[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")] @@ -299,13 +322,16 @@ where Ok(value) } -fn check_sticky_key_profile_max_num<'de, D>(deserializer: D) -> Result +fn check_sticky_key_profile_max_num<'de, D>(deserializer: D) -> Result, D::Error> where D: de::Deserializer<'de>, { - let value = Deserialize::deserialize(deserializer)?; - if value > 255 { - panic!("❌ Parse `keyboard.toml` error: sticky_key_profile_max_num must be between 0 and 255, got {value}"); + let value = Option::::deserialize(deserializer)?; + if value.is_some_and(|value| value > 255) { + panic!( + "❌ Parse `keyboard.toml` error: sticky_key_profile_max_num must be between 0 and 255, got {}", + value.unwrap() + ); } Ok(value) } @@ -342,7 +368,7 @@ impl Default for RmkConstantsConfig { combo_max_length: 4, fork_max_num: 8, morse_max_num: 8, - sticky_key_profile_max_num: 16, + sticky_key_profile_max_num: None, max_patterns_per_key: 8, macro_space_size: 256, debounce_time: 20, @@ -418,7 +444,7 @@ define_event_config!( keyboard, // Keyboard state events layer_change, - sticky_key_release, + layer_transition, wpm_update, led_indicator, sleep_state, diff --git a/rmk-config/src/resolved/behavior.rs b/rmk-config/src/resolved/behavior.rs index d4bd1f91b..381e41746 100644 --- a/rmk-config/src/resolved/behavior.rs +++ b/rmk-config/src/resolved/behavior.rs @@ -282,10 +282,11 @@ impl crate::KeyboardTomlConfig { .transpose()?, }) }; - if s.profiles.len() > self.rmk.sticky_key_profile_max_num { + let sticky_key_profile_max_num = self.sticky_key_profile_capacity(); + if s.profiles.len() > sticky_key_profile_max_num { return Err(format!( "behavior.sticky_key.profiles defines {} profiles, but `[rmk] sticky_key_profile_max_num` is {}. Raise it in keyboard.toml", - s.profiles.len(), self.rmk.sticky_key_profile_max_num + s.profiles.len(), sticky_key_profile_max_num )); } let profiles = s.profiles.into_iter() diff --git a/rmk-config/src/resolved/build_constants.rs b/rmk-config/src/resolved/build_constants.rs index fe1a44be7..6e8850afd 100644 --- a/rmk-config/src/resolved/build_constants.rs +++ b/rmk-config/src/resolved/build_constants.rs @@ -101,7 +101,7 @@ impl crate::KeyboardTomlConfig { modifier, keyboard, layer_change, - sticky_key_release, + layer_transition, wpm_update, led_indicator, sleep_state, @@ -162,6 +162,13 @@ 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); + 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 > 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 let Some(entries) = self.behavior.as_ref().and_then(|b| b.auto_mouse_layer.as_ref()) { if entries.len() > auto_mouse_layer_max_num { return Err(format!( @@ -185,7 +192,7 @@ impl crate::KeyboardTomlConfig { combo_max_length: rmk.combo_max_length, fork_max_num: rmk.fork_max_num, morse_max_num: rmk.morse_max_num, - sticky_key_profile_max_num: rmk.sticky_key_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, @@ -297,6 +304,37 @@ mod tests { assert!(parse(toml).build_constants(&[]).is_ok()); } + #[test] + fn sticky_key_profile_capacity_is_derived_from_configuration() { + let toml = r#" +[behavior.sticky_key.profiles.one] +[behavior.sticky_key.profiles.two] +[behavior.sticky_key.profiles.three] +[behavior.sticky_key.profiles.four] +[behavior.sticky_key.profiles.five] +"#; + let constants = parse(toml).build_constants(&[]).unwrap(); + assert_eq!(constants.sticky_key_profile_max_num, 5); + } + + #[test] + fn sticky_key_profile_capacity_can_be_explicitly_disabled() { + let constants = parse("[rmk]\nsticky_key_profile_max_num = 0\n") + .build_constants(&[]) + .unwrap(); + assert_eq!(constants.sticky_key_profile_max_num, 0); + } + + #[test] + fn sticky_key_profile_capacity_rejects_too_small_override() { + let toml = "[rmk]\nsticky_key_profile_max_num = 0\n\n[behavior.sticky_key.profiles.named]\n"; + let err = match parse(toml).build_constants(&[]) { + Ok(_) => panic!("expected sticky_key_profile_max_num validation failure"), + Err(err) => err, + }; + assert!(err.contains("sticky_key_profile_max_num")); + } + #[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 65ed79eef..7502525a1 100644 --- a/rmk-macro/src/codegen/action_parser.rs +++ b/rmk-macro/src/codegen/action_parser.rs @@ -158,10 +158,9 @@ pub(crate) fn expand_profile_name( } pub(crate) fn sorted_sticky_profile_names( - profiles: &Option>, + profiles: Option<&HashMap>, ) -> Vec { let mut names: Vec = profiles - .as_ref() .map(|profiles| profiles.keys().cloned().collect()) .unwrap_or_default(); names.sort(); @@ -175,11 +174,12 @@ fn sticky_profile_index( let Some(name) = name else { return quote! { ::core::primitive::u8::MAX }; }; - let names = sorted_sticky_profile_names(profiles); + 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"); }; - quote! { #index as u8 } + let index = index as u8; + quote! { #index } } /// Split `s` on commas that are *not* nested inside parentheses. @@ -712,4 +712,27 @@ mod tests { parse_key("SK(LShift, @missing)".to_string(), &None, &Some(profiles)); } + + #[test] + fn sticky_key_profile_indices_are_sorted_by_name() { + let profile = || StickyKeyProfile { + timeout_ms: None, + activate_on_keypress: None, + max_repeat: None, + release_mode: None, + }; + let profiles = Some(HashMap::from([ + ("zebra".to_string(), profile()), + ("alpha".to_string(), profile()), + ])); + + assert!( + squash(&parse_key("SK(LShift, @alpha)".into(), &None, &profiles).to_string()) + .contains(",0u8") + ); + assert!( + squash(&parse_key("SK(LShift, @zebra)".into(), &None, &profiles).to_string()) + .contains(",1u8") + ); + } } diff --git a/rmk-macro/src/codegen/behavior.rs b/rmk-macro/src/codegen/behavior.rs index 0afa83150..d476a123c 100644 --- a/rmk-macro/src/codegen/behavior.rs +++ b/rmk-macro/src/codegen/behavior.rs @@ -9,7 +9,9 @@ use rmk_config::resolved::behavior::{ MorseProfile, StickyKeyProfile, }; -use super::action_parser::{expand_profile, expand_profile_name, get_key_with_alias, parse_key}; +use super::action_parser::{ + expand_profile, expand_profile_name, get_key_with_alias, parse_key, sorted_sticky_profile_names, +}; fn expand_tri_layer(tri_layer: &Option<[u8; 3]>) -> proc_macro2::TokenStream { match tri_layer { @@ -66,19 +68,14 @@ fn expand_sticky_key(behavior: &Behavior) -> proc_macro2::TokenStream { release_mode: sk.release_mode, }) .unwrap_or_default(); - let default_timeout = default.timeout_ms.unwrap_or(1000); - let default_activate_on_keypress = default.activate_on_keypress.unwrap_or(false); - let default_max_repeat = default.max_repeat.unwrap_or(0); let default_profile = expand_sticky_key_profile(&default, &StickyKeyProfile::default()); let profile_tokens = behavior .sticky_key .as_ref() .map(|sk| { - let mut names: Vec<_> = sk.profiles.keys().collect(); - names.sort(); - names + sorted_sticky_profile_names(Some(&sk.profiles)) .into_iter() - .map(|name| expand_sticky_key_profile(&sk.profiles[name], &default)) + .map(|name| expand_sticky_key_profile(&sk.profiles[&name], &default)) .collect::>() }) .unwrap_or_default(); @@ -86,10 +83,6 @@ fn expand_sticky_key(behavior: &Behavior) -> proc_macro2::TokenStream { ::rmk::config::StickyKeyConfig { default_profile: #default_profile, profiles: ::rmk::heapless::Vec::from_iter([#(#profile_tokens),*]), - timeout: ::rmk::embassy_time::Duration::from_millis(#default_timeout), - activate_on_keypress: #default_activate_on_keypress, - max_repeat: #default_max_repeat, - ..Default::default() } } } @@ -625,4 +618,43 @@ mod tests { assert!(tokens.contains("release_mode:::core::option::Option::Some")); assert!(tokens.contains("profiles:::rmk::heapless::Vec::from_iter")); } + + #[test] + fn sticky_key_codegen_uses_the_action_parsers_profile_order() { + let mut profiles = HashMap::new(); + profiles.insert( + "zebra".to_string(), + StickyKeyProfile { + timeout_ms: Some(200), + ..Default::default() + }, + ); + profiles.insert( + "alpha".to_string(), + StickyKeyProfile { + timeout_ms: Some(100), + ..Default::default() + }, + ); + let behavior = Behavior { + tri_layer: None, + combos: None, + macros: None, + forks: None, + morse: None, + sticky_key: Some(StickyKeyConfig { + timeout_ms: None, + activate_on_keypress: None, + max_repeat: None, + release_mode: None, + profiles, + }), + auto_mouse_layer: Vec::new(), + }; + + let tokens = expand_sticky_key(&behavior).to_string().replace(' ', ""); + let alpha = tokens.find("from_millis(100u64)").unwrap(); + let zebra = tokens.find("from_millis(200u64)").unwrap(); + assert!(alpha < zebra); + } } diff --git a/rmk-types/src/protocol/rmk/mod.rs b/rmk-types/src/protocol/rmk/mod.rs index 75f850e08..b575df096 100644 --- a/rmk-types/src/protocol/rmk/mod.rs +++ b/rmk-types/src/protocol/rmk/mod.rs @@ -43,6 +43,11 @@ //! variant renamed or renumbered. `sys/version` itself is exempt — //! changing its shape is forbidden even across major bumps. //! - Neither: no wire change. +//! +//! Protocol 1.1 appends `Action::StickyKey`. Existing action discriminants are +//! unchanged, but endpoint schema keys involving `Action` change. A 1.0 host +//! must therefore stop after `sys/version`; it must not call those endpoints +//! using cached 1.0 keys. mod combo; mod encoder; diff --git a/rmk/src/config/behavior.rs b/rmk/src/config/behavior.rs index 3223a9e69..d6669127b 100644 --- a/rmk/src/config/behavior.rs +++ b/rmk/src/config/behavior.rs @@ -18,6 +18,9 @@ pub struct BehaviorConfig { pub default_layer: u8, pub tri_layer: Option<[u8; 3]>, pub tap: TapConfig, + /// Legacy one-shot inputs, normalized into `sticky_key` when the keymap is built. + pub one_shot: OneShotConfig, + pub one_shot_modifiers: OneShotModifiersConfig, pub combo: CombosConfig, pub fork: ForksConfig, pub morse: MorsesConfig, @@ -161,7 +164,8 @@ impl StickyKeyReleaseMode { pub const LAYER_EXIT: Self = Self::new().with_layer_exit(true); pub const DOUBLE_TAP: Self = Self::new().with_double_tap(true); - pub const fn contains(self, other: Self) -> bool { + /// Returns `true` when the two trigger sets share at least one bit. + pub const fn intersects(self, other: Self) -> bool { self.into_bits() & other.into_bits() != 0 } } @@ -197,15 +201,6 @@ impl Default for StickyKeyProfile { pub struct StickyKeyConfig { pub default_profile: StickyKeyProfile, pub profiles: Vec, - /// Legacy Rust-API compatibility knobs. TOML uses `release_mode` instead. - pub timeout: Duration, - pub activate_on_keypress: bool, - pub max_repeat: u16, - pub quick_release: bool, - pub release_on_layer_change: bool, - pub tap_key_release_on_layer_change: Option, - pub one_shot_mod_release_on_layer_change: Option, - pub one_shot_layer_release_on_layer_change: Option, } impl Default for StickyKeyConfig { @@ -213,14 +208,46 @@ impl Default for StickyKeyConfig { Self { default_profile: StickyKeyProfile::default(), profiles: Vec::new(), + } + } +} + +/// Legacy one-shot timeout input retained for Rust keymaps. +#[derive(Clone, Copy, Debug)] +pub struct OneShotConfig { + pub timeout: Duration, +} + +impl Default for OneShotConfig { + fn default() -> Self { + Self { timeout: Duration::from_secs(1), - activate_on_keypress: false, - max_repeat: 0, - quick_release: false, - release_on_layer_change: false, - tap_key_release_on_layer_change: None, - one_shot_mod_release_on_layer_change: None, - one_shot_layer_release_on_layer_change: None, + } + } +} + +/// Legacy one-shot modifier input retained for Rust keymaps. +#[derive(Clone, Copy, Debug, Default)] +pub struct OneShotModifiersConfig { + pub activate_on_keypress: bool, + pub quick_release: bool, +} + +impl BehaviorConfig { + /// Convert legacy one-shot inputs to the canonical sticky-key profile. + /// + /// This runs once at the keymap boundary. Runtime code reads only + /// `sticky_key`, so there is no mirrored mutable state to synchronize. + pub(crate) fn normalize_sticky_key_compat(&mut self) { + let legacy_timeout = OneShotConfig::default().timeout; + if self.one_shot.timeout != legacy_timeout { + self.sticky_key.default_profile.timeout = self.one_shot.timeout; + } + if self.one_shot_modifiers.activate_on_keypress { + self.sticky_key.default_profile.activate_on_keypress = true; + } + if self.one_shot_modifiers.quick_release && self.sticky_key.default_profile.release_mode.is_none() { + self.sticky_key.default_profile.release_mode = Some(StickyKeyReleaseMode::OTHER_KEY_PRESS); } } } diff --git a/rmk/src/config/mod.rs b/rmk/src/config/mod.rs index de8feeb24..c61977d22 100644 --- a/rmk/src/config/mod.rs +++ b/rmk/src/config/mod.rs @@ -8,7 +8,8 @@ mod vial; pub use behavior::{ AutoMouseLayerConfig, BehaviorConfig, CombosConfig, ForksConfig, KeyboardMacrosConfig, MorsesConfig, - MouseKeyConfig, StickyKeyConfig, StickyKeyProfile, StickyKeyReleaseMode, TapConfig, + MouseKeyConfig, OneShotConfig, OneShotModifiersConfig, StickyKeyConfig, StickyKeyProfile, StickyKeyReleaseMode, + TapConfig, }; #[cfg(feature = "_ble")] pub use ble_battery::BleBatteryConfig; diff --git a/rmk/src/event/mod.rs b/rmk/src/event/mod.rs index 4b6e54b09..f328fdff5 100644 --- a/rmk/src/event/mod.rs +++ b/rmk/src/event/mod.rs @@ -67,8 +67,8 @@ pub use input::{ pub use split::{CentralConnectedEvent, PeripheralConnectedEvent}; #[cfg(all(feature = "split", feature = "_ble"))] pub use split::{ClearPeerEvent, PeripheralBatteryEvent}; -pub(crate) use state::StickyKeyReleaseEvent; pub use state::{LayerChangeEvent, LedIndicatorEvent, SleepStateEvent, WpmUpdateEvent}; +pub(crate) use state::{LayerTransition, LayerTransitionEvent}; /// Trait for event publishers pub trait EventPublisher { diff --git a/rmk/src/event/state.rs b/rmk/src/event/state.rs index 3ac637f81..467d4eee3 100644 --- a/rmk/src/event/state.rs +++ b/rmk/src/event/state.rs @@ -3,8 +3,6 @@ use rmk_macro::event; use rmk_types::led_indicator::LedIndicator; -use crate::config::StickyKeyReleaseMode; - /// Active layer changed event #[event(channel_size = crate::LAYER_CHANGE_EVENT_CHANNEL_SIZE, pubs = crate::LAYER_CHANGE_EVENT_PUB_SIZE, subs = crate::LAYER_CHANGE_EVENT_SUB_SIZE)] #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -19,12 +17,19 @@ impl LayerChangeEvent { impl_payload_wrapper!(LayerChangeEvent, u8); -/// A layer transition that may release an active Sticky Key. -#[event(channel_size = crate::STICKY_KEY_RELEASE_EVENT_CHANNEL_SIZE, pubs = crate::STICKY_KEY_RELEASE_EVENT_PUB_SIZE, subs = crate::STICKY_KEY_RELEASE_EVENT_SUB_SIZE)] +/// The direction of a layer state transition. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum LayerTransition { + Enter, + Exit, +} + +/// A layer transition produced outside the main keyboard action loop. +#[event(channel_size = crate::LAYER_TRANSITION_EVENT_CHANNEL_SIZE, pubs = crate::LAYER_TRANSITION_EVENT_PUB_SIZE, subs = crate::LAYER_TRANSITION_EVENT_SUB_SIZE)] #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct StickyKeyReleaseEvent(pub StickyKeyReleaseMode); +pub(crate) struct LayerTransitionEvent(pub LayerTransition); -impl_payload_wrapper!(StickyKeyReleaseEvent, StickyKeyReleaseMode); +impl_payload_wrapper!(LayerTransitionEvent, LayerTransition); /// WPM updated event #[event(channel_size = crate::WPM_UPDATE_EVENT_CHANNEL_SIZE, pubs = crate::WPM_UPDATE_EVENT_PUB_SIZE, subs = crate::WPM_UPDATE_EVENT_SUB_SIZE)] diff --git a/rmk/src/keyboard.rs b/rmk/src/keyboard.rs index fedbaf14a..b42dd24d5 100644 --- a/rmk/src/keyboard.rs +++ b/rmk/src/keyboard.rs @@ -20,8 +20,8 @@ use crate::core_traits::Runnable; #[cfg(all(feature = "split", feature = "_ble"))] use crate::event::ClearPeerEvent; use crate::event::{ - ActionEvent, KeyboardEvent, KeyboardEventPos, ModifierEvent, StickyKeyReleaseEvent, SubscribableEvent, - publish_event, publish_event_async, + ActionEvent, KeyboardEvent, KeyboardEventPos, LayerTransition, LayerTransitionEvent, ModifierEvent, + SubscribableEvent, publish_event, publish_event_async, }; use crate::hid::{KeyboardReport, Report}; use crate::keyboard::combo::Combo; @@ -30,7 +30,7 @@ use crate::keyboard::held_buffer::{HeldBuffer, HeldKey, KeyState}; use crate::keyboard::mouse::{MouseAction, MouseState}; use crate::keyboard::sticky_key::StickyKeyState; use crate::keyboard_macros::MacroOperation; -use crate::keymap::{KeyMap, StickyKeyShape}; +use crate::keymap::KeyMap; #[cfg(all(feature = "split", feature = "_ble"))] use crate::split::ble::central::update_activity_time; use crate::{COMBO_MAX_NUM, FORK_MAX_NUM, MACRO_SPACE_SIZE, boot}; @@ -166,7 +166,7 @@ impl Runnable for Keyboard<'_> { if let Some(deadline) = deadline { match select3( self.keyboard_event_subscriber.next_message_pure(), - self.sticky_key_release_event_subscriber.next_message_pure(), + self.layer_transition_event_subscriber.next_message_pure(), Timer::at(deadline), ) .await @@ -175,20 +175,28 @@ impl Runnable for Keyboard<'_> { self.process_inner(event).await; } Either3::Second(layer_event) => { - self.release_sticky_key_on_layer_event(layer_event.0).await; + self.release_sticky_key_on_layer_event(match layer_event.0 { + LayerTransition::Enter => crate::config::StickyKeyReleaseMode::LAYER_ENTER, + LayerTransition::Exit => crate::config::StickyKeyReleaseMode::LAYER_EXIT, + }) + .await; } Either3::Third(_) => {} } } else { match select( self.keyboard_event_subscriber.next_message_pure(), - self.sticky_key_release_event_subscriber.next_message_pure(), + self.layer_transition_event_subscriber.next_message_pure(), ) .await { Either::First(event) => self.process_inner(event).await, Either::Second(layer_event) => { - self.release_sticky_key_on_layer_event(layer_event.0).await; + self.release_sticky_key_on_layer_event(match layer_event.0 { + LayerTransition::Enter => crate::config::StickyKeyReleaseMode::LAYER_ENTER, + LayerTransition::Exit => crate::config::StickyKeyReleaseMode::LAYER_EXIT, + }) + .await; } } } @@ -219,13 +227,13 @@ pub struct Keyboard<'a> { { crate::KEYBOARD_EVENT_PUB_SIZE }, >, - sticky_key_release_event_subscriber: embassy_sync::pubsub::Subscriber< + layer_transition_event_subscriber: embassy_sync::pubsub::Subscriber< 'static, crate::RawMutex, - StickyKeyReleaseEvent, - { crate::STICKY_KEY_RELEASE_EVENT_CHANNEL_SIZE }, - { crate::STICKY_KEY_RELEASE_EVENT_SUB_SIZE }, - { crate::STICKY_KEY_RELEASE_EVENT_PUB_SIZE }, + LayerTransitionEvent, + { crate::LAYER_TRANSITION_EVENT_CHANNEL_SIZE }, + { crate::LAYER_TRANSITION_EVENT_SUB_SIZE }, + { crate::LAYER_TRANSITION_EVENT_PUB_SIZE }, >, /// Unprocessed events @@ -295,7 +303,7 @@ impl<'a> Keyboard<'a> { Keyboard { keymap, keyboard_event_subscriber: KeyboardEvent::subscriber(), - sticky_key_release_event_subscriber: StickyKeyReleaseEvent::subscriber(), + layer_transition_event_subscriber: LayerTransitionEvent::subscriber(), last_press_time: Instant::now(), sticky_key_state: StickyKeyState::default(), caps_word: CapsWordState::default(), @@ -1261,7 +1269,7 @@ impl<'a> Keyboard<'a> { // in `process_action_key`, per `quick_release`). Only the tap-key shape releases its // held modifier cleanly before the foreign key registers. let mut release_tap_key_after_action = false; - if self.sticky_key_state.is_tap_key() { + if self.sticky_key_state.has_tap_key() { let is_sk_or_modifier = match action { Action::StickyKey(_) | Action::OneShotModifier(_) | Action::OneShotLayer(_) | Action::Modifier(_) => { true @@ -1269,19 +1277,10 @@ impl<'a> Keyboard<'a> { Action::Key(KeyCode::Hid(hid_key)) if hid_key.is_modifier() => true, _ => false, }; - let release_mode = self.sticky_key_state.profile().and_then(|index| { - self.keymap - .sticky_key_profile(index, StickyKeyShape::TapKey) - .release_mode - }); - let should_release = match release_mode { - Some(mode) if event.pressed => mode.contains(crate::config::StickyKeyReleaseMode::OTHER_KEY_PRESS), - Some(mode) => mode.contains(crate::config::StickyKeyReleaseMode::OTHER_KEY_RELEASE), - None => event.pressed, - }; + let should_release = self.sticky_key_state.tap_key_releases_on(event.pressed); if !is_sk_or_modifier && should_release { if event.pressed { - self.release_sticky_key_if_active().await; + self.release_tap_key().await; } else { release_tap_key_after_action = true; } @@ -1457,7 +1456,7 @@ impl<'a> Keyboard<'a> { } if release_tap_key_after_action { - self.release_sticky_key_if_active().await; + self.release_tap_key().await; } } @@ -1500,15 +1499,7 @@ impl<'a> Keyboard<'a> { // press report and is "released" together with the key release — except in held // mode (key pressed while SK still physically held), where the modifier behaves // like a normal held modifier and stays applied until the SK itself is released. - if let Some(mods) = self.sticky_key_state.value().copied() { - if self.sticky_key_state.is_pure_mod() || self.sticky_key_state.is_layer() { - if pressed || self.sticky_key_state.is_held() { - result |= mods; - } - } else { - result |= mods; - } - } + result |= self.sticky_key_state.modifiers(pressed); result } @@ -1700,12 +1691,7 @@ impl<'a> Keyboard<'a> { // Consume any pending one-shot StickyKey. A press-triggered release needs a // follow-up report after the terminating key has been registered. - let press_release = self.sticky_key_state.profile().is_some_and(|index| { - self.keymap - .sticky_key_profile(index, StickyKeyShape::PureMod) - .release_mode - .is_some_and(|mode| mode.contains(crate::config::StickyKeyReleaseMode::OTHER_KEY_PRESS)) - }); + let press_release = self.sticky_key_state.modifier_releases_on_press(); let sk_consumed = self.update_sticky_key(event); if press_release && sk_consumed && is_basic_keyboard_key && event.pressed { self.send_keyboard_report_with_resolved_modifiers(true).await; diff --git a/rmk/src/keyboard/auto_mouse_layer.rs b/rmk/src/keyboard/auto_mouse_layer.rs index 0fe8f1f03..970780cda 100644 --- a/rmk/src/keyboard/auto_mouse_layer.rs +++ b/rmk/src/keyboard/auto_mouse_layer.rs @@ -24,11 +24,11 @@ use rmk_types::keycode::{HidKeyCode, KeyCode}; use rmk_types::modifier::ModifierCombination; use crate::AUTO_MOUSE_LAYER_MAX_NUM; -use crate::config::{AutoMouseLayerConfig, StickyKeyReleaseMode}; +use crate::config::AutoMouseLayerConfig; use crate::core_traits::Runnable; use crate::event::{ - ActionEvent, Axis, AxisValType, EventSubscriber, LayerChangeEvent, PointingEvent, StickyKeyReleaseEvent, - SubscribableEvent, publish_event, + ActionEvent, Axis, AxisValType, EventSubscriber, LayerChangeEvent, LayerTransition, LayerTransitionEvent, + PointingEvent, SubscribableEvent, publish_event, }; use crate::keymap::KeyMap; use crate::processor::Processor; @@ -107,7 +107,7 @@ impl<'a, 'k> AutoMouseLayerRunner<'a, 'k> { let target_layer = self.entries[idx].config.target_layer; let activated_by_us = self.keymap.activate_layer_if_inactive(target_layer); if activated_by_us { - publish_event(StickyKeyReleaseEvent(StickyKeyReleaseMode::LAYER_ENTER)); + publish_event(LayerTransitionEvent(LayerTransition::Enter)); } if pointing_step(&mut self.entries, idx, Instant::now(), activated_by_us) == PointingOutcome::OverlapFirstSeen { warn!( @@ -143,7 +143,7 @@ impl<'a, 'k> AutoMouseLayerRunner<'a, 'k> { } for layer in keypress_step(&mut self.entries, event.action, Instant::now()) { if self.keymap.deactivate_layer_if_active(layer) { - publish_event(StickyKeyReleaseEvent(StickyKeyReleaseMode::LAYER_EXIT)); + publish_event(LayerTransitionEvent(LayerTransition::Exit)); } } } @@ -179,7 +179,7 @@ impl AutoMouseLayerRunner<'_, '_> { async fn on_deadline(&mut self) { for layer in timeout_step(&mut self.entries, Instant::now()) { if self.keymap.deactivate_layer_if_active(layer) { - publish_event(StickyKeyReleaseEvent(StickyKeyReleaseMode::LAYER_EXIT)); + publish_event(LayerTransitionEvent(LayerTransition::Exit)); } } } diff --git a/rmk/src/keyboard/sticky_key.rs b/rmk/src/keyboard/sticky_key.rs index 99a2816a0..574f46101 100644 --- a/rmk/src/keyboard/sticky_key.rs +++ b/rmk/src/keyboard/sticky_key.rs @@ -1,12 +1,8 @@ -//! StickyKey action implementation. +//! Sticky modifier, layer, and tap-key behavior. //! -//! A unified one-shot action engine covering pure-mod (OSM), tap-key, and layer (OSL) shapes. -//! The effect is represented explicitly by the `StickyKeyAction` payload. -//! Runtime state and its lifecycle are represented by `StickyKeyState`. -//! -//! Timeout is driven solely by the run-loop deadline race (see `Keyboard::run`); there is -//! no inline `select` in this module. On expiry the run loop calls -//! [`Keyboard::release_sticky_key_if_active`]. +//! The three effects share one latch lifecycle, but modifier and layer state +//! remain independent. Tap keys are deliberately exclusive because they keep +//! both a HID key and modifiers live between repetitions. use embassy_time::{Duration, Instant}; use rmk_types::action::{StickyKeyAction, StickyKeyEffect}; @@ -16,268 +12,271 @@ use rmk_types::modifier::ModifierCombination; use crate::config::StickyKeyReleaseMode; use crate::event::{KeyboardEvent, KeyboardEventPos}; use crate::keyboard::Keyboard; -use crate::keymap::StickyKeyShape; +use crate::keymap::{StickyKeyPolicy, StickyKeyShape}; fn deadline_from_timeout(timeout: Duration) -> Option { (timeout != Duration::MAX).then(|| Instant::now() + timeout) } -/// The operation performed while a Sticky Key is active. -#[derive(Clone, Copy, Debug)] -enum ActiveEffect { - Modifier, - Layer(u8), - TapKey(HidKeyCode), +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum LatchPhase { + /// One or more physical producers are still down. + Pressed, + /// Every producer is up and the effect is armed. + Latched, + /// A foreign key was pressed while a producer remained down. + Held, } -/// Data carried through each active Sticky Key lifecycle state. #[derive(Clone, Copy, Debug)] -pub(crate) struct ActiveStickyKey { - /// Physical key that owns this latch. +struct Latch { + value: T, source: KeyboardEventPos, - mods: ModifierCombination, - effect: ActiveEffect, - /// Selected Sticky Key profile (`u8::MAX` means default profile). - profile: u8, + policy: StickyKeyPolicy, + phase: LatchPhase, + pressed_count: u8, repeat_count: u16, deadline: Option, } -/// Lifecycle of a Sticky Key. -#[derive(Clone, Copy, Debug, Default)] -pub(crate) enum StickyKeyState { - /// No Sticky Key is active. - #[default] - None, - /// The physical Sticky Key is down and no foreign key has been pressed. - Pressed(ActiveStickyKey), - /// The physical Sticky Key was released and is armed for a foreign key. - Latched(ActiveStickyKey), - /// A foreign key was pressed while the physical Sticky Key remained down. - Held(ActiveStickyKey), -} - -enum ReleaseTransition { +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PhysicalRelease { Ignored, Latched, - Held, + Released, } -impl StickyKeyState { - pub fn value(&self) -> Option<&ModifierCombination> { - self.active().map(|active| &active.mods) +impl Latch { + fn new(value: T, source: KeyboardEventPos, policy: StickyKeyPolicy) -> Self { + Self { + value, + source, + policy, + phase: LatchPhase::Pressed, + pressed_count: 1, + repeat_count: 1, + deadline: deadline_from_timeout(policy.timeout), + } } - pub fn is_active(&self) -> bool { - !matches!(self, StickyKeyState::None) + fn deadline(&self) -> Option { + self.deadline } - pub fn deadline(&self) -> Option { - self.active().and_then(|active| active.deadline) + fn begin_press(&mut self, source: KeyboardEventPos, policy: StickyKeyPolicy) { + self.source = source; + self.policy = policy; + self.phase = LatchPhase::Pressed; + self.pressed_count = self.pressed_count.saturating_add(1).max(1); + self.deadline = deadline_from_timeout(policy.timeout); } - pub fn is_pure_mod(&self) -> bool { - self.active() - .is_some_and(|active| matches!(active.effect, ActiveEffect::Modifier)) + fn on_physical_release(&mut self, owner: Option) -> PhysicalRelease { + if owner.is_some_and(|owner| owner != self.source) { + return PhysicalRelease::Ignored; + } + match self.phase { + LatchPhase::Pressed | LatchPhase::Held if self.pressed_count > 1 => { + self.pressed_count -= 1; + PhysicalRelease::Ignored + } + LatchPhase::Pressed => { + self.pressed_count = 0; + self.phase = LatchPhase::Latched; + self.deadline = deadline_from_timeout(self.policy.timeout); + PhysicalRelease::Latched + } + LatchPhase::Held => { + self.pressed_count = 0; + PhysicalRelease::Released + } + LatchPhase::Latched => PhysicalRelease::Ignored, + } } - pub fn is_tap_key(&self) -> bool { - self.active() - .is_some_and(|active| matches!(active.effect, ActiveEffect::TapKey(_))) + fn mark_foreign_key(&mut self) { + if self.phase == LatchPhase::Pressed { + self.phase = LatchPhase::Held; + self.deadline = None; + } } - pub fn is_layer(&self) -> bool { - self.active() - .is_some_and(|active| matches!(active.effect, ActiveEffect::Layer(_))) + fn trigger_for_key(&self, pressed: bool) -> bool { + let trigger = if pressed { + StickyKeyReleaseMode::OTHER_KEY_PRESS + } else { + StickyKeyReleaseMode::OTHER_KEY_RELEASE + }; + self.policy.release_mode.intersects(trigger) } - pub(crate) fn profile(&self) -> Option { - self.active().map(|active| active.profile) + fn is_double_tap(&self, source: KeyboardEventPos, policy: StickyKeyPolicy) -> bool { + self.phase == LatchPhase::Latched + && self.source == source + && policy.release_mode.intersects(StickyKeyReleaseMode::DOUBLE_TAP) } - pub(crate) fn shape(&self) -> Option { - if self.is_pure_mod() { - Some(StickyKeyShape::PureMod) - } else if self.is_layer() { - Some(StickyKeyShape::Layer) - } else if self.is_tap_key() { - Some(StickyKeyShape::TapKey) + /// A timeout cannot erase a latch whose physical producer is still down: + /// its later release must still complete the lifecycle. + fn timeout_disposition(&mut self, now: Instant) -> TimeoutDisposition { + if !self.deadline.is_some_and(|deadline| deadline <= now) { + return TimeoutDisposition::Pending; + } + if self.phase == LatchPhase::Pressed { + self.deadline = None; + TimeoutDisposition::Deferred } else { - None + TimeoutDisposition::Release } } +} - pub(crate) fn is_held(&self) -> bool { - matches!(self, Self::Held(_)) - } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TimeoutDisposition { + Pending, + Deferred, + Release, +} - fn active(&self) -> Option<&ActiveStickyKey> { - match self { - Self::Pressed(active) | Self::Latched(active) | Self::Held(active) => Some(active), - Self::None => None, - } - } +#[derive(Clone, Copy, Debug)] +struct TapKeyEffect { + key: HidKeyCode, + modifiers: ModifierCombination, } -impl Keyboard<'_> { - fn transition_on_release( - &mut self, - owner: Option, - deadline: Option, - ) -> ReleaseTransition { - match self.sticky_key_state { - StickyKeyState::Pressed(mut active) if owner.is_none_or(|owner| active.source == owner) => { - active.deadline = deadline; - self.sticky_key_state = StickyKeyState::Latched(active); - ReleaseTransition::Latched - } - StickyKeyState::Held(active) if owner.is_none_or(|owner| active.source == owner) => { - self.sticky_key_state = StickyKeyState::None; - ReleaseTransition::Held - } - _ => ReleaseTransition::Ignored, - } +/// Runtime composition for sticky effects. +/// +/// Modifier and layer effects can coexist and therefore own distinct policies, +/// phases, sources, and deadlines. A tap key is exclusive with both. +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct StickyKeyState { + modifier: Option>, + layer: Option>, + tap_key: Option>, +} + +impl StickyKeyState { + pub(crate) fn deadline(&self) -> Option { + [ + self.modifier.as_ref().and_then(Latch::deadline), + self.layer.as_ref().and_then(Latch::deadline), + self.tap_key.as_ref().and_then(Latch::deadline), + ] + .into_iter() + .flatten() + .min() } - pub(crate) async fn release_sticky_key_on_layer_event(&mut self, event: StickyKeyReleaseMode) { - let (Some(index), Some(shape)) = (self.sticky_key_state.profile(), self.sticky_key_state.shape()) else { - return; - }; - if self - .keymap - .sticky_key_profile(index, shape) - .release_mode - .is_some_and(|mode| mode.contains(event)) - { - self.release_sticky_key_if_active().await; - } + pub(crate) fn has_tap_key(&self) -> bool { + self.tap_key.is_some() } - pub(crate) async fn process_action_sticky_key(&mut self, params: StickyKeyAction, event: KeyboardEvent) { - let shape = match params.effect { - StickyKeyEffect::Modifier(_) => StickyKeyShape::PureMod, - StickyKeyEffect::Layer(_) => StickyKeyShape::Layer, - StickyKeyEffect::TapKey { .. } => StickyKeyShape::TapKey, - }; + pub(crate) fn tap_key_releases_on(&self, pressed: bool) -> bool { + self.tap_key + .as_ref() + .is_some_and(|tap_key| tap_key.trigger_for_key(pressed)) + } + + pub(crate) fn modifier_releases_on_press(&self) -> bool { + self.modifier + .as_ref() + .is_some_and(|modifier| modifier.trigger_for_key(true)) + } - if event.pressed - && matches!( - self.sticky_key_state, - StickyKeyState::Latched(ActiveStickyKey { source, .. }) if source == event.pos - ) - && self - .keymap - .sticky_key_profile(params.profile, shape) - .release_mode - .is_some_and(|mode| mode.double_tap()) + pub(crate) fn modifiers(&self, pressed: bool) -> ModifierCombination { + let mut modifiers = ModifierCombination::new(); + if let Some(modifier) = self.modifier + && (pressed || modifier.phase == LatchPhase::Held) { - self.release_sticky_key_if_active().await; - return; + modifiers |= modifier.value; + } + if let Some(tap_key) = self.tap_key { + modifiers |= tap_key.value.modifiers; } + modifiers + } +} - match params.effect { +impl Keyboard<'_> { + pub(crate) async fn process_action_sticky_key(&mut self, action: StickyKeyAction, event: KeyboardEvent) { + match action.effect { StickyKeyEffect::Modifier(modifiers) => { - self.process_sticky_pure_mod(modifiers, params.profile, event).await + self.process_sticky_modifier(modifiers, action.profile, event).await } - StickyKeyEffect::Layer(layer) => self.process_sticky_layer(layer, params.profile, event).await, + StickyKeyEffect::Layer(layer) => self.process_sticky_layer(layer, action.profile, event).await, StickyKeyEffect::TapKey { key, modifiers } => { - self.process_sticky_tap_key(key, modifiers, params.profile, event).await + self.process_sticky_tap_key(key, modifiers, action.profile, event).await } } } - /// Pure-mod (OSM) shape: accumulate the modifier across taps, apply it through the - /// terminating key, honor `activate_on_keypress`/`quick_release`. - async fn process_sticky_pure_mod( + async fn process_sticky_modifier( &mut self, modifiers: ModifierCombination, profile_index: u8, event: KeyboardEvent, ) { - let profile = self.keymap.sticky_key_profile(profile_index, StickyKeyShape::PureMod); - let deadline = deadline_from_timeout(profile.timeout); + let policy = self.keymap.sticky_key_profile(profile_index, StickyKeyShape::PureMod); if event.pressed { - if self.sticky_key_state.is_active() - && !self.sticky_key_state.is_pure_mod() - && !self.sticky_key_state.is_layer() + self.release_tap_key().await; + if self + .sticky_key_state + .modifier + .is_some_and(|latch| latch.is_double_tap(event.pos, policy)) { - self.release_sticky_key_if_active().await; + self.release_sticky_modifier().await; + return; } - self.sticky_key_state = match self.sticky_key_state.active().copied() { - None => StickyKeyState::Pressed(ActiveStickyKey { - source: event.pos, - mods: modifiers, - effect: ActiveEffect::Modifier, - profile: profile_index, - repeat_count: 1, - deadline, - }), - Some(mut active) => { - active.source = event.pos; - active.mods |= modifiers; - active.profile = profile_index; - active.deadline = deadline; - StickyKeyState::Pressed(active) + match &mut self.sticky_key_state.modifier { + Some(latch) => { + latch.value |= modifiers; + latch.begin_press(event.pos, policy); } - }; - - if profile.activate_on_keypress { + None => { + self.sticky_key_state.modifier = Some(Latch::new(modifiers, event.pos, policy)); + } + } + if policy.activate_on_keypress { self.send_keyboard_report_with_resolved_modifiers(true).await; } - } else { - // Combo outputs may be released by a different constituent position, - // so modifier actions cannot require the original source position. - if matches!(self.transition_on_release(None, deadline), ReleaseTransition::Held) { - self.send_keyboard_report_with_resolved_modifiers(false).await; + } else if let Some(latch) = &mut self.sticky_key_state.modifier { + // Combo outputs may be released by a different constituent + // position, so modifier producers use counted ownership. + if latch.on_physical_release(None) == PhysicalRelease::Released { + self.release_sticky_modifier().await; } } } - /// Layer (OSL) shape: activate the layer for the next foreign key. The layer carries - /// no modifier, so consuming it emits no HID report. - async fn process_sticky_layer(&mut self, layer_num: u8, profile_index: u8, event: KeyboardEvent) { - let profile = self.keymap.sticky_key_profile(profile_index, StickyKeyShape::Layer); - let deadline = deadline_from_timeout(profile.timeout); + async fn process_sticky_layer(&mut self, layer: u8, profile_index: u8, event: KeyboardEvent) { + let policy = self.keymap.sticky_key_profile(profile_index, StickyKeyShape::Layer); if event.pressed { - if self.sticky_key_state.is_tap_key() { - self.release_sticky_key_if_active().await; + self.release_tap_key().await; + if self + .sticky_key_state + .layer + .is_some_and(|latch| latch.is_double_tap(event.pos, policy)) + { + self.release_sticky_layer(); + return; } - - let existing_mods = match self.sticky_key_state.active().copied() { - Some(active) => { - if let ActiveEffect::Layer(previous_layer) = active.effect { - self.keymap.deactivate_layer(previous_layer); - } - active.mods - } - None => ModifierCombination::new(), - }; - - self.keymap.activate_layer(layer_num); - self.sticky_key_state = StickyKeyState::Pressed(ActiveStickyKey { - source: event.pos, - mods: existing_mods, - effect: ActiveEffect::Layer(layer_num), - profile: profile_index, - repeat_count: 1, - deadline, - }); - } else { - if matches!( - self.transition_on_release(Some(event.pos), deadline), - ReleaseTransition::Held - ) { - self.keymap.deactivate_layer(layer_num); + if let Some(previous) = self.sticky_key_state.layer.take() + && previous.value != layer + { + self.keymap.deactivate_layer(previous.value); } + self.keymap.activate_layer(layer); + self.sticky_key_state.layer = Some(Latch::new(layer, event.pos, policy)); + } else if let Some(latch) = &mut self.sticky_key_state.layer + && latch.on_physical_release(Some(event.pos)) == PhysicalRelease::Released + { + self.release_sticky_layer(); } } - /// Tap-key (alt-tab) shape: send `keep` mods + `key` on every press, hold the mods - /// between presses, cycle on each press (`max_repeat`). Ignores - /// `activate_on_keypress`/`quick_release`. async fn process_sticky_tap_key( &mut self, key: HidKeyCode, @@ -285,193 +284,240 @@ impl Keyboard<'_> { profile_index: u8, event: KeyboardEvent, ) { - let profile = self.keymap.sticky_key_profile(profile_index, StickyKeyShape::TapKey); - let deadline = deadline_from_timeout(profile.timeout); + let policy = self.keymap.sticky_key_profile(profile_index, StickyKeyShape::TapKey); if event.pressed { - let is_different_tap_key = self + self.release_sticky_modifier().await; + self.release_sticky_layer(); + + let same_tap_key = self + .sticky_key_state + .tap_key + .is_some_and(|latch| latch.source == event.pos && latch.value.key == key); + if self .sticky_key_state - .active() - .is_some_and(|active| active.source != event.pos); - if self.sticky_key_state.is_active() && (!self.sticky_key_state.is_tap_key() || is_different_tap_key) { - self.release_sticky_key_if_active().await; + .tap_key + .is_some_and(|latch| latch.is_double_tap(event.pos, policy)) + { + self.release_tap_key().await; + return; + } + if !same_tap_key { + self.release_tap_key().await; } - let mut should_deactivate = false; - self.sticky_key_state = match self.sticky_key_state.active().copied() { - None => StickyKeyState::Pressed(ActiveStickyKey { - source: event.pos, - mods: modifiers, - effect: ActiveEffect::TapKey(key), - profile: profile_index, - repeat_count: 1, - deadline, - }), - Some(mut active) => { - active.repeat_count = active.repeat_count.saturating_add(1); - if profile.max_repeat > 0 && active.repeat_count > profile.max_repeat { - should_deactivate = true; - StickyKeyState::None + let mut deactivate = false; + match &mut self.sticky_key_state.tap_key { + Some(latch) => { + latch.repeat_count = latch.repeat_count.saturating_add(1); + if policy.max_repeat > 0 && latch.repeat_count > policy.max_repeat { + deactivate = true; } else { - active.deadline = deadline; - StickyKeyState::Pressed(active) + latch.policy = policy; + latch.phase = LatchPhase::Pressed; + latch.pressed_count = 1; + latch.deadline = deadline_from_timeout(policy.timeout); } } - }; + None => { + self.sticky_key_state.tap_key = + Some(Latch::new(TapKeyEffect { key, modifiers }, event.pos, policy)); + } + } - if should_deactivate { - self.send_keyboard_report_with_resolved_modifiers(false).await; + if deactivate { + self.release_tap_key().await; } else { self.register_key(key, event); self.send_keyboard_report_with_resolved_modifiers(true).await; } - } else if let StickyKeyState::Pressed(mut active) = self.sticky_key_state - && active.source == event.pos + } else if self + .sticky_key_state + .tap_key + .is_some_and(|latch| latch.source == event.pos && latch.phase == LatchPhase::Pressed) { - if active.deadline.is_none() { - active.deadline = deadline; - } + self.sticky_key_state + .tap_key + .as_mut() + .expect("tap key checked above") + .on_physical_release(Some(event.pos)); self.unregister_key(key, event); self.send_keyboard_report_with_resolved_modifiers(false).await; - self.sticky_key_state = StickyKeyState::Latched(active); } } - /// Foreign-key hook for the pure-mod shape, mirroring the former `update_osm`. - /// Called from `process_action_key` for every basic key. Drives the OSM-style - /// phase transitions on the terminating key and returns `true` when the latch was - /// consumed (so the caller can emit a quick-release report). - /// - /// Tap-key shape is untouched here — it is consumed elsewhere. - /// - /// Called only from `process_action_key` (basic keys), so a bare `Action::Modifier` - /// no longer consumes a latched OSL the way the former `update_osl` did from the - /// modifier path — only a non-modifier key, a layer change, or timeout consumes it. - /// This narrowing is intentional (a held modifier is not a "terminating key") and - /// matches how tap-key SKs already ignore bare modifiers. + /// Apply a foreign key event to the independently active modifier and + /// layer latches. Returns whether a modifier was consumed. pub(crate) fn update_sticky_key(&mut self, event: KeyboardEvent) -> bool { - if !self.sticky_key_state.is_pure_mod() && !self.sticky_key_state.is_layer() { - return false; - } - let mode = self - .sticky_key_state - .profile() - .zip(self.sticky_key_state.shape()) - .and_then(|(index, shape)| self.keymap.sticky_key_profile(index, shape).release_mode); - match self.sticky_key_state { - StickyKeyState::Pressed(mut active) => { - active.deadline = None; - self.sticky_key_state = StickyKeyState::Held(active); - false - } - StickyKeyState::Latched(active) => { - let release_on_press = - event.pressed && mode.is_some_and(|mode| mode.contains(StickyKeyReleaseMode::OTHER_KEY_PRESS)); - let release_on_release = !event.pressed - && (mode.is_none() - || mode.is_some_and(|mode| mode.contains(StickyKeyReleaseMode::OTHER_KEY_RELEASE))); - if !release_on_press && !release_on_release { - return false; + let mut modifier_consumed = false; + + if let Some(modifier) = &mut self.sticky_key_state.modifier { + match modifier.phase { + LatchPhase::Pressed => modifier.mark_foreign_key(), + LatchPhase::Latched if modifier.trigger_for_key(event.pressed) => { + self.sticky_key_state.modifier = None; + modifier_consumed = true; } + LatchPhase::Latched | LatchPhase::Held => {} + } + } - if let ActiveEffect::Layer(layer) = active.effect { + if let Some(layer) = &mut self.sticky_key_state.layer { + match layer.phase { + LatchPhase::Pressed => layer.mark_foreign_key(), + LatchPhase::Latched if layer.trigger_for_key(event.pressed) => { + let layer = layer.value; + self.sticky_key_state.layer = None; self.keymap.deactivate_layer(layer); - self.sticky_key_state = StickyKeyState::None; - release_on_press - } else { - self.sticky_key_state = StickyKeyState::None; - true } + LatchPhase::Latched | LatchPhase::Held => {} } - StickyKeyState::None | StickyKeyState::Held(_) => false, + } + + modifier_consumed + } + + pub(crate) async fn release_sticky_key_on_layer_event(&mut self, event: StickyKeyReleaseMode) { + if self + .sticky_key_state + .modifier + .is_some_and(|latch| latch.policy.release_mode.intersects(event)) + { + self.release_sticky_modifier().await; + } + if self + .sticky_key_state + .layer + .is_some_and(|latch| latch.policy.release_mode.intersects(event)) + { + self.release_sticky_layer(); + } + if self + .sticky_key_state + .tap_key + .is_some_and(|latch| latch.policy.release_mode.intersects(event)) + { + self.release_tap_key().await; } } - /// Release a StickyKey whose timeout has elapsed. - /// - /// A physical key release must still be able to observe the active state, so a timeout that - /// fires while the key is held only clears its deadline. Explicit cleanup (for a replacement - /// key or layer change) uses `release_sticky_key_if_active` and must not be deferred. pub(crate) async fn release_sticky_key_if_active_on_timeout(&mut self) { - if !self.sticky_key_state.is_active() { - return; + let now = Instant::now(); + if self + .sticky_key_state + .modifier + .as_mut() + .is_some_and(|latch| latch.timeout_disposition(now) == TimeoutDisposition::Release) + { + self.release_sticky_modifier().await; + } + if self + .sticky_key_state + .layer + .as_mut() + .is_some_and(|latch| latch.timeout_disposition(now) == TimeoutDisposition::Release) + { + self.release_sticky_layer(); + } + if self + .sticky_key_state + .tap_key + .as_mut() + .is_some_and(|latch| latch.timeout_disposition(now) == TimeoutDisposition::Release) + { + self.release_tap_key().await; } + } - // If the SK is still physically held, the deadline fired but the - // key hasn't been released yet. Don't clear the latch — the physical release - // handler (process_sticky_*) will transition Held→None cleanly. For pure-mod, - // the deadline was set on press (→ Held on any other key press), so this can - // only happen when the key is held and idle. For layer and tap-key shapes, the - // deadline fires in the same scenario. - // Clear the deadline to avoid busy-looping on every iteration. - if let StickyKeyState::Pressed(active) = &mut self.sticky_key_state { - debug!( - "StickyKey timeout fired while key is still held — clearing deadline, deferring to physical release" - ); - active.deadline = None; + async fn release_sticky_modifier(&mut self) { + let Some(modifier) = self.sticky_key_state.modifier.take() else { return; + }; + if modifier.phase == LatchPhase::Held || modifier.policy.activate_on_keypress { + self.send_keyboard_report_with_resolved_modifiers(false).await; } - - self.release_sticky_key_if_active().await; } - pub(crate) async fn release_sticky_key_if_active(&mut self) { - if !self.sticky_key_state.is_active() { - return; + fn release_sticky_layer(&mut self) { + if let Some(layer) = self.sticky_key_state.layer.take() { + self.keymap.deactivate_layer(layer.value); } + } - debug!("Releasing StickyKey"); - - // Decide whether the release needs its own HID report. A report is only meaningful - // when the sticky modifier was actually visible in the last report: - // - tap-key shape: the modifier is always live between presses → always report. - // - pure-mod shape: only when promoted to Held, or when `activate_on_keypress` - // emitted the modifier early. A bare Latched pure-mod that times out before any - // key (and without early activation) never emitted the modifier, so releasing it - // must NOT produce a spurious empty report. Mirrors the former OSM timeout path. - // - layer shape: deactivating a layer emits nothing → never report. - let needs_report = if self.sticky_key_state.is_pure_mod() { - let activate_on_keypress = self.sticky_key_state.profile().is_some_and(|index| { - self.keymap - .sticky_key_profile(index, StickyKeyShape::PureMod) - .activate_on_keypress - }); - self.sticky_key_state.is_held() || activate_on_keypress - } else { - // tap-key shape always reports; layer shape never does (deactivating emits nothing). - !self.sticky_key_state.is_layer() + pub(crate) async fn release_tap_key(&mut self) { + let Some(tap_key) = self.sticky_key_state.tap_key.take() else { + return; }; - - // A tap-key may still have its HID key registered when it is displaced by a different - // StickyKey while physically held. Unregister it before clearing the latch so it cannot - // remain stuck in the report. - if let Some(ActiveStickyKey { - effect: ActiveEffect::TapKey(hid_key), - source, - .. - }) = self.sticky_key_state.active().copied() - { + if tap_key.phase == LatchPhase::Pressed { self.unregister_key( - hid_key, + tap_key.value.key, KeyboardEvent { pressed: false, - pos: source, + pos: tap_key.source, }, ); } + self.send_keyboard_report_with_resolved_modifiers(false).await; + } +} - // For the layer shape, deactivate the active layer before clearing the latch. - if let Some(ActiveStickyKey { - effect: ActiveEffect::Layer(layer_num), - .. - }) = self.sticky_key_state.active().copied() - { - self.keymap.deactivate_layer(layer_num); - } +#[cfg(test)] +mod tests { + use super::*; - self.sticky_key_state = StickyKeyState::None; - if needs_report { - self.send_keyboard_report_with_resolved_modifiers(false).await; + fn pos(col: u8) -> KeyboardEventPos { + KeyboardEventPos::key_pos(col, 0) + } + + fn policy(release_mode: StickyKeyReleaseMode) -> StickyKeyPolicy { + StickyKeyPolicy { + timeout: Duration::from_secs(1), + activate_on_keypress: false, + max_repeat: 0, + release_mode, } } + + #[test] + fn latch_counts_overlapping_physical_producers() { + let mut latch = Latch::new( + ModifierCombination::LCTRL, + pos(0), + policy(StickyKeyReleaseMode::OTHER_KEY_RELEASE), + ); + latch.begin_press(pos(1), policy(StickyKeyReleaseMode::OTHER_KEY_RELEASE)); + + assert_eq!(latch.on_physical_release(None), PhysicalRelease::Ignored); + assert_eq!(latch.phase, LatchPhase::Pressed); + assert_eq!(latch.on_physical_release(None), PhysicalRelease::Latched); + assert_eq!(latch.phase, LatchPhase::Latched); + } + + #[test] + fn held_latch_releases_after_last_physical_producer() { + let mut latch = Latch::new( + ModifierCombination::LCTRL, + pos(0), + policy(StickyKeyReleaseMode::OTHER_KEY_RELEASE), + ); + latch.begin_press(pos(1), policy(StickyKeyReleaseMode::OTHER_KEY_RELEASE)); + latch.mark_foreign_key(); + + assert_eq!(latch.on_physical_release(None), PhysicalRelease::Ignored); + assert_eq!(latch.on_physical_release(None), PhysicalRelease::Released); + } + + #[test] + fn timeout_is_deferred_while_physical_producer_is_down() { + let mut latch = Latch::new( + ModifierCombination::LCTRL, + pos(0), + policy(StickyKeyReleaseMode::OTHER_KEY_RELEASE), + ); + let deadline = latch.deadline.unwrap(); + + assert_eq!(latch.timeout_disposition(deadline), TimeoutDisposition::Deferred); + assert_eq!(latch.phase, LatchPhase::Pressed); + assert_eq!(latch.deadline, None); + } } diff --git a/rmk/src/keymap.rs b/rmk/src/keymap.rs index 539ed039d..6e6de826d 100644 --- a/rmk/src/keymap.rs +++ b/rmk/src/keymap.rs @@ -11,7 +11,7 @@ use { }; use crate::MACRO_SPACE_SIZE; -use crate::config::{BehaviorConfig, Hand, MouseKeyConfig, PositionalConfig, StickyKeyProfile, StickyKeyReleaseMode}; +use crate::config::{BehaviorConfig, Hand, MouseKeyConfig, PositionalConfig, StickyKeyReleaseMode}; use crate::event::{KeyboardEvent, KeyboardEventPos, LayerChangeEvent, publish_event}; use crate::input_device::rotary_encoder::Direction; use crate::keyboard::combo::Combo; @@ -28,6 +28,14 @@ pub(crate) enum StickyKeyShape { Layer, } +#[derive(Clone, Copy, Debug)] +pub(crate) struct StickyKeyPolicy { + pub timeout: Duration, + pub activate_on_keypress: bool, + pub max_repeat: u16, + pub release_mode: StickyKeyReleaseMode, +} + /// All allocated data needed to build a [`KeyMap`]. pub struct KeymapData { /// Per-layer key actions @@ -363,6 +371,7 @@ impl<'a> KeyMap<'a> { behavior: &'a mut BehaviorConfig, positional_config: &'a PositionalConfig, ) -> Self { + behavior.normalize_sticky_key_compat(); let layers = data.keymap.as_mut_slice().as_flattened_mut().as_flattened_mut(); let encoders = if NUM_ENCODER > 0 { Some(data.encoder_map.as_mut_slice().as_flattened_mut()) @@ -590,43 +599,23 @@ impl<'a> KeyMap<'a> { self.inner.borrow().behavior.sticky_key.default_profile.timeout } - pub(crate) fn sticky_key_profile(&self, index: u8, shape: StickyKeyShape) -> StickyKeyProfile { + pub(crate) fn sticky_key_profile(&self, index: u8, shape: StickyKeyShape) -> StickyKeyPolicy { let config = &self.inner.borrow().behavior.sticky_key; - if let Some(profile) = config.profiles.get(index as usize) { - return *profile; - } - let mut profile = config.default_profile; - // Keep the resolved default profile canonical. The remaining fields are - // a compatibility shim for Rust callers using the legacy struct-update - // API: only non-default legacy values override the canonical profile. - if config.timeout != Duration::from_secs(1) { - profile.timeout = config.timeout; - } - if config.activate_on_keypress { - profile.activate_on_keypress = true; - } - if config.max_repeat != 0 { - profile.max_repeat = config.max_repeat; - } - if profile.release_mode.is_none() { - let mut mode = 0; - if shape == StickyKeyShape::PureMod && config.quick_release { - mode |= StickyKeyReleaseMode::OTHER_KEY_PRESS.into_bits(); - } - let layer_release = match shape { - StickyKeyShape::PureMod => config.one_shot_mod_release_on_layer_change, - StickyKeyShape::Layer => config.one_shot_layer_release_on_layer_change, - StickyKeyShape::TapKey => config.tap_key_release_on_layer_change, - } - .unwrap_or(config.release_on_layer_change); - if layer_release { - mode |= StickyKeyReleaseMode::LAYER_ENTER.into_bits() | StickyKeyReleaseMode::LAYER_EXIT.into_bits(); - } - if mode != 0 { - profile.release_mode = Some(StickyKeyReleaseMode::from_bits(mode)); - } + let profile = config + .profiles + .get(index as usize) + .copied() + .unwrap_or(config.default_profile); + let release_mode = profile.release_mode.unwrap_or(match shape { + StickyKeyShape::TapKey => StickyKeyReleaseMode::OTHER_KEY_PRESS, + StickyKeyShape::PureMod | StickyKeyShape::Layer => StickyKeyReleaseMode::OTHER_KEY_RELEASE, + }); + StickyKeyPolicy { + timeout: profile.timeout, + activate_on_keypress: profile.activate_on_keypress, + max_repeat: profile.max_repeat, + release_mode, } - profile } pub(crate) fn tap_interval(&self) -> u16 { @@ -668,11 +657,7 @@ impl<'a> KeyMap<'a> { } pub(crate) fn set_sticky_key_timeout(&self, timeout: Duration) { - let mut inner = self.inner.borrow_mut(); - inner.behavior.sticky_key.default_profile.timeout = timeout; - // Keep the legacy Rust-API compatibility mirror synchronized so it - // cannot override a Vial runtime update during profile resolution. - inner.behavior.sticky_key.timeout = timeout; + self.inner.borrow_mut().behavior.sticky_key.default_profile.timeout = timeout; } pub(crate) fn set_tap_interval(&self, interval: u16) { @@ -972,7 +957,7 @@ mod test { fn runtime_timeout_update_changes_the_canonical_default_profile() { let mut data = KeymapData::<1, 1, 1>::new([[[k!(A)]]]); let mut behavior = BehaviorConfig::default(); - behavior.sticky_key.timeout = Duration::from_millis(50); + behavior.sticky_key.default_profile.timeout = Duration::from_millis(50); let positional = PositionalConfig::<1, 1>::default(); let keymap = KeyMap::build(&mut data, &mut behavior, &positional); @@ -989,8 +974,8 @@ mod test { fn named_profiles_ignore_legacy_default_overrides() { let mut data = KeymapData::<1, 1, 1>::new([[[k!(A)]]]); let mut behavior = BehaviorConfig::default(); - behavior.sticky_key.timeout = Duration::from_millis(50); - behavior.sticky_key.quick_release = true; + behavior.one_shot.timeout = Duration::from_millis(50); + behavior.one_shot_modifiers.quick_release = true; behavior .sticky_key .profiles @@ -1007,23 +992,19 @@ mod test { let profile = keymap.sticky_key_profile(0, StickyKeyShape::PureMod); assert_eq!(profile.timeout, Duration::from_millis(900)); assert_eq!(profile.max_repeat, 4); - assert_eq!(profile.release_mode, Some(StickyKeyReleaseMode::OTHER_KEY_RELEASE)); + assert_eq!(profile.release_mode, StickyKeyReleaseMode::OTHER_KEY_RELEASE); } #[test] - fn legacy_release_overrides_are_shape_specific() { + fn release_defaults_are_shape_specific() { let mut data = KeymapData::<1, 1, 1>::new([[[k!(A)]]]); let mut behavior = BehaviorConfig::default(); - behavior.sticky_key.one_shot_mod_release_on_layer_change = Some(true); - behavior.sticky_key.tap_key_release_on_layer_change = Some(false); let positional = PositionalConfig::<1, 1>::default(); let keymap = KeyMap::build(&mut data, &mut behavior, &positional); let pure_mod = keymap.sticky_key_profile(u8::MAX, StickyKeyShape::PureMod); let tap_key = keymap.sticky_key_profile(u8::MAX, StickyKeyShape::TapKey); - assert!(pure_mod.release_mode.is_some_and(|mode| { - mode.contains(StickyKeyReleaseMode::LAYER_ENTER) && mode.contains(StickyKeyReleaseMode::LAYER_EXIT) - })); - assert_eq!(tap_key.release_mode, None); + assert_eq!(pure_mod.release_mode, StickyKeyReleaseMode::OTHER_KEY_RELEASE); + assert_eq!(tap_key.release_mode, StickyKeyReleaseMode::OTHER_KEY_PRESS); } } diff --git a/rmk/src/storage/mod.rs b/rmk/src/storage/mod.rs index 973884d91..850080df7 100644 --- a/rmk/src/storage/mod.rs +++ b/rmk/src/storage/mod.rs @@ -515,7 +515,6 @@ impl Keyboard<'static> { + fn create_test_keyboard_with_one_shot_modifiers_config(config: OneShotModifiersConfig) -> Keyboard<'static> { let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig { - sticky_key: config, + one_shot_modifiers: config, ..BehaviorConfig::default() })); let per_key_config: &'static PositionalConfig<1, 6> = Box::leak(Box::new(PositionalConfig::default())); @@ -109,9 +109,9 @@ mod one_shot_test { key_sequence_test! { keyboard: create_test_keyboard_with_behavior_config( BehaviorConfig { - sticky_key: StickyKeyConfig { + one_shot: OneShotConfig { timeout: Duration::from_millis(100), - ..StickyKeyConfig::default() + ..OneShotConfig::default() }, ..BehaviorConfig::default() } @@ -328,9 +328,9 @@ mod one_shot_test { #[test] fn test_osm_activate_on_keypress() { key_sequence_test! { - keyboard: create_test_keyboard_with_sticky_key_config(StickyKeyConfig { + keyboard: create_test_keyboard_with_one_shot_modifiers_config(OneShotModifiersConfig { activate_on_keypress: true, - ..StickyKeyConfig::default() + ..OneShotModifiersConfig::default() }), sequence: [ [0, 0, true, 10], // Press OSM LShift @@ -365,9 +365,9 @@ mod one_shot_test { #[test] fn test_osm_combined_modifiers_with_activate_on_keypress() { key_sequence_test! { - keyboard: create_test_keyboard_with_sticky_key_config(StickyKeyConfig { + keyboard: create_test_keyboard_with_one_shot_modifiers_config(OneShotModifiersConfig { activate_on_keypress: true, - ..StickyKeyConfig::default() + ..OneShotModifiersConfig::default() }), sequence: [ // Press and Release OSM LShift @@ -429,9 +429,9 @@ mod one_shot_test { key_sequence_test! { keyboard: create_test_keyboard_with_behavior_config( BehaviorConfig { - sticky_key: StickyKeyConfig { + one_shot: OneShotConfig { timeout: Duration::from_millis(100), - ..StickyKeyConfig::default() + ..OneShotConfig::default() }, ..BehaviorConfig::default() } @@ -513,9 +513,9 @@ mod one_shot_test { key_sequence_test! { keyboard: create_test_keyboard_with_behavior_config( BehaviorConfig { - sticky_key: StickyKeyConfig { + one_shot: OneShotConfig { timeout: Duration::from_millis(100), - ..StickyKeyConfig::default() + ..OneShotConfig::default() }, ..BehaviorConfig::default() } @@ -539,9 +539,9 @@ mod one_shot_test { #[test] fn test_osm_chain_mode_basic() { key_sequence_test! { - keyboard: create_test_keyboard_with_sticky_key_config(StickyKeyConfig { + keyboard: create_test_keyboard_with_one_shot_modifiers_config(OneShotModifiersConfig { quick_release: false, - ..StickyKeyConfig::default() + ..OneShotModifiersConfig::default() }), sequence: [ [0, 0, true, 10], // Press OSM LShift @@ -560,9 +560,9 @@ mod one_shot_test { #[test] fn test_osm_chain_mode_multiple_keys() { key_sequence_test! { - keyboard: create_test_keyboard_with_sticky_key_config(StickyKeyConfig { + keyboard: create_test_keyboard_with_one_shot_modifiers_config(OneShotModifiersConfig { quick_release: false, - ..StickyKeyConfig::default() + ..OneShotModifiersConfig::default() }), sequence: [ [0, 0, true, 10], // Press OSM LShift @@ -585,10 +585,10 @@ mod one_shot_test { #[test] fn test_osm_chain_mode_activate_on_keypress() { key_sequence_test! { - keyboard: create_test_keyboard_with_sticky_key_config(StickyKeyConfig { + keyboard: create_test_keyboard_with_one_shot_modifiers_config(OneShotModifiersConfig { activate_on_keypress: true, quick_release: false, - ..StickyKeyConfig::default() + ..OneShotModifiersConfig::default() }), sequence: [ [0, 0, true, 10], // Press OSM LShift @@ -609,9 +609,9 @@ mod one_shot_test { #[test] fn test_osm_quick_release_basic() { key_sequence_test! { - keyboard: create_test_keyboard_with_sticky_key_config(StickyKeyConfig { + keyboard: create_test_keyboard_with_one_shot_modifiers_config(OneShotModifiersConfig { quick_release: true, - ..StickyKeyConfig::default() + ..OneShotModifiersConfig::default() }), sequence: [ [0, 0, true, 10], // Press OSM LShift @@ -630,9 +630,9 @@ mod one_shot_test { #[test] fn test_osm_quick_release_multiple_keys() { key_sequence_test! { - keyboard: create_test_keyboard_with_sticky_key_config(StickyKeyConfig { + keyboard: create_test_keyboard_with_one_shot_modifiers_config(OneShotModifiersConfig { quick_release: true, - ..StickyKeyConfig::default() + ..OneShotModifiersConfig::default() }), sequence: [ [0, 0, true, 10], // Press OSM LShift @@ -658,9 +658,9 @@ mod one_shot_test { #[test] fn test_osm_quick_release_combined_modifiers() { key_sequence_test! { - keyboard: create_test_keyboard_with_sticky_key_config(StickyKeyConfig { + keyboard: create_test_keyboard_with_one_shot_modifiers_config(OneShotModifiersConfig { quick_release: true, - ..StickyKeyConfig::default() + ..OneShotModifiersConfig::default() }), sequence: [ [0, 0, true, 10], // Press OSM LShift @@ -681,9 +681,9 @@ mod one_shot_test { #[test] fn test_osm_quick_release_with_wm() { key_sequence_test! { - keyboard: create_test_keyboard_with_sticky_key_config(StickyKeyConfig { + keyboard: create_test_keyboard_with_one_shot_modifiers_config(OneShotModifiersConfig { quick_release: true, - ..StickyKeyConfig::default() + ..OneShotModifiersConfig::default() }), sequence: [ [0, 0, true, 10], // Press OSM LShift @@ -704,10 +704,10 @@ mod one_shot_test { #[test] fn test_osm_quick_release_activate_on_keypress() { key_sequence_test! { - keyboard: create_test_keyboard_with_sticky_key_config(StickyKeyConfig { + keyboard: create_test_keyboard_with_one_shot_modifiers_config(OneShotModifiersConfig { activate_on_keypress: true, quick_release: true, - ..StickyKeyConfig::default() + ..OneShotModifiersConfig::default() }), sequence: [ [0, 0, true, 10], // Press OSM LShift @@ -727,10 +727,10 @@ mod one_shot_test { #[test] fn test_osm_quick_release_combined_activate_on_keypress() { key_sequence_test! { - keyboard: create_test_keyboard_with_sticky_key_config(StickyKeyConfig { + keyboard: create_test_keyboard_with_one_shot_modifiers_config(OneShotModifiersConfig { activate_on_keypress: true, quick_release: true, - ..StickyKeyConfig::default() + ..OneShotModifiersConfig::default() }), sequence: [ [0, 0, true, 10], // Press OSM LShift diff --git a/rmk/tests/keyboard_sticky_key_test.rs b/rmk/tests/keyboard_sticky_key_test.rs index 42c104941..4176fe8ea 100644 --- a/rmk/tests/keyboard_sticky_key_test.rs +++ b/rmk/tests/keyboard_sticky_key_test.rs @@ -1,7 +1,9 @@ pub mod common; use embassy_time::Duration; -use rmk::config::{BehaviorConfig, PositionalConfig, StickyKeyConfig, StickyKeyProfile, StickyKeyReleaseMode}; +use rmk::config::{ + BehaviorConfig, OneShotModifiersConfig, PositionalConfig, StickyKeyConfig, StickyKeyProfile, StickyKeyReleaseMode, +}; use rmk::keyboard::Keyboard; use rmk::types::action::KeyAction; use rmk::types::modifier::ModifierCombination; @@ -90,6 +92,12 @@ fn sticky_key_config_with_release_mode(release_mode: StickyKeyReleaseMode) -> St } } +fn layer_change_release_mode() -> StickyKeyReleaseMode { + StickyKeyReleaseMode::from_bits( + StickyKeyReleaseMode::LAYER_ENTER.into_bits() | StickyKeyReleaseMode::LAYER_EXIT.into_bits(), + ) +} + // KEYMAP_MIXED: all three SK shapes on layer 0, used to exercise the mutually-exclusive // latch (pressing a different-shape SK while one is latched REPLACES it, never merges). // Layer 0: SK(LGui) SK(Tab,LAlt) SK(MO(1)) P No No @@ -161,7 +169,10 @@ fn create_test_keyboard() -> Keyboard<'static> { static BEHAVIOR_CONFIG: static_cell::StaticCell = static_cell::StaticCell::new(); let behavior_config = BEHAVIOR_CONFIG.init(BehaviorConfig { sticky_key: StickyKeyConfig { - release_on_layer_change: true, + default_profile: StickyKeyProfile { + release_mode: Some(layer_change_release_mode()), + ..StickyKeyProfile::default() + }, ..StickyKeyConfig::default() }, ..BehaviorConfig::default() @@ -174,7 +185,10 @@ fn create_test_keyboard() -> Keyboard<'static> { fn create_test_keyboard_max_repeat() -> Keyboard<'static> { let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig { sticky_key: StickyKeyConfig { - max_repeat: 2, + default_profile: StickyKeyProfile { + max_repeat: 2, + ..StickyKeyProfile::default() + }, ..StickyKeyConfig::default() }, ..BehaviorConfig::default() @@ -195,19 +209,16 @@ fn create_test_keyboard_with_behavior_config(config: BehaviorConfig) -> Keyboard Keyboard::new(wrap_keymap(KEYMAP, per_key_config, behavior_config)) } -#[test] -fn sticky_key_config_reserves_the_bounded_profile_table() { - assert!(core::mem::size_of::() >= core::mem::size_of::()); -} - /// A tap-key override can enable layer-change release while the global fallback is disabled. #[test] fn tap_key_layer_change_override_enables_release() { key_sequence_test! { keyboard: create_test_keyboard_with_behavior_config(BehaviorConfig { sticky_key: StickyKeyConfig { - release_on_layer_change: false, - tap_key_release_on_layer_change: Some(true), + default_profile: StickyKeyProfile { + release_mode: Some(layer_change_release_mode()), + ..StickyKeyProfile::default() + }, ..StickyKeyConfig::default() }, ..BehaviorConfig::default() @@ -232,8 +243,7 @@ fn tap_key_layer_change_override_disables_release() { key_sequence_test! { keyboard: create_test_keyboard_with_behavior_config(BehaviorConfig { sticky_key: StickyKeyConfig { - release_on_layer_change: true, - tap_key_release_on_layer_change: Some(false), + default_profile: StickyKeyProfile::default(), ..StickyKeyConfig::default() }, ..BehaviorConfig::default() @@ -261,8 +271,6 @@ fn tap_key_layer_change_override_disables_release() { fn pure_mod_layer_change_override_disables_release() { key_sequence_test! { keyboard: create_pure_mod_layer_change_keyboard(StickyKeyConfig { - release_on_layer_change: true, - one_shot_mod_release_on_layer_change: Some(false), ..StickyKeyConfig::default() }), sequence: [ @@ -285,8 +293,10 @@ fn pure_mod_layer_change_override_disables_release() { fn pure_mod_layer_change_override_enables_release() { key_sequence_test! { keyboard: create_pure_mod_layer_change_keyboard(StickyKeyConfig { - release_on_layer_change: false, - one_shot_mod_release_on_layer_change: Some(true), + default_profile: StickyKeyProfile { + release_mode: Some(layer_change_release_mode()), + ..StickyKeyProfile::default() + }, ..StickyKeyConfig::default() }), sequence: [ @@ -309,8 +319,6 @@ fn pure_mod_layer_change_override_enables_release() { fn osl_layer_change_override_disables_release() { key_sequence_test! { keyboard: create_osl_layer_change_keyboard(StickyKeyConfig { - release_on_layer_change: true, - one_shot_layer_release_on_layer_change: Some(false), ..StickyKeyConfig::default() }), sequence: [ @@ -333,8 +341,10 @@ fn osl_layer_change_override_disables_release() { fn osl_layer_change_override_enables_release() { key_sequence_test! { keyboard: create_osl_layer_change_keyboard(StickyKeyConfig { - release_on_layer_change: false, - one_shot_layer_release_on_layer_change: Some(true), + default_profile: StickyKeyProfile { + release_mode: Some(layer_change_release_mode()), + ..StickyKeyProfile::default() + }, ..StickyKeyConfig::default() }), sequence: [ @@ -528,8 +538,11 @@ fn test_sk_timeout() { key_sequence_test! { keyboard: create_test_keyboard_with_behavior_config(BehaviorConfig { sticky_key: StickyKeyConfig { - timeout: Duration::from_millis(100), - release_on_layer_change: true, + default_profile: StickyKeyProfile { + timeout: Duration::from_millis(100), + release_mode: Some(layer_change_release_mode()), + ..StickyKeyProfile::default() + }, ..StickyKeyConfig::default() }, ..BehaviorConfig::default() @@ -569,8 +582,11 @@ fn test_sk_timeout_resets_on_press() { key_sequence_test! { keyboard: create_test_keyboard_with_behavior_config(BehaviorConfig { sticky_key: StickyKeyConfig { - timeout: Duration::from_millis(100), - release_on_layer_change: true, + default_profile: StickyKeyProfile { + timeout: Duration::from_millis(100), + release_mode: Some(layer_change_release_mode()), + ..StickyKeyProfile::default() + }, ..StickyKeyConfig::default() }, ..BehaviorConfig::default() @@ -916,8 +932,11 @@ fn test_sk_tap_key_ignores_activate_on_keypress() { key_sequence_test! { keyboard: create_test_keyboard_with_behavior_config(BehaviorConfig { sticky_key: StickyKeyConfig { - activate_on_keypress: true, // pure-mod-only knob — must be ignored here - release_on_layer_change: true, // match create_test_keyboard so MO release cleans up + default_profile: StickyKeyProfile { + activate_on_keypress: true, // pure-mod-only knob — must be ignored here + release_mode: Some(layer_change_release_mode()), + ..StickyKeyProfile::default() + }, ..StickyKeyConfig::default() }, ..BehaviorConfig::default() @@ -953,7 +972,10 @@ const KEYMAP_PUREMOD_SK: [[[KeyAction; 6]; 1]; 1] = [[[ fn create_test_keyboard_puremod_sk() -> Keyboard<'static> { let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig { sticky_key: StickyKeyConfig { - timeout: Duration::from_millis(10), + default_profile: StickyKeyProfile { + timeout: Duration::from_millis(10), + ..StickyKeyProfile::default() + }, ..StickyKeyConfig::default() }, ..BehaviorConfig::default() @@ -973,10 +995,39 @@ const KEYMAP_PROFILED_PURE_MODS: [[[KeyAction; 3]; 1]; 1] = [[[ k!(A), ]]]; +// Modifier and layer effects use distinct profiles and remain independently +// active. Layer 1 changes the detector key from A to B. +const KEYMAP_PROFILED_MOD_AND_LAYER: [[[KeyAction; 3]; 1]; 2] = [ + [[sk_mod!(ModifierCombination::LSHIFT, 0), sk_layer!(1, 1), k!(A)]], + [[a!(Transparent), a!(Transparent), k!(B)]], +]; + +fn create_profiled_mod_and_layer_keyboard( + modifier_profile: StickyKeyProfile, + layer_profile: StickyKeyProfile, +) -> Keyboard<'static> { + let mut sticky_key = StickyKeyConfig::default(); + sticky_key.profiles.push(modifier_profile).unwrap(); + sticky_key.profiles.push(layer_profile).unwrap(); + let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig { + sticky_key, + ..BehaviorConfig::default() + })); + let per_key_config: &'static PositionalConfig<1, 3> = Box::leak(Box::new(PositionalConfig::default())); + Keyboard::new(wrap_keymap( + KEYMAP_PROFILED_MOD_AND_LAYER, + per_key_config, + behavior_config, + )) +} + fn create_test_keyboard_tap_sk() -> Keyboard<'static> { let behavior_config: &'static mut BehaviorConfig = Box::leak(Box::new(BehaviorConfig { sticky_key: StickyKeyConfig { - timeout: Duration::from_millis(10), + default_profile: StickyKeyProfile { + timeout: Duration::from_millis(10), + ..StickyKeyProfile::default() + }, ..StickyKeyConfig::default() }, ..BehaviorConfig::default() @@ -1085,6 +1136,91 @@ fn latest_accumulated_pure_mod_profile_owns_release_behavior() { }; } +#[test] +fn modifier_and_layer_keep_independent_release_policies() { + key_sequence_test! { + keyboard: create_profiled_mod_and_layer_keyboard( + StickyKeyProfile { + release_mode: Some(StickyKeyReleaseMode::OTHER_KEY_PRESS), + ..StickyKeyProfile::default() + }, + StickyKeyProfile { + release_mode: Some(StickyKeyReleaseMode::OTHER_KEY_RELEASE), + ..StickyKeyProfile::default() + }, + ), + sequence: [ + [0, 0, true, 0], + [0, 0, false, 0], + [0, 1, true, 0], + [0, 1, false, 0], + [0, 2, true, 0], + [0, 2, false, 0], + ], + expected_reports: [ + [KC_LSHIFT, [kc_to_u8!(B), 0, 0, 0, 0, 0]], + [0, [kc_to_u8!(B), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + ] + }; +} + +#[test] +fn modifier_timeout_does_not_expire_sticky_layer() { + key_sequence_test! { + keyboard: create_profiled_mod_and_layer_keyboard( + StickyKeyProfile { + timeout: Duration::from_millis(20), + ..StickyKeyProfile::default() + }, + StickyKeyProfile { + timeout: Duration::from_millis(200), + ..StickyKeyProfile::default() + }, + ), + sequence: [ + [0, 0, true, 0], + [0, 0, false, 0], + [0, 1, true, 0], + [0, 1, false, 40], + [0, 2, true, 0], + [0, 2, false, 0], + ], + expected_reports: [ + [0, [kc_to_u8!(B), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + ] + }; +} + +#[test] +fn layer_timeout_does_not_expire_sticky_modifier() { + key_sequence_test! { + keyboard: create_profiled_mod_and_layer_keyboard( + StickyKeyProfile { + timeout: Duration::from_millis(200), + ..StickyKeyProfile::default() + }, + StickyKeyProfile { + timeout: Duration::from_millis(20), + ..StickyKeyProfile::default() + }, + ), + sequence: [ + [0, 0, true, 0], + [0, 0, false, 0], + [0, 1, true, 0], + [0, 1, false, 40], + [0, 2, true, 0], + [0, 2, false, 0], + ], + expected_reports: [ + [KC_LSHIFT, [kc_to_u8!(B), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + ] + }; +} + // KEYMAP_TWO_TAP_SK: two tap-key SKs for verifying that a second physical key replaces the // first latch instead of reusing its key and modifiers. const KEYMAP_TWO_TAP_SK: [[[KeyAction; 2]; 1]; 1] = [[[ @@ -1194,9 +1330,15 @@ fn test_second_tap_sk_replaces_first_while_held() { fn test_sk_tap_key_ignores_quick_release() { key_sequence_test! { keyboard: create_test_keyboard_with_behavior_config(BehaviorConfig { + one_shot_modifiers: OneShotModifiersConfig { + quick_release: true, + ..OneShotModifiersConfig::default() + }, sticky_key: StickyKeyConfig { - quick_release: true, // pure-mod-only knob — must be ignored here - release_on_layer_change: true, // match create_test_keyboard so MO release cleans up + default_profile: StickyKeyProfile { + release_mode: Some(layer_change_release_mode()), + ..StickyKeyProfile::default() + }, ..StickyKeyConfig::default() }, ..BehaviorConfig::default() From 1cdac47c6bd86890d587b245d65860f7c99996f6 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:04:16 -0500 Subject: [PATCH 104/119] fix(sticky-key): harden lifecycle correctness --- docs/docs/main/docs/configuration/event.md | 2 +- rmk-config/src/keymap.pest | 2 +- rmk-config/src/resolved/build_constants.rs | 55 +++++++ rmk-macro/src/codegen/action_parser.rs | 169 ++++++++++++++++++--- rmk/src/config/behavior.rs | 4 +- rmk/src/event/state.rs | 16 +- rmk/src/keyboard.rs | 60 +++++--- rmk/src/keyboard/auto_mouse_layer.rs | 8 +- rmk/src/keyboard/sticky_key.rs | 123 +++++++++++---- rmk/src/keymap.rs | 5 +- 10 files changed, 360 insertions(+), 84 deletions(-) diff --git a/docs/docs/main/docs/configuration/event.md b/docs/docs/main/docs/configuration/event.md index 6403e4ebf..dcf32922f 100644 --- a/docs/docs/main/docs/configuration/event.md +++ b/docs/docs/main/docs/configuration/event.md @@ -55,7 +55,7 @@ peripheral_battery.subs = 4 | `pointing` | `PointingEvent` | channel_size=8 | | **State Events** | | | | `layer_change` | `LayerChangeEvent` | subs=4 | -| `layer_transition` | Internal layer transition | channel_size=2 | +| `layer_transition` | Internal layer transition | channel_size=2; `channel_size` and `subs` must be at least 1 because the keyboard consumes it. `pubs` must be at least 1 when auto-mouse layers are configured. | | `wpm_update` | `WpmUpdateEvent` | | | `led_indicator` | `LedIndicatorEvent` | | | `sleep_state` | `SleepStateEvent` | | diff --git a/rmk-config/src/keymap.pest b/rmk-config/src/keymap.pest index 2852d2bb5..36a60eafb 100644 --- a/rmk-config/src/keymap.pest +++ b/rmk-config/src/keymap.pest @@ -99,7 +99,7 @@ layer_action = _{ // accepted as a slot argument. nestable_action = _{ wm_action | osm_action | shifted_action | trigger_macro_action | alias_ref | - df_action | mo_action | lm_action | osl_action | tg_action | to_action | + df_action | mo_action | lm_action | osl_action | tg_action | to_action | sk_action | keycode_name } diff --git a/rmk-config/src/resolved/build_constants.rs b/rmk-config/src/resolved/build_constants.rs index 6e8850afd..b7922ca15 100644 --- a/rmk-config/src/resolved/build_constants.rs +++ b/rmk-config/src/resolved/build_constants.rs @@ -187,6 +187,34 @@ impl crate::KeyboardTomlConfig { } } + // The keyboard always subscribes to this internal event. Auto-mouse + // additionally publishes it when configured, so validate the generated + // pubsub resources at their configuration boundary rather than panicking + // during runtime initialization. + let layer_transition = events + .iter() + .find(|event| event.name == "layer_transition") + .expect("layer_transition is a built-in event"); + if layer_transition.channel_size == 0 { + return Err("[event.layer_transition].channel_size must be at least 1".to_string()); + } + if layer_transition.subs == 0 { + return Err( + "[event.layer_transition].subs must be at least 1 because Keyboard subscribes to it".to_string(), + ); + } + let auto_mouse_configured = self + .behavior + .as_ref() + .and_then(|behavior| behavior.auto_mouse_layer.as_ref()) + .is_some_and(|entries| !entries.is_empty()); + if auto_mouse_configured && layer_transition.pubs == 0 { + return Err( + "[event.layer_transition].pubs must be at least 1 when [[behavior.auto_mouse_layer]] is configured" + .to_string(), + ); + } + Ok(BuildConstants { combo_max_num: rmk.combo_max_num, combo_max_length: rmk.combo_max_length, @@ -350,4 +378,31 @@ mod tests { let toml = "[event.action]\nchannel_size = 16\npubs = 1\nsubs = 1\n\n[[behavior.auto_mouse_layer]]\ntarget_layer = 1\ndeactivate_on_key = true\n"; assert!(parse(toml).build_constants(&[]).is_ok()); } + + #[test] + fn layer_transition_requires_keyboard_resources() { + for field in ["channel_size", "subs"] { + let toml = if field == "channel_size" { + "[event.layer_transition]\nchannel_size = 0\npubs = 1\nsubs = 1\n" + } else { + "[event.layer_transition]\nchannel_size = 2\npubs = 1\nsubs = 0\n" + }; + let err = match parse(&toml).build_constants(&[]) { + Err(err) => err, + Ok(_) => panic!("expected layer-transition validation failure"), + }; + assert!(err.contains("[event.layer_transition]")); + assert!(err.contains(field)); + } + } + + #[test] + fn auto_mouse_requires_layer_transition_publisher() { + let toml = "[event.layer_transition]\nchannel_size = 1\npubs = 0\nsubs = 1\n\n[[behavior.auto_mouse_layer]]\ntarget_layer = 1\n"; + let err = match parse(toml).build_constants(&[]) { + Err(err) => err, + Ok(_) => panic!("expected layer-transition publisher validation failure"), + }; + assert!(err.contains("[event.layer_transition].pubs")); + } } diff --git a/rmk-macro/src/codegen/action_parser.rs b/rmk-macro/src/codegen/action_parser.rs index 7502525a1..c769d9d20 100644 --- a/rmk-macro/src/codegen/action_parser.rs +++ b/rmk-macro/src/codegen/action_parser.rs @@ -221,6 +221,103 @@ fn strip_call(s: &str) -> &str { }) } +/// Parse all user-facing Sticky Key spellings into the one nested `Action`. +/// `parse_key` wraps this action for top-level use; tap-hold slots use it directly. +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 effect = match alias { + Some("modifier") => { + let modifiers = parse_modifiers(&action); + if modifiers.is_empty() { + panic!( + "\n❌ keyboard.toml: OSM(modifier) is not valid; use OSM(LGui) or OSM(LCtrl | LShift)" + ); + } + quote! { ::rmk::types::action::StickyKeyEffect::Modifier(#modifiers) } + } + Some("layer") => { + let layer = action.parse::().unwrap(); + quote! { ::rmk::types::action::StickyKeyEffect::Layer(#layer) } + } + None if action.to_lowercase().starts_with("mo(") => { + let layer = parse_layer(&action); + quote! { ::rmk::types::action::StickyKeyEffect::Layer(#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::StickyKeyEffect::TapKey { key: ::rmk::types::keycode::HidKeyCode::#key_ident, modifiers: #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; use SK(LGui), SK(Tab, [LAlt]), or SK(MO(n))." + ); + } + quote! { ::rmk::types::action::StickyKeyEffect::Modifier(#modifiers) } + } + _ => unreachable!(), + }; + Some(quote! { + ::rmk::types::action::Action::StickyKey(::rmk::types::action::StickyKeyAction { + effect: #effect, + profile: #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 @@ -228,10 +325,15 @@ 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`]. -fn parse_action(key: &str) -> TokenStream2 { +fn parse_action( + key: &str, + sticky_profiles: &Option>, +) -> TokenStream2 { let lower = key.to_lowercase(); - if lower == "no" { + if let Some(action) = parse_sticky_action(key, sticky_profiles) { + return action; + } else if lower == "no" { return quote! { ::rmk::types::action::Action::No }; } else if lower.starts_with("wm(") { let keys = split_top_level(strip_call(key)); @@ -395,6 +497,10 @@ pub(crate) fn parse_key( let lower = key.to_lowercase(); + if let Some(action) = parse_sticky_action(&key, sticky_profiles) { + return quote! { ::rmk::types::action::KeyAction::Single(#action) }; + } + if lower.starts_with("mt(") { let keys = split_top_level(strip_call(&key)); if keys.len() < 2 || keys.len() > 3 { @@ -402,7 +508,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(&keys[0], sticky_profiles); let modifiers = parse_modifiers(&keys[1]); if modifiers.is_empty() { panic!( @@ -420,8 +526,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(&keys[0], sticky_profiles); + let hold = parse_action(&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(") { @@ -432,7 +538,7 @@ pub(crate) fn parse_key( ); } let layer = keys[0].parse::().unwrap(); - let tap = parse_action(&keys[1]); + let tap = parse_action(&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) @@ -542,7 +648,7 @@ pub(crate) fn parse_key( quote! { ::rmk::sk_mod!(#modifiers, #profile) } } } else { - let action = parse_action(&key); + let action = parse_action(&key, sticky_profiles); quote! { ::rmk::types::action::KeyAction::Single(#action) } } } @@ -626,9 +732,7 @@ mod tests { .contains("KeyAction::Single(::rmk::types::action::Action::LayerOn(1u8))") ); assert!(squash(&expand("WM(C,LCtrl)")).contains("Action::KeyWithModifier")); - // OSM/OSL are now aliases for the unified sticky key, so they desugar to - // `sk_mod!`/`sk_layer!` rather than the removed `OneShotModifier` variant. - assert!(squash(&expand("OSM(LShift)")).contains("::rmk::sk_mod!")); + assert!(squash(&expand("OSM(LShift)")).contains("Action::StickyKey")); } #[test] @@ -655,6 +759,25 @@ mod tests { assert!(out.contains("Action::LayerOn(1u8)")); } + #[test] + fn sticky_key_is_a_single_action_at_top_level_and_when_nested() { + let top = squash(&expand("SK(Tab, [LAlt])")); + assert!(top.contains("KeyAction::Single(::rmk::types::action::Action::StickyKey")); + assert!(!top.contains("KeyAction::Single(::rmk::types::action::KeyAction::Single")); + + let nested = squash(&expand("TH(SK(LShift), SK(MO(1)))")); + assert_eq!(nested.matches("Action::StickyKey").count(), 2); + assert!(nested.contains("StickyKeyEffect::Modifier")); + assert!(nested.contains("StickyKeyEffect::Layer(1u8)")); + } + + #[test] + fn nested_sticky_aliases_use_the_same_action() { + let canonical = squash(&expand("TH(SK(LShift), SK(MO(1)))")); + let aliases = squash(&expand("TH(OSM(LShift), OSL(1))")); + assert_eq!(canonical, aliases); + } + #[test] fn plain_mt_th_lt_still_expand() { assert!( @@ -668,16 +791,20 @@ mod tests { } /// OSM(modifier)/OSL(n) are aliases that must expand to the exact same - /// action tokens as their SK equivalents (sk_mod! / sk_layer!). + /// sticky-key action tokens as their canonical equivalents. #[test] fn osm_osl_aliases_match_sk_tokens() { - // (alias form, canonical SK form, macro the action must emit) + // (alias form, canonical SK form, effect the action must emit) let cases = [ - ("OSM(LGui)", "SK(LGui)", "sk_mod"), - ("OSM(LCtrl | LShift)", "SK(LCtrl | LShift)", "sk_mod"), - ("osm(lalt)", "sk(lalt)", "sk_mod"), - ("OSL(1)", "SK(MO(1))", "sk_layer"), - ("OSL(3)", "SK(MO(3))", "sk_layer"), + ("OSM(LGui)", "SK(LGui)", "StickyKeyEffect::Modifier"), + ( + "OSM(LCtrl | LShift)", + "SK(LCtrl | LShift)", + "StickyKeyEffect::Modifier", + ), + ("osm(lalt)", "sk(lalt)", "StickyKeyEffect::Modifier"), + ("OSL(1)", "SK(MO(1))", "StickyKeyEffect::Layer"), + ("OSL(3)", "SK(MO(3))", "StickyKeyEffect::Layer"), ]; for (alias, sk, expected_macro) in cases { @@ -690,8 +817,8 @@ mod tests { // ...and the shared expansion is the real sticky-key action, not // just two strings that happen to match. assert!( - alias_tokens.contains(expected_macro), - "{alias} should emit {expected_macro}!, got: {alias_tokens}" + squash(&alias_tokens).contains(expected_macro), + "{alias} should emit {expected_macro}, got: {alias_tokens}" ); } } @@ -728,11 +855,11 @@ mod tests { assert!( squash(&parse_key("SK(LShift, @alpha)".into(), &None, &profiles).to_string()) - .contains(",0u8") + .contains("profile:0u8") ); assert!( squash(&parse_key("SK(LShift, @zebra)".into(), &None, &profiles).to_string()) - .contains(",1u8") + .contains("profile:1u8") ); } } diff --git a/rmk/src/config/behavior.rs b/rmk/src/config/behavior.rs index d6669127b..dea9cf04c 100644 --- a/rmk/src/config/behavior.rs +++ b/rmk/src/config/behavior.rs @@ -240,7 +240,9 @@ impl BehaviorConfig { /// `sticky_key`, so there is no mirrored mutable state to synchronize. pub(crate) fn normalize_sticky_key_compat(&mut self) { let legacy_timeout = OneShotConfig::default().timeout; - if self.one_shot.timeout != legacy_timeout { + if self.one_shot.timeout != legacy_timeout + && self.sticky_key.default_profile.timeout == StickyKeyProfile::default().timeout + { self.sticky_key.default_profile.timeout = self.one_shot.timeout; } if self.one_shot_modifiers.activate_on_keypress { diff --git a/rmk/src/event/state.rs b/rmk/src/event/state.rs index 467d4eee3..5d87c105b 100644 --- a/rmk/src/event/state.rs +++ b/rmk/src/event/state.rs @@ -1,5 +1,6 @@ //! Keyboard state events +use embassy_time::Instant; use rmk_macro::event; use rmk_types::led_indicator::LedIndicator; @@ -27,9 +28,20 @@ pub(crate) enum LayerTransition { /// A layer transition produced outside the main keyboard action loop. #[event(channel_size = crate::LAYER_TRANSITION_EVENT_CHANNEL_SIZE, pubs = crate::LAYER_TRANSITION_EVENT_PUB_SIZE, subs = crate::LAYER_TRANSITION_EVENT_SUB_SIZE)] #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct LayerTransitionEvent(pub LayerTransition); +pub(crate) struct LayerTransitionEvent { + pub(crate) transition: LayerTransition, + /// Timestamp at which the layer state was actually changed. + pub(crate) occurred_at: Instant, +} -impl_payload_wrapper!(LayerTransitionEvent, LayerTransition); +impl LayerTransitionEvent { + pub(crate) fn new(transition: LayerTransition) -> Self { + Self { + transition, + occurred_at: Instant::now(), + } + } +} /// WPM updated event #[event(channel_size = crate::WPM_UPDATE_EVENT_CHANNEL_SIZE, pubs = crate::WPM_UPDATE_EVENT_PUB_SIZE, subs = crate::WPM_UPDATE_EVENT_SUB_SIZE)] diff --git a/rmk/src/keyboard.rs b/rmk/src/keyboard.rs index b42dd24d5..47f573885 100644 --- a/rmk/src/keyboard.rs +++ b/rmk/src/keyboard.rs @@ -175,10 +175,13 @@ impl Runnable for Keyboard<'_> { self.process_inner(event).await; } Either3::Second(layer_event) => { - self.release_sticky_key_on_layer_event(match layer_event.0 { - LayerTransition::Enter => crate::config::StickyKeyReleaseMode::LAYER_ENTER, - LayerTransition::Exit => crate::config::StickyKeyReleaseMode::LAYER_EXIT, - }) + self.release_sticky_key_on_layer_event( + match layer_event.transition { + LayerTransition::Enter => crate::config::StickyKeyReleaseMode::LAYER_ENTER, + LayerTransition::Exit => crate::config::StickyKeyReleaseMode::LAYER_EXIT, + }, + Some(layer_event.occurred_at), + ) .await; } Either3::Third(_) => {} @@ -192,10 +195,13 @@ impl Runnable for Keyboard<'_> { { Either::First(event) => self.process_inner(event).await, Either::Second(layer_event) => { - self.release_sticky_key_on_layer_event(match layer_event.0 { - LayerTransition::Enter => crate::config::StickyKeyReleaseMode::LAYER_ENTER, - LayerTransition::Exit => crate::config::StickyKeyReleaseMode::LAYER_EXIT, - }) + self.release_sticky_key_on_layer_event( + match layer_event.transition { + LayerTransition::Enter => crate::config::StickyKeyReleaseMode::LAYER_ENTER, + LayerTransition::Exit => crate::config::StickyKeyReleaseMode::LAYER_EXIT, + }, + Some(layer_event.occurred_at), + ) .await; } } @@ -1294,11 +1300,17 @@ impl<'a> Keyboard<'a> { // Consumer/system keys with no HID alias are dispatched directly here. KeyCode::Consumer(consumer) => { self.process_action_consumer_control(consumer, event).await; - self.update_sticky_key(event); + let update = self.update_sticky_key(event); + if update.modifier_consumed && update.modifier_was_host_visible { + self.send_keyboard_report_with_resolved_modifiers(false).await; + } } KeyCode::SystemControl(system_control) => { self.process_action_system_control(system_control, event).await; - self.update_sticky_key(event); + let update = self.update_sticky_key(event); + if update.modifier_consumed && update.modifier_was_host_visible { + self.send_keyboard_report_with_resolved_modifiers(false).await; + } } _ => warn!("KeyCode variant not supported: {:?}", key), }, @@ -1307,7 +1319,7 @@ impl<'a> Keyboard<'a> { // Turn off a layer temporarily when the key is pressed // Reactivate the layer after the key is released if event.pressed && self.keymap.deactivate_layer(layer_num) { - self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_EXIT) + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_EXIT, None) .await; } } @@ -1321,7 +1333,7 @@ impl<'a> Keyboard<'a> { } else { crate::config::StickyKeyReleaseMode::LAYER_EXIT }; - self.release_sticky_key_on_layer_event(mode).await; + self.release_sticky_key_on_layer_event(mode, None).await; } } Action::LayerToggleOnly(layer_num) => { @@ -1339,11 +1351,11 @@ impl<'a> Keyboard<'a> { // Activate the target layer let entered = self.keymap.activate_layer(layer_num); if exited { - self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_EXIT) + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_EXIT, None) .await; } if entered { - self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_ENTER) + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_ENTER, None) .await; } } @@ -1351,18 +1363,18 @@ impl<'a> Keyboard<'a> { Action::DefaultLayer(layer_num) => { // Set the default layer if event.pressed && self.keymap.set_default_layer(layer_num) { - self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_EXIT) + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_EXIT, None) .await; - self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_ENTER) + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_ENTER, None) .await; } } Action::PersistentDefaultLayer(layer_num) => { // Set the default layer and persist it so it survives a reboot if event.pressed && self.keymap.set_default_layer(layer_num) { - self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_EXIT) + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_EXIT, None) .await; - self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_ENTER) + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_ENTER, None) .await; } // Persist only if the layer was valid (set_default_layer rejects out-of-range) @@ -1691,9 +1703,11 @@ impl<'a> Keyboard<'a> { // Consume any pending one-shot StickyKey. A press-triggered release needs a // follow-up report after the terminating key has been registered. - let press_release = self.sticky_key_state.modifier_releases_on_press(); - let sk_consumed = self.update_sticky_key(event); - if press_release && sk_consumed && is_basic_keyboard_key && event.pressed { + let modifier_releases_on_press = self.sticky_key_state.modifier_releases_on_press(); + let update = self.update_sticky_key(event); + if (is_basic_keyboard_key && event.pressed && modifier_releases_on_press && update.modifier_consumed) + || (!is_basic_keyboard_key && update.modifier_consumed && update.modifier_was_host_visible) + { self.send_keyboard_report_with_resolved_modifiers(true).await; } } @@ -1703,12 +1717,12 @@ impl<'a> Keyboard<'a> { // Change layer state only when the key's state is changed if event.pressed { if self.keymap.activate_layer(layer_num) { - self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_ENTER) + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_ENTER, None) .await; } } else { if self.keymap.deactivate_layer(layer_num) { - self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_EXIT) + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_EXIT, None) .await; } } diff --git a/rmk/src/keyboard/auto_mouse_layer.rs b/rmk/src/keyboard/auto_mouse_layer.rs index 970780cda..20fb9f3b5 100644 --- a/rmk/src/keyboard/auto_mouse_layer.rs +++ b/rmk/src/keyboard/auto_mouse_layer.rs @@ -28,7 +28,7 @@ use crate::config::AutoMouseLayerConfig; use crate::core_traits::Runnable; use crate::event::{ ActionEvent, Axis, AxisValType, EventSubscriber, LayerChangeEvent, LayerTransition, LayerTransitionEvent, - PointingEvent, SubscribableEvent, publish_event, + PointingEvent, SubscribableEvent, publish_event_async, }; use crate::keymap::KeyMap; use crate::processor::Processor; @@ -107,7 +107,7 @@ impl<'a, 'k> AutoMouseLayerRunner<'a, 'k> { let target_layer = self.entries[idx].config.target_layer; let activated_by_us = self.keymap.activate_layer_if_inactive(target_layer); if activated_by_us { - publish_event(LayerTransitionEvent(LayerTransition::Enter)); + publish_event_async(LayerTransitionEvent::new(LayerTransition::Enter)).await; } if pointing_step(&mut self.entries, idx, Instant::now(), activated_by_us) == PointingOutcome::OverlapFirstSeen { warn!( @@ -143,7 +143,7 @@ impl<'a, 'k> AutoMouseLayerRunner<'a, 'k> { } for layer in keypress_step(&mut self.entries, event.action, Instant::now()) { if self.keymap.deactivate_layer_if_active(layer) { - publish_event(LayerTransitionEvent(LayerTransition::Exit)); + publish_event_async(LayerTransitionEvent::new(LayerTransition::Exit)).await; } } } @@ -179,7 +179,7 @@ impl AutoMouseLayerRunner<'_, '_> { async fn on_deadline(&mut self) { for layer in timeout_step(&mut self.entries, Instant::now()) { if self.keymap.deactivate_layer_if_active(layer) { - publish_event(LayerTransitionEvent(LayerTransition::Exit)); + publish_event_async(LayerTransitionEvent::new(LayerTransition::Exit)).await; } } } diff --git a/rmk/src/keyboard/sticky_key.rs b/rmk/src/keyboard/sticky_key.rs index 574f46101..21c213289 100644 --- a/rmk/src/keyboard/sticky_key.rs +++ b/rmk/src/keyboard/sticky_key.rs @@ -37,6 +37,8 @@ struct Latch { pressed_count: u8, repeat_count: u16, deadline: Option, + /// The start of this lifecycle; external events from before this point are stale. + activated_at: Instant, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -56,6 +58,7 @@ impl Latch { pressed_count: 1, repeat_count: 1, deadline: deadline_from_timeout(policy.timeout), + activated_at: Instant::now(), } } @@ -69,6 +72,7 @@ impl Latch { self.phase = LatchPhase::Pressed; self.pressed_count = self.pressed_count.saturating_add(1).max(1); self.deadline = deadline_from_timeout(policy.timeout); + self.activated_at = Instant::now(); } fn on_physical_release(&mut self, owner: Option) -> PhysicalRelease { @@ -129,6 +133,10 @@ impl Latch { TimeoutDisposition::Release } } + + fn releases_on_layer_event(&self, event: StickyKeyReleaseMode, occurred_at: Instant) -> bool { + self.policy.release_mode.intersects(event) && self.activated_at < occurred_at + } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -144,6 +152,19 @@ struct TapKeyEffect { modifiers: ModifierCombination, } +/// Layer state owned by a Sticky Key lifecycle. +#[derive(Clone, Copy, Debug)] +struct StickyLayerEffect { + layer: u8, + activated_by_us: bool, +} + +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct StickyKeyUpdate { + pub(crate) modifier_consumed: bool, + pub(crate) modifier_was_host_visible: bool, +} + /// Runtime composition for sticky effects. /// /// Modifier and layer effects can coexist and therefore own distinct policies, @@ -151,7 +172,7 @@ struct TapKeyEffect { #[derive(Clone, Copy, Debug, Default)] pub(crate) struct StickyKeyState { modifier: Option>, - layer: Option>, + layer: Option>, tap_key: Option>, } @@ -255,6 +276,11 @@ impl Keyboard<'_> { if event.pressed { self.release_tap_key().await; + if layer as usize >= self.keymap.num_layer() { + // Keep KeyMap's established diagnostic, but never arm an invalid layer. + self.keymap.activate_layer(layer); + return; + } if self .sticky_key_state .layer @@ -263,13 +289,20 @@ impl Keyboard<'_> { self.release_sticky_layer(); return; } - if let Some(previous) = self.sticky_key_state.layer.take() - && previous.value != layer - { - self.keymap.deactivate_layer(previous.value); + if let Some(mut previous) = self.sticky_key_state.layer.take() { + if previous.value.layer == layer { + previous.begin_press(event.pos, policy); + self.sticky_key_state.layer = Some(previous); + return; + } + self.release_sticky_layer_effect(previous.value); } - self.keymap.activate_layer(layer); - self.sticky_key_state.layer = Some(Latch::new(layer, event.pos, policy)); + let activated_by_us = self.keymap.activate_layer(layer); + self.sticky_key_state.layer = Some(Latch::new( + StickyLayerEffect { layer, activated_by_us }, + event.pos, + policy, + )); } else if let Some(latch) = &mut self.sticky_key_state.layer && latch.on_physical_release(Some(event.pos)) == PhysicalRelease::Released { @@ -317,6 +350,7 @@ impl Keyboard<'_> { latch.phase = LatchPhase::Pressed; latch.pressed_count = 1; latch.deadline = deadline_from_timeout(policy.timeout); + latch.activated_at = Instant::now(); } } None => { @@ -347,16 +381,17 @@ impl Keyboard<'_> { } /// Apply a foreign key event to the independently active modifier and - /// layer latches. Returns whether a modifier was consumed. - pub(crate) fn update_sticky_key(&mut self, event: KeyboardEvent) -> bool { - let mut modifier_consumed = false; + /// layer latches. + pub(crate) fn update_sticky_key(&mut self, event: KeyboardEvent) -> StickyKeyUpdate { + let mut update = StickyKeyUpdate::default(); if let Some(modifier) = &mut self.sticky_key_state.modifier { match modifier.phase { LatchPhase::Pressed => modifier.mark_foreign_key(), LatchPhase::Latched if modifier.trigger_for_key(event.pressed) => { + update.modifier_was_host_visible = modifier.policy.activate_on_keypress; self.sticky_key_state.modifier = None; - modifier_consumed = true; + update.modifier_consumed = true; } LatchPhase::Latched | LatchPhase::Held => {} } @@ -368,35 +403,42 @@ impl Keyboard<'_> { LatchPhase::Latched if layer.trigger_for_key(event.pressed) => { let layer = layer.value; self.sticky_key_state.layer = None; - self.keymap.deactivate_layer(layer); + self.release_sticky_layer_effect(layer); } LatchPhase::Latched | LatchPhase::Held => {} } } - modifier_consumed + update } - pub(crate) async fn release_sticky_key_on_layer_event(&mut self, event: StickyKeyReleaseMode) { - if self - .sticky_key_state - .modifier - .is_some_and(|latch| latch.policy.release_mode.intersects(event)) - { + pub(crate) async fn release_sticky_key_on_layer_event( + &mut self, + event: StickyKeyReleaseMode, + occurred_at: Option, + ) { + if self.sticky_key_state.modifier.is_some_and(|latch| { + occurred_at.map_or_else( + || latch.policy.release_mode.intersects(event), + |at| latch.releases_on_layer_event(event, at), + ) + }) { self.release_sticky_modifier().await; } - if self - .sticky_key_state - .layer - .is_some_and(|latch| latch.policy.release_mode.intersects(event)) - { + if self.sticky_key_state.layer.is_some_and(|latch| { + occurred_at.map_or_else( + || latch.policy.release_mode.intersects(event), + |at| latch.releases_on_layer_event(event, at), + ) + }) { self.release_sticky_layer(); } - if self - .sticky_key_state - .tap_key - .is_some_and(|latch| latch.policy.release_mode.intersects(event)) - { + if self.sticky_key_state.tap_key.is_some_and(|latch| { + occurred_at.map_or_else( + || latch.policy.release_mode.intersects(event), + |at| latch.releases_on_layer_event(event, at), + ) + }) { self.release_tap_key().await; } } @@ -440,7 +482,13 @@ impl Keyboard<'_> { fn release_sticky_layer(&mut self) { if let Some(layer) = self.sticky_key_state.layer.take() { - self.keymap.deactivate_layer(layer.value); + self.release_sticky_layer_effect(layer.value); + } + } + + fn release_sticky_layer_effect(&self, effect: StickyLayerEffect) { + if effect.activated_by_us { + self.keymap.deactivate_layer(effect.layer); } } @@ -520,4 +568,19 @@ mod tests { assert_eq!(latch.phase, LatchPhase::Pressed); assert_eq!(latch.deadline, None); } + + #[test] + fn external_layer_events_only_release_their_current_lifecycle() { + let mut latch = Latch::new( + ModifierCombination::LCTRL, + pos(0), + policy(StickyKeyReleaseMode::LAYER_ENTER), + ); + latch.activated_at = Instant::from_ticks(10); + + assert!(!latch.releases_on_layer_event(StickyKeyReleaseMode::LAYER_ENTER, Instant::from_ticks(10))); + assert!(!latch.releases_on_layer_event(StickyKeyReleaseMode::LAYER_EXIT, Instant::from_ticks(11))); + assert!(!latch.releases_on_layer_event(StickyKeyReleaseMode::LAYER_ENTER, Instant::from_ticks(9))); + assert!(latch.releases_on_layer_event(StickyKeyReleaseMode::LAYER_ENTER, Instant::from_ticks(11))); + } } diff --git a/rmk/src/keymap.rs b/rmk/src/keymap.rs index 6e6de826d..62a46535b 100644 --- a/rmk/src/keymap.rs +++ b/rmk/src/keymap.rs @@ -371,7 +371,6 @@ impl<'a> KeyMap<'a> { behavior: &'a mut BehaviorConfig, positional_config: &'a PositionalConfig, ) -> Self { - behavior.normalize_sticky_key_compat(); let layers = data.keymap.as_mut_slice().as_flattened_mut().as_flattened_mut(); let encoders = if NUM_ENCODER > 0 { Some(data.encoder_map.as_mut_slice().as_flattened_mut()) @@ -411,6 +410,8 @@ impl<'a> KeyMap<'a> { ) -> Self { fill_vec(&mut behavior.fork.forks); fill_vec(&mut behavior.morse.morses); + // Resolve source-level compatibility before runtime construction. + behavior.normalize_sticky_key_compat(); Self::build(data, behavior, positional_config) } @@ -429,6 +430,8 @@ impl<'a> KeyMap<'a> { ) -> Self { fill_vec(&mut behavior.fork.forks); fill_vec(&mut behavior.morse.morses); + // Storage is applied after legacy source defaults and therefore wins at boot. + behavior.normalize_sticky_key_compat(); // Read from storage BEFORE flattening (storage expects typed arrays). if let Some(storage) = storage From 999d369e1e38c653461aa4c57ac6d6ce638a3027 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:49:31 -0500 Subject: [PATCH 105/119] fix(sticky-key): harden ownership and compatibility --- rmk-types/src/action/mod.rs | 13 ++ rmk/src/config/behavior.rs | 12 +- rmk/src/event/mod.rs | 2 +- rmk/src/event/state.rs | 77 ++++++- rmk/src/keyboard.rs | 75 ++++-- rmk/src/keyboard/auto_mouse_layer.rs | 6 +- rmk/src/keyboard/sticky_key.rs | 91 ++++++-- rmk/src/keymap.rs | 52 ++++- rmk/src/lib.rs | 5 + rmk/src/storage/mod.rs | 36 +++ rmk/tests/keyboard_sticky_key_test.rs | 320 +++++++++++++++++++++++++- 11 files changed, 620 insertions(+), 69 deletions(-) diff --git a/rmk-types/src/action/mod.rs b/rmk-types/src/action/mod.rs index e87ac9eb3..12f87ad5a 100644 --- a/rmk-types/src/action/mod.rs +++ b/rmk-types/src/action/mod.rs @@ -140,4 +140,17 @@ mod tests { assert_eq!(decoded, action); } + + #[test] + fn existing_action_discriminants_remain_stable() { + fn discriminant(action: Action) -> u8 { + let mut bytes = [0; 32]; + postcard::to_slice(&action, &mut bytes).unwrap()[0] + } + + assert_eq!(discriminant(Action::No), 0); + assert_eq!(discriminant(Action::OneShotLayer(1)), 13); + assert_eq!(discriminant(Action::OneShotModifier(ModifierCombination::LSHIFT)), 14); + assert_eq!(discriminant(Action::PersistentDefaultLayer(1)), 20); + } } diff --git a/rmk/src/config/behavior.rs b/rmk/src/config/behavior.rs index dea9cf04c..5540a3eb2 100644 --- a/rmk/src/config/behavior.rs +++ b/rmk/src/config/behavior.rs @@ -236,8 +236,10 @@ pub struct OneShotModifiersConfig { impl BehaviorConfig { /// Convert legacy one-shot inputs to the canonical sticky-key profile. /// - /// This runs once at the keymap boundary. Runtime code reads only - /// `sticky_key`, so there is no mirrored mutable state to synchronize. + /// This runs once at the keymap boundary. Runtime policy comes from the + /// canonical profile; the legacy pure-mod-only quick-release bit is + /// captured separately by `KeyMap` because applying it to the shared + /// profile would also change OSL behavior. pub(crate) fn normalize_sticky_key_compat(&mut self) { let legacy_timeout = OneShotConfig::default().timeout; if self.one_shot.timeout != legacy_timeout @@ -248,9 +250,9 @@ impl BehaviorConfig { if self.one_shot_modifiers.activate_on_keypress { self.sticky_key.default_profile.activate_on_keypress = true; } - if self.one_shot_modifiers.quick_release && self.sticky_key.default_profile.release_mode.is_none() { - self.sticky_key.default_profile.release_mode = Some(StickyKeyReleaseMode::OTHER_KEY_PRESS); - } + // `quick_release` is intentionally not copied into the shared default + // profile: the legacy option applied only to OSM/pure-mod behavior. + // KeyMap resolves that compatibility input once for the pure-mod shape. } } diff --git a/rmk/src/event/mod.rs b/rmk/src/event/mod.rs index f328fdff5..6e9b41159 100644 --- a/rmk/src/event/mod.rs +++ b/rmk/src/event/mod.rs @@ -68,7 +68,7 @@ pub use split::{CentralConnectedEvent, PeripheralConnectedEvent}; #[cfg(all(feature = "split", feature = "_ble"))] pub use split::{ClearPeerEvent, PeripheralBatteryEvent}; pub use state::{LayerChangeEvent, LedIndicatorEvent, SleepStateEvent, WpmUpdateEvent}; -pub(crate) use state::{LayerTransition, LayerTransitionEvent}; +pub(crate) use state::{LayerTransition, LayerTransitionEvent, LayerTransitionGeneration}; /// Trait for event publishers pub trait EventPublisher { diff --git a/rmk/src/event/state.rs b/rmk/src/event/state.rs index 5d87c105b..482da98c9 100644 --- a/rmk/src/event/state.rs +++ b/rmk/src/event/state.rs @@ -1,6 +1,8 @@ //! Keyboard state events -use embassy_time::Instant; +use core::cell::Cell; + +use embassy_sync::blocking_mutex::Mutex; use rmk_macro::event; use rmk_types::led_indicator::LedIndicator; @@ -25,24 +27,89 @@ pub(crate) enum LayerTransition { Exit, } +/// Causal order for layer transitions produced outside the keyboard task. +/// +/// Timer ticks cannot order two transitions that happen within the same tick, +/// so Sticky Key lifecycles compare this generation instead of timestamps. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct LayerTransitionGeneration(u64); + +static LAYER_TRANSITION_GENERATION: Mutex> = Mutex::new(Cell::new(0)); + +impl LayerTransitionGeneration { + pub(crate) fn current() -> Self { + LAYER_TRANSITION_GENERATION.lock(|generation| Self(generation.get())) + } + + fn next() -> Self { + LAYER_TRANSITION_GENERATION.lock(|generation| { + let next = generation.get().wrapping_add(1); + generation.set(next); + Self(next) + }) + } + + /// Return whether `self` is causally newer than `baseline`. + /// + /// Half-range wrapping order is unambiguous as long as fewer than 2^63 + /// external transitions can remain queued, which is guaranteed by the + /// bounded event channel. + pub(crate) const fn is_after(self, baseline: Self) -> bool { + let distance = self.0.wrapping_sub(baseline.0); + distance != 0 && distance <= (u64::MAX / 2) + } + + #[cfg(test)] + pub(crate) const fn from_raw(value: u64) -> Self { + Self(value) + } +} + /// A layer transition produced outside the main keyboard action loop. #[event(channel_size = crate::LAYER_TRANSITION_EVENT_CHANNEL_SIZE, pubs = crate::LAYER_TRANSITION_EVENT_PUB_SIZE, subs = crate::LAYER_TRANSITION_EVENT_SUB_SIZE)] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct LayerTransitionEvent { + /// Layer whose boolean state changed. + pub(crate) layer: u8, pub(crate) transition: LayerTransition, - /// Timestamp at which the layer state was actually changed. - pub(crate) occurred_at: Instant, + /// Causal generation assigned when the layer state was changed. + pub(crate) generation: LayerTransitionGeneration, } impl LayerTransitionEvent { - pub(crate) fn new(transition: LayerTransition) -> Self { + pub(crate) fn new(layer: u8, transition: LayerTransition) -> Self { Self { + layer, transition, - occurred_at: Instant::now(), + generation: LayerTransitionGeneration::next(), } } } +#[cfg(test)] +mod tests { + use super::LayerTransitionGeneration; + + #[test] + fn layer_transition_generation_orders_equal_tick_events() { + let before = LayerTransitionGeneration::from_raw(10); + let after = LayerTransitionGeneration::from_raw(11); + + assert!(after.is_after(before)); + assert!(!before.is_after(before)); + assert!(!before.is_after(after)); + } + + #[test] + fn layer_transition_generation_has_defined_wrapping_order() { + let before_wrap = LayerTransitionGeneration::from_raw(u64::MAX); + let after_wrap = LayerTransitionGeneration::from_raw(0); + + assert!(after_wrap.is_after(before_wrap)); + assert!(!before_wrap.is_after(after_wrap)); + } +} + /// WPM updated event #[event(channel_size = crate::WPM_UPDATE_EVENT_CHANNEL_SIZE, pubs = crate::WPM_UPDATE_EVENT_PUB_SIZE, subs = crate::WPM_UPDATE_EVENT_SUB_SIZE)] #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/rmk/src/keyboard.rs b/rmk/src/keyboard.rs index 47f573885..55b9115d6 100644 --- a/rmk/src/keyboard.rs +++ b/rmk/src/keyboard.rs @@ -180,7 +180,8 @@ impl Runnable for Keyboard<'_> { LayerTransition::Enter => crate::config::StickyKeyReleaseMode::LAYER_ENTER, LayerTransition::Exit => crate::config::StickyKeyReleaseMode::LAYER_EXIT, }, - Some(layer_event.occurred_at), + Some(layer_event.generation), + Some(layer_event.layer), ) .await; } @@ -200,7 +201,8 @@ impl Runnable for Keyboard<'_> { LayerTransition::Enter => crate::config::StickyKeyReleaseMode::LAYER_ENTER, LayerTransition::Exit => crate::config::StickyKeyReleaseMode::LAYER_EXIT, }, - Some(layer_event.occurred_at), + Some(layer_event.generation), + Some(layer_event.layer), ) .await; } @@ -1319,8 +1321,12 @@ impl<'a> Keyboard<'a> { // Turn off a layer temporarily when the key is pressed // Reactivate the layer after the key is released if event.pressed && self.keymap.deactivate_layer(layer_num) { - self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_EXIT, None) - .await; + self.release_sticky_key_on_layer_event( + crate::config::StickyKeyReleaseMode::LAYER_EXIT, + None, + Some(layer_num), + ) + .await; } } Action::LayerToggle(layer_num) => { @@ -1333,7 +1339,8 @@ impl<'a> Keyboard<'a> { } else { crate::config::StickyKeyReleaseMode::LAYER_EXIT }; - self.release_sticky_key_on_layer_event(mode, None).await; + self.release_sticky_key_on_layer_event(mode, None, Some(layer_num)) + .await; } } Action::LayerToggleOnly(layer_num) => { @@ -1342,40 +1349,52 @@ impl<'a> Keyboard<'a> { // Disable all layers except the default layer let default_layer = self.keymap.get_default_layer(); let (_, _, num_layer) = self.keymap.get_keymap_config(); - let mut exited = false; for i in 0..num_layer as u8 { - if i != default_layer { - exited |= self.keymap.deactivate_layer(i); + if i != default_layer && self.keymap.deactivate_layer(i) { + self.release_sticky_key_on_layer_event( + crate::config::StickyKeyReleaseMode::LAYER_EXIT, + None, + Some(i), + ) + .await; } } // Activate the target layer let entered = self.keymap.activate_layer(layer_num); - if exited { - self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_EXIT, None) - .await; - } if entered { - self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_ENTER, None) - .await; + self.release_sticky_key_on_layer_event( + crate::config::StickyKeyReleaseMode::LAYER_ENTER, + None, + Some(layer_num), + ) + .await; } } } Action::DefaultLayer(layer_num) => { // Set the default layer if event.pressed && self.keymap.set_default_layer(layer_num) { - self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_EXIT, None) - .await; - self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_ENTER, None) + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_EXIT, None, None) .await; + self.release_sticky_key_on_layer_event( + crate::config::StickyKeyReleaseMode::LAYER_ENTER, + None, + None, + ) + .await; } } Action::PersistentDefaultLayer(layer_num) => { // Set the default layer and persist it so it survives a reboot if event.pressed && self.keymap.set_default_layer(layer_num) { - self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_EXIT, None) - .await; - self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_ENTER, None) + self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_EXIT, None, None) .await; + self.release_sticky_key_on_layer_event( + crate::config::StickyKeyReleaseMode::LAYER_ENTER, + None, + None, + ) + .await; } // Persist only if the layer was valid (set_default_layer rejects out-of-range) #[cfg(feature = "storage")] @@ -1717,13 +1736,21 @@ impl<'a> Keyboard<'a> { // Change layer state only when the key's state is changed if event.pressed { if self.keymap.activate_layer(layer_num) { - self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_ENTER, None) - .await; + self.release_sticky_key_on_layer_event( + crate::config::StickyKeyReleaseMode::LAYER_ENTER, + None, + Some(layer_num), + ) + .await; } } else { if self.keymap.deactivate_layer(layer_num) { - self.release_sticky_key_on_layer_event(crate::config::StickyKeyReleaseMode::LAYER_EXIT, None) - .await; + self.release_sticky_key_on_layer_event( + crate::config::StickyKeyReleaseMode::LAYER_EXIT, + None, + Some(layer_num), + ) + .await; } } } diff --git a/rmk/src/keyboard/auto_mouse_layer.rs b/rmk/src/keyboard/auto_mouse_layer.rs index 20fb9f3b5..9c025e78f 100644 --- a/rmk/src/keyboard/auto_mouse_layer.rs +++ b/rmk/src/keyboard/auto_mouse_layer.rs @@ -107,7 +107,7 @@ impl<'a, 'k> AutoMouseLayerRunner<'a, 'k> { let target_layer = self.entries[idx].config.target_layer; let activated_by_us = self.keymap.activate_layer_if_inactive(target_layer); if activated_by_us { - publish_event_async(LayerTransitionEvent::new(LayerTransition::Enter)).await; + publish_event_async(LayerTransitionEvent::new(target_layer, LayerTransition::Enter)).await; } if pointing_step(&mut self.entries, idx, Instant::now(), activated_by_us) == PointingOutcome::OverlapFirstSeen { warn!( @@ -143,7 +143,7 @@ impl<'a, 'k> AutoMouseLayerRunner<'a, 'k> { } for layer in keypress_step(&mut self.entries, event.action, Instant::now()) { if self.keymap.deactivate_layer_if_active(layer) { - publish_event_async(LayerTransitionEvent::new(LayerTransition::Exit)).await; + publish_event_async(LayerTransitionEvent::new(layer, LayerTransition::Exit)).await; } } } @@ -179,7 +179,7 @@ impl AutoMouseLayerRunner<'_, '_> { async fn on_deadline(&mut self) { for layer in timeout_step(&mut self.entries, Instant::now()) { if self.keymap.deactivate_layer_if_active(layer) { - publish_event_async(LayerTransitionEvent::new(LayerTransition::Exit)).await; + publish_event_async(LayerTransitionEvent::new(layer, LayerTransition::Exit)).await; } } } diff --git a/rmk/src/keyboard/sticky_key.rs b/rmk/src/keyboard/sticky_key.rs index 21c213289..d6b86f7d1 100644 --- a/rmk/src/keyboard/sticky_key.rs +++ b/rmk/src/keyboard/sticky_key.rs @@ -10,7 +10,7 @@ use rmk_types::keycode::HidKeyCode; use rmk_types::modifier::ModifierCombination; use crate::config::StickyKeyReleaseMode; -use crate::event::{KeyboardEvent, KeyboardEventPos}; +use crate::event::{KeyboardEvent, KeyboardEventPos, LayerTransitionGeneration}; use crate::keyboard::Keyboard; use crate::keymap::{StickyKeyPolicy, StickyKeyShape}; @@ -37,8 +37,8 @@ struct Latch { pressed_count: u8, repeat_count: u16, deadline: Option, - /// The start of this lifecycle; external events from before this point are stale. - activated_at: Instant, + /// External layer transitions at or before this generation are stale. + layer_generation: LayerTransitionGeneration, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -58,7 +58,7 @@ impl Latch { pressed_count: 1, repeat_count: 1, deadline: deadline_from_timeout(policy.timeout), - activated_at: Instant::now(), + layer_generation: LayerTransitionGeneration::current(), } } @@ -72,7 +72,7 @@ impl Latch { self.phase = LatchPhase::Pressed; self.pressed_count = self.pressed_count.saturating_add(1).max(1); self.deadline = deadline_from_timeout(policy.timeout); - self.activated_at = Instant::now(); + self.layer_generation = LayerTransitionGeneration::current(); } fn on_physical_release(&mut self, owner: Option) -> PhysicalRelease { @@ -134,8 +134,8 @@ impl Latch { } } - fn releases_on_layer_event(&self, event: StickyKeyReleaseMode, occurred_at: Instant) -> bool { - self.policy.release_mode.intersects(event) && self.activated_at < occurred_at + fn releases_on_layer_event(&self, event: StickyKeyReleaseMode, generation: LayerTransitionGeneration) -> bool { + self.policy.release_mode.intersects(event) && generation.is_after(self.layer_generation) } } @@ -159,6 +159,14 @@ struct StickyLayerEffect { activated_by_us: bool, } +impl StickyLayerEffect { + fn observe_later_transition(&mut self, changed_layer: u8) { + if changed_layer == self.layer { + self.activated_by_us = false; + } + } +} + #[derive(Clone, Copy, Debug, Default)] pub(crate) struct StickyKeyUpdate { pub(crate) modifier_consumed: bool, @@ -350,7 +358,7 @@ impl Keyboard<'_> { latch.phase = LatchPhase::Pressed; latch.pressed_count = 1; latch.deadline = deadline_from_timeout(policy.timeout); - latch.activated_at = Instant::now(); + latch.layer_generation = LayerTransitionGeneration::current(); } } None => { @@ -415,10 +423,22 @@ impl Keyboard<'_> { pub(crate) async fn release_sticky_key_on_layer_event( &mut self, event: StickyKeyReleaseMode, - occurred_at: Option, + generation: Option, + changed_layer: Option, ) { + if let Some(layer) = &mut self.sticky_key_state.layer + && generation.is_none_or(|occurred| occurred.is_after(layer.layer_generation)) + && let Some(changed_layer) = changed_layer + { + // Another producer changed the target layer after this lifecycle + // began. The boolean layer backend cannot retain multiple owners, + // so Sticky Key must relinquish cleanup ownership rather than later + // deactivating state that may now belong to that producer. + layer.value.observe_later_transition(changed_layer); + } + if self.sticky_key_state.modifier.is_some_and(|latch| { - occurred_at.map_or_else( + generation.map_or_else( || latch.policy.release_mode.intersects(event), |at| latch.releases_on_layer_event(event, at), ) @@ -426,7 +446,7 @@ impl Keyboard<'_> { self.release_sticky_modifier().await; } if self.sticky_key_state.layer.is_some_and(|latch| { - occurred_at.map_or_else( + generation.map_or_else( || latch.policy.release_mode.intersects(event), |at| latch.releases_on_layer_event(event, at), ) @@ -434,7 +454,7 @@ impl Keyboard<'_> { self.release_sticky_layer(); } if self.sticky_key_state.tap_key.is_some_and(|latch| { - occurred_at.map_or_else( + generation.map_or_else( || latch.policy.release_mode.intersects(event), |at| latch.releases_on_layer_event(event, at), ) @@ -576,11 +596,48 @@ mod tests { pos(0), policy(StickyKeyReleaseMode::LAYER_ENTER), ); - latch.activated_at = Instant::from_ticks(10); + latch.layer_generation = LayerTransitionGeneration::from_raw(10); + + assert!(!latch.releases_on_layer_event( + StickyKeyReleaseMode::LAYER_ENTER, + LayerTransitionGeneration::from_raw(10) + )); + assert!(!latch.releases_on_layer_event( + StickyKeyReleaseMode::LAYER_EXIT, + LayerTransitionGeneration::from_raw(11) + )); + assert!(!latch.releases_on_layer_event( + StickyKeyReleaseMode::LAYER_ENTER, + LayerTransitionGeneration::from_raw(9) + )); + assert!(latch.releases_on_layer_event( + StickyKeyReleaseMode::LAYER_ENTER, + LayerTransitionGeneration::from_raw(11) + )); + } + + #[test] + fn sticky_layer_relinquishes_only_for_its_own_later_transition() { + let mut effect = StickyLayerEffect { + layer: 2, + activated_by_us: true, + }; + + effect.observe_later_transition(1); + assert!(effect.activated_by_us); + + effect.observe_later_transition(2); + assert!(!effect.activated_by_us); + } + + #[test] + fn preexisting_sticky_layer_never_claims_cleanup_ownership() { + let mut effect = StickyLayerEffect { + layer: 2, + activated_by_us: false, + }; - assert!(!latch.releases_on_layer_event(StickyKeyReleaseMode::LAYER_ENTER, Instant::from_ticks(10))); - assert!(!latch.releases_on_layer_event(StickyKeyReleaseMode::LAYER_EXIT, Instant::from_ticks(11))); - assert!(!latch.releases_on_layer_event(StickyKeyReleaseMode::LAYER_ENTER, Instant::from_ticks(9))); - assert!(latch.releases_on_layer_event(StickyKeyReleaseMode::LAYER_ENTER, Instant::from_ticks(11))); + effect.observe_later_transition(2); + assert!(!effect.activated_by_us); } } diff --git a/rmk/src/keymap.rs b/rmk/src/keymap.rs index 62a46535b..a1642547f 100644 --- a/rmk/src/keymap.rs +++ b/rmk/src/keymap.rs @@ -113,6 +113,8 @@ struct KeyMapInner<'a> { encoder_layer_cache: &'a mut [u8], /// Behavior configuration behavior: &'a mut BehaviorConfig, + /// Legacy OSM-only quick release, resolved at construction time. + pure_mod_quick_release: bool, /// Hand info: row * col (read-only) hand: &'a [Hand], /// Mouse button state @@ -381,6 +383,7 @@ impl<'a> KeyMap<'a> { let layer_cache = data.layer_cache.as_mut_slice().as_flattened_mut(); let encoder_layer_cache = data.encoder_layer_cache.as_mut_slice().as_flattened_mut(); let hand = positional_config.hand.as_slice().as_flattened(); + let pure_mod_quick_release = behavior.one_shot_modifiers.quick_release; KeyMap { inner: RefCell::new(KeyMapInner { @@ -394,6 +397,7 @@ impl<'a> KeyMap<'a> { layer_cache, encoder_layer_cache, behavior, + pure_mod_quick_release, hand, mouse_buttons: 0, #[cfg(feature = "host_security")] @@ -603,14 +607,19 @@ impl<'a> KeyMap<'a> { } pub(crate) fn sticky_key_profile(&self, index: u8, shape: StickyKeyShape) -> StickyKeyPolicy { - let config = &self.inner.borrow().behavior.sticky_key; + let inner = self.inner.borrow(); + let config = &inner.behavior.sticky_key; + let uses_default = index as usize >= config.profiles.len(); let profile = config .profiles .get(index as usize) .copied() .unwrap_or(config.default_profile); - let release_mode = profile.release_mode.unwrap_or(match shape { + let release_mode = profile.release_mode.unwrap_or_else(|| match shape { StickyKeyShape::TapKey => StickyKeyReleaseMode::OTHER_KEY_PRESS, + StickyKeyShape::PureMod if uses_default && inner.pure_mod_quick_release => { + StickyKeyReleaseMode::OTHER_KEY_PRESS + } StickyKeyShape::PureMod | StickyKeyShape::Layer => StickyKeyReleaseMode::OTHER_KEY_RELEASE, }); StickyKeyPolicy { @@ -1010,4 +1019,43 @@ mod test { assert_eq!(pure_mod.release_mode, StickyKeyReleaseMode::OTHER_KEY_RELEASE); assert_eq!(tap_key.release_mode, StickyKeyReleaseMode::OTHER_KEY_PRESS); } + + #[test] + fn legacy_quick_release_only_changes_the_pure_mod_default() { + let mut data = KeymapData::<1, 1, 1>::new([[[k!(A)]]]); + let mut behavior = BehaviorConfig::default(); + behavior.one_shot_modifiers.quick_release = true; + behavior.normalize_sticky_key_compat(); + let positional = PositionalConfig::<1, 1>::default(); + let keymap = KeyMap::build(&mut data, &mut behavior, &positional); + + assert_eq!( + keymap.sticky_key_profile(u8::MAX, StickyKeyShape::PureMod).release_mode, + StickyKeyReleaseMode::OTHER_KEY_PRESS + ); + assert_eq!( + keymap.sticky_key_profile(u8::MAX, StickyKeyShape::Layer).release_mode, + StickyKeyReleaseMode::OTHER_KEY_RELEASE + ); + assert_eq!( + keymap.sticky_key_profile(u8::MAX, StickyKeyShape::TapKey).release_mode, + StickyKeyReleaseMode::OTHER_KEY_PRESS + ); + } + + #[test] + fn canonical_release_mode_wins_over_legacy_quick_release() { + let mut data = KeymapData::<1, 1, 1>::new([[[k!(A)]]]); + let mut behavior = BehaviorConfig::default(); + behavior.one_shot_modifiers.quick_release = true; + behavior.sticky_key.default_profile.release_mode = Some(StickyKeyReleaseMode::DOUBLE_TAP); + behavior.normalize_sticky_key_compat(); + let positional = PositionalConfig::<1, 1>::default(); + let keymap = KeyMap::build(&mut data, &mut behavior, &positional); + + assert_eq!( + keymap.sticky_key_profile(u8::MAX, StickyKeyShape::PureMod).release_mode, + StickyKeyReleaseMode::DOUBLE_TAP + ); + } } diff --git a/rmk/src/lib.rs b/rmk/src/lib.rs index 154dc1ea8..c44c617c6 100644 --- a/rmk/src/lib.rs +++ b/rmk/src/lib.rs @@ -130,6 +130,11 @@ pub async fn initialize_keymap_and_storage< behavior_config: &'a mut config::BehaviorConfig, positional_config: &'a PositionalConfig, ) -> (KeyMap<'a>, Storage) { + // Resolve source-level compatibility before an empty flash is initialized; + // otherwise storage would persist the unnormalized canonical defaults and + // immediately overwrite the legacy inputs during first boot. + behavior_config.normalize_sticky_key_compat(); + #[cfg(feature = "host")] { let mut storage = { diff --git a/rmk/src/storage/mod.rs b/rmk/src/storage/mod.rs index 850080df7..47b0433fc 100644 --- a/rmk/src/storage/mod.rs +++ b/rmk/src/storage/mod.rs @@ -1075,4 +1075,40 @@ mod tests { )); }); } + + #[test] + fn empty_storage_persists_normalized_sticky_key_defaults() { + block_on(async { + type Flash = TestFlash<16_384, 4_096, 1>; + + let mut behavior = RuntimeBehaviorConfig::default(); + behavior.one_shot.timeout = Duration::from_millis(321); + behavior.normalize_sticky_key_compat(); + + #[cfg(feature = "host")] + let keymap = [[[KeyAction::No; 1]; 1]; 1]; + #[cfg(feature = "host")] + let encoder_map: Option<&mut [[EncoderAction; 0]; 1]> = None; + + let mut storage = Storage::::new( + Flash::new(), + #[cfg(feature = "host")] + &keymap, + #[cfg(feature = "host")] + &encoder_map, + &RuntimeStorageConfig::default(), + &behavior, + ) + .await; + + let stored_behavior = storage.fetch_data(StorageKey::BehaviorConfig).await.unwrap(); + assert!(matches!( + stored_behavior, + StorageData::BehaviorConfig(BehaviorConfig { + sticky_key_timeout: 321, + .. + }) + )); + }); + } } diff --git a/rmk/tests/keyboard_sticky_key_test.rs b/rmk/tests/keyboard_sticky_key_test.rs index 4176fe8ea..0f44df7be 100644 --- a/rmk/tests/keyboard_sticky_key_test.rs +++ b/rmk/tests/keyboard_sticky_key_test.rs @@ -1,16 +1,314 @@ pub mod common; -use embassy_time::Duration; +use embassy_futures::select::{Either, select}; +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use embassy_sync::mutex::Mutex; +use embassy_time::{Duration, Timer}; +use futures::join; +use rmk::channel::USB_REPORT_CHANNEL; use rmk::config::{ BehaviorConfig, OneShotModifiersConfig, PositionalConfig, StickyKeyConfig, StickyKeyProfile, StickyKeyReleaseMode, }; +use rmk::core_traits::Runnable; +use rmk::event::{AsyncEventPublisher, AsyncPublishableEvent, KeyboardEvent}; +use rmk::hid::Report; use rmk::keyboard::Keyboard; -use rmk::types::action::KeyAction; +use rmk::state::set_usb_state; +use rmk::types::action::{Action, KeyAction}; +use rmk::types::connection::UsbState; +use rmk::types::keycode::{ConsumerKey, KeyCode, SystemControlKey}; use rmk::types::modifier::ModifierCombination; use rmk::{a, k, mo, sk, sk_layer, sk_mod}; use crate::common::{KC_LALT, KC_LCTRL, KC_LGUI, KC_LSHIFT, wrap_keymap}; +#[derive(Clone, Copy, Debug)] +enum ExpectedReportKind { + Keyboard(u8), + Media, + System, + Mouse, +} + +async fn run_mixed_report_test(keyboard: &mut Keyboard<'_>, sequence: &[(u8, bool)], expected: &[ExpectedReportKind]) { + static REPORTS_DONE: Mutex = Mutex::new(false); + *REPORTS_DONE.lock().await = false; + let sender = KeyboardEvent::publisher_async(); + sender.clear(); + USB_REPORT_CHANNEL.clear(); + set_usb_state(UsbState::Configured); + + join!( + async { + select(keyboard.run(), async { + while !*REPORTS_DONE.lock().await { + Timer::after_millis(10).await; + } + }) + .await; + }, + async { + for &(col, pressed) in sequence { + sender.publish_async(KeyboardEvent::key(0, col, pressed)).await; + Timer::after_millis(1).await; + } + }, + async { + for expected in expected { + let report = match select(Timer::after_secs(2), USB_REPORT_CHANNEL.receive()).await { + Either::First(_) => panic!("mixed report wait timed out"), + Either::Second(report) => report, + }; + match (expected, report) { + (ExpectedReportKind::Keyboard(modifier), Report::KeyboardReport(report)) => { + assert_eq!(*modifier, report.modifier); + } + (ExpectedReportKind::Media, Report::MediaKeyboardReport(_)) + | (ExpectedReportKind::System, Report::SystemControlReport(_)) + | (ExpectedReportKind::Mouse, Report::MouseReport(_)) => {} + (expected, report) => panic!("expected {expected:?}, received {report:?}"), + } + } + *REPORTS_DONE.lock().await = true; + } + ); +} + +const KEYMAP_MIXED_REPORTS: [[[KeyAction; 7]; 1]; 1] = [[[ + sk_mod!(ModifierCombination::LSHIFT), + KeyAction::Single(Action::Key(KeyCode::Consumer(ConsumerKey::VolumeIncrement))), + KeyAction::Single(Action::Key(KeyCode::SystemControl(SystemControlKey::PowerDown))), + k!(AudioVolUp), + k!(SystemPower), + k!(MouseBtn1), + k!(A), +]]]; + +const KEYMAP_LEGACY_MOD_EQUIVALENCE: [[[KeyAction; 3]; 1]; 1] = [[[ + KeyAction::Single(Action::OneShotModifier(ModifierCombination::LSHIFT)), + sk_mod!(ModifierCombination::LSHIFT), + k!(A), +]]]; + +const KEYMAP_LEGACY_LAYER_EQUIVALENCE: [[[KeyAction; 4]; 1]; 2] = [ + [[KeyAction::Single(Action::OneShotLayer(1)), sk_layer!(1), k!(A), k!(B)]], + [[a!(Transparent), a!(Transparent), k!(C), k!(D)]], +]; + +const KEYMAP_LAYER_OWNERSHIP: [[[KeyAction; 3]; 1]; 2] = [ + [[mo!(1), sk_layer!(1), k!(A)]], + [[a!(Transparent), a!(Transparent), k!(C)]], +]; + +fn create_mixed_report_keyboard_with_policy( + activate_on_keypress: bool, + release_mode: StickyKeyReleaseMode, +) -> Keyboard<'static> { + let behavior = Box::leak(Box::new(BehaviorConfig { + sticky_key: StickyKeyConfig { + default_profile: StickyKeyProfile { + activate_on_keypress, + release_mode: Some(release_mode), + ..StickyKeyProfile::default() + }, + ..StickyKeyConfig::default() + }, + ..BehaviorConfig::default() + })); + let positional = Box::leak(Box::new(PositionalConfig::<1, 7>::default())); + Keyboard::new(wrap_keymap(KEYMAP_MIXED_REPORTS, positional, behavior)) +} + +fn create_mixed_report_keyboard(activate_on_keypress: bool) -> Keyboard<'static> { + create_mixed_report_keyboard_with_policy(activate_on_keypress, StickyKeyReleaseMode::OTHER_KEY_PRESS) +} + +#[test] +fn host_visible_sticky_modifiers_are_cleaned_after_non_keyboard_actions() { + common::test_block_on::test_block_on(async { + let mut keyboard = create_mixed_report_keyboard(true); + let mut sequence = Vec::new(); + for terminating_col in 1..=5 { + sequence.extend([(0, true), (0, false), (terminating_col, true), (terminating_col, false)]); + } + sequence.extend([(6, true), (6, false)]); + run_mixed_report_test( + &mut keyboard, + &sequence, + &[ + ExpectedReportKind::Keyboard(KC_LSHIFT), + ExpectedReportKind::Media, + ExpectedReportKind::Keyboard(0), + ExpectedReportKind::Media, + ExpectedReportKind::Keyboard(KC_LSHIFT), + ExpectedReportKind::System, + ExpectedReportKind::Keyboard(0), + ExpectedReportKind::System, + ExpectedReportKind::Keyboard(KC_LSHIFT), + ExpectedReportKind::Media, + ExpectedReportKind::Keyboard(0), + ExpectedReportKind::Media, + ExpectedReportKind::Keyboard(KC_LSHIFT), + ExpectedReportKind::System, + ExpectedReportKind::Keyboard(0), + ExpectedReportKind::System, + ExpectedReportKind::Keyboard(KC_LSHIFT), + ExpectedReportKind::Mouse, + ExpectedReportKind::Keyboard(0), + ExpectedReportKind::Mouse, + ExpectedReportKind::Keyboard(0), + ExpectedReportKind::Keyboard(0), + ], + ) + .await; + }); +} + +#[test] +fn non_visible_sticky_modifier_adds_no_keyboard_cleanup_for_consumer_action() { + common::test_block_on::test_block_on(async { + let mut keyboard = create_mixed_report_keyboard(false); + run_mixed_report_test( + &mut keyboard, + &[(0, true), (0, false), (1, true), (1, false), (6, true), (6, false)], + &[ + ExpectedReportKind::Media, + ExpectedReportKind::Media, + ExpectedReportKind::Keyboard(0), + ExpectedReportKind::Keyboard(0), + ], + ) + .await; + }); +} + +#[test] +fn other_key_release_cleans_up_after_non_keyboard_release() { + common::test_block_on::test_block_on(async { + let mut keyboard = create_mixed_report_keyboard_with_policy(true, StickyKeyReleaseMode::OTHER_KEY_RELEASE); + run_mixed_report_test( + &mut keyboard, + &[(0, true), (0, false), (1, true), (1, false), (6, true), (6, false)], + &[ + ExpectedReportKind::Keyboard(KC_LSHIFT), + ExpectedReportKind::Media, + ExpectedReportKind::Media, + ExpectedReportKind::Keyboard(0), + ExpectedReportKind::Keyboard(0), + ExpectedReportKind::Keyboard(0), + ], + ) + .await; + }); +} + +#[test] +fn legacy_one_shot_modifier_uses_the_canonical_sticky_backend() { + let behavior = Box::leak(Box::new(BehaviorConfig::default())); + let positional = Box::leak(Box::new(PositionalConfig::<1, 3>::default())); + key_sequence_test! { + keyboard: Keyboard::new(wrap_keymap(KEYMAP_LEGACY_MOD_EQUIVALENCE, positional, behavior)), + sequence: [ + [0, 0, true, 0], + [0, 0, false, 0], + [0, 2, true, 0], + [0, 2, false, 0], + [0, 1, true, 0], + [0, 1, false, 0], + [0, 2, true, 0], + [0, 2, false, 0], + ], + expected_reports: [ + [KC_LSHIFT, [kc_to_u8!(A), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + [KC_LSHIFT, [kc_to_u8!(A), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + ] + }; +} + +#[test] +fn legacy_one_shot_layer_uses_the_canonical_sticky_backend() { + let behavior = Box::leak(Box::new(BehaviorConfig::default())); + let positional = Box::leak(Box::new(PositionalConfig::<1, 4>::default())); + key_sequence_test! { + keyboard: Keyboard::new(wrap_keymap(KEYMAP_LEGACY_LAYER_EQUIVALENCE, positional, behavior)), + sequence: [ + [0, 0, true, 0], + [0, 0, false, 0], + [0, 2, true, 0], + [0, 2, false, 0], + [0, 1, true, 0], + [0, 1, false, 0], + [0, 3, true, 0], + [0, 3, false, 0], + ], + expected_reports: [ + [0, [kc_to_u8!(C), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + [0, [kc_to_u8!(D), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + ] + }; +} + +#[test] +fn sticky_layer_does_not_claim_a_preexisting_layer() { + let behavior = Box::leak(Box::new(BehaviorConfig::default())); + let positional = Box::leak(Box::new(PositionalConfig::<1, 3>::default())); + key_sequence_test! { + keyboard: Keyboard::new(wrap_keymap(KEYMAP_LAYER_OWNERSHIP, positional, behavior)), + sequence: [ + [0, 0, true, 0], + [0, 1, true, 0], + [0, 1, false, 0], + [0, 2, true, 0], + [0, 2, false, 0], + [0, 2, true, 0], + [0, 2, false, 0], + [0, 0, false, 0], + [0, 2, true, 0], + [0, 2, false, 0], + ], + expected_reports: [ + [0, [kc_to_u8!(C), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + [0, [kc_to_u8!(C), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + [0, [kc_to_u8!(A), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + ] + }; +} + +#[test] +fn same_sticky_layer_repress_preserves_cleanup_ownership() { + let behavior = Box::leak(Box::new(BehaviorConfig { + sticky_key: sticky_key_config_with_release_mode(StickyKeyReleaseMode::OTHER_KEY_RELEASE), + ..BehaviorConfig::default() + })); + let positional = Box::leak(Box::new(PositionalConfig::<1, 3>::default())); + key_sequence_test! { + keyboard: Keyboard::new(wrap_keymap(KEYMAP_LAYER_OWNERSHIP, positional, behavior)), + sequence: [ + [0, 1, true, 0], + [0, 1, false, 0], + [0, 1, true, 0], + [0, 1, false, 0], + [0, 2, true, 0], + [0, 2, false, 0], + [0, 2, true, 0], + [0, 2, false, 0], + ], + expected_reports: [ + [0, [kc_to_u8!(C), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + [0, [kc_to_u8!(A), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + ] + }; +} + // KEYMAP (release_on_layer_change=true is set in the helper config, not per-key) // Layer 0: A B C MO(1) LShift No // Layer 1: SK(Tab,LAlt) SK(Tab,LCtrl) SK(Tab,LCtrl|LShift) Transparent Transparent No @@ -98,8 +396,8 @@ fn layer_change_release_mode() -> StickyKeyReleaseMode { ) } -// KEYMAP_MIXED: all three SK shapes on layer 0, used to exercise the mutually-exclusive -// latch (pressing a different-shape SK while one is latched REPLACES it, never merges). +// KEYMAP_MIXED: all three SK shapes on layer 0. Modifier and layer latches may +// compose, while a tap-key lifecycle is exclusive with both. // Layer 0: SK(LGui) SK(Tab,LAlt) SK(MO(1)) P No No // Layer 1: Trns Trns Trns Z No No // (cols 0-2 fall through to layer 0 so the SKs stay pressable while layer 1 is @@ -812,7 +1110,7 @@ fn sticky_layer_double_tap_deactivates_layer() { } /// StickyKey Test 12 (regression): a tap-key SK pressed while a PURE-MOD SK is latched -/// REPLACES it — the latch is mutually exclusive, so the old modifier is dropped, not +/// REPLACES it — tap-key lifecycles are exclusive, so the old modifier is dropped, not /// merged. Without the replacement guard the tap-key press would OR the pure-mod's LGui /// onto the report, yielding LGui+LAlt+Tab instead of just LAlt+Tab. /// @@ -838,8 +1136,7 @@ fn test_sk_tap_key_replaces_pure_mod() { /// StickyKey Test 13 (regression): a pure-mod SK pressed while a TAP-KEY SK is latched /// REPLACES it. The tap-key's held LAlt is released (its own report) and the next basic /// key gets the new pure-mod's LGui applied through it — OSM terminating-key behavior — -/// not the stale LAlt. Without the guard the pure-mod's LGui would merge onto the tap-key -/// latch, leaving the shape as tap-key and applying LAlt+LGui. +/// not the stale LAlt. Without the replacement guard both effects could remain visible. /// /// Sequence: press/release SK(Tab,LAlt) (col 1), tap SK(LGui) (col 0), tap P (col 3) /// Expected: LAlt+Tab, LAlt held, LAlt released, LGui+P, all released. @@ -898,7 +1195,7 @@ fn test_sk_tap_key_replaces_layer() { } /// A layer-shaped SK must release a physically held tap-key before replacing the -/// shared latch; otherwise the displaced tap key remains registered indefinitely. +/// exclusive tap-key lifecycle; otherwise the displaced tap key remains registered indefinitely. #[test] fn test_sk_layer_replaces_held_tap_key_without_sticking() { key_sequence_test! { @@ -1272,10 +1569,9 @@ fn test_sk_timeout_while_held() { /// StickyKey Test 17b: Timeout fires while a tap-key SK is still physically held. /// -/// Tap-key SKs use the `Latched` phase while their physical key is down, so phase alone cannot -/// tell the timeout handler whether clearing the state is safe. The physical-press flag must keep -/// the state alive until release so that release unregisters Tab, retains the latched Alt, and -/// re-arms Alt's timeout. +/// Tap-key SKs remain in `Pressed` while their physical producer is down. Timeout +/// cleanup must defer until release so that release unregisters Tab, retains the +/// latched Alt, and re-arms Alt's timeout. #[test] fn test_tap_sk_timeout_while_held() { key_sequence_test! { From 04903240d27712971532dae05b30790e04b313b9 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:50:26 -0500 Subject: [PATCH 106/119] refactor(sticky-key): unify parser and alias paths --- rmk-config/src/layout.rs | 48 ++++++++ rmk-macro/src/codegen/action_parser.rs | 156 +++++++++---------------- rmk/src/host/via/keycode_convert.rs | 15 +++ rmk/src/layout_macro.rs | 23 ++++ 4 files changed, 144 insertions(+), 98 deletions(-) diff --git a/rmk-config/src/layout.rs b/rmk-config/src/layout.rs index 65494d64b..1625cc81e 100644 --- a/rmk-config/src/layout.rs +++ b/rmk-config/src/layout.rs @@ -660,6 +660,45 @@ mod tests { assert_eq!(result.unwrap(), vec!["MO(3)", "TH(A, MO(3))"]); } + #[test] + fn test_sticky_layer_names_resolve_at_top_level_and_nested() { + let aliases = HashMap::new(); + let layer_names = HashMap::from([("nav".to_string(), 3u32)]); + let keymap = "OSL(nav) SK(MO(nav)) TH(OSL(nav), SK(MO(nav))) LT(nav, OSL(nav))"; + + let result = KeyboardTomlConfig::keymap_parser(keymap, &aliases, &layer_names); + + assert!(result.is_ok(), "{:?}", result); + assert_eq!( + result.unwrap(), + vec!["OSL(3)", "SK(MO(3))", "TH(OSL(3), SK(MO(3)))", "LT(3, OSL(3))",] + ); + } + + #[test] + fn test_keymap_aliases_resolve_to_sticky_actions_at_top_level_and_nested() { + let aliases = HashMap::from([ + ("shift_once".to_string(), "OSM(LShift)".to_string()), + ("nav_once".to_string(), "OSL(nav)".to_string()), + ]); + let layer_names = HashMap::from([("nav".to_string(), 2u32)]); + let keymap = "@shift_once @nav_once MT(@shift_once, LCtrl) TH(@shift_once, @nav_once) LT(nav, @nav_once)"; + + let result = KeyboardTomlConfig::keymap_parser(keymap, &aliases, &layer_names); + + assert!(result.is_ok(), "{:?}", result); + assert_eq!( + result.unwrap(), + vec![ + "OSM(LShift)", + "OSL(2)", + "MT(OSM(LShift), LCtrl)", + "TH(OSM(LShift), OSL(2))", + "LT(2, OSL(2))", + ] + ); + } + #[test] fn test_composite_actions_rejected_in_slots() { // Tap-hold / morse forms are not single `Action`s, so they cannot nest @@ -726,6 +765,15 @@ mod tests { } } + #[test] + fn test_removed_five_positional_sticky_form_is_rejected_by_the_grammar() { + let input = "SK(Tab, [LAlt], 2, 1000, true)"; + assert!( + ConfigParser::parse(Rule::key_map, input).is_err(), + "removed input should be rejected: {input}" + ); + } + #[test] fn test_osm_osl_alias_grammar() { // OSM(modifier) parses as osm_action, OSL(n) as osl_action. diff --git a/rmk-macro/src/codegen/action_parser.rs b/rmk-macro/src/codegen/action_parser.rs index c769d9d20..991d17646 100644 --- a/rmk-macro/src/codegen/action_parser.rs +++ b/rmk-macro/src/codegen/action_parser.rs @@ -549,104 +549,6 @@ pub(crate) fn parse_key( } else if lower.starts_with("td(") || lower.starts_with("morse(") { let index = strip_call(&key).trim().parse::().unwrap(); quote! { ::rmk::types::action::KeyAction::Morse(#index) } - } else if lower.starts_with("osl(") { - // OSL(n) — user-facing alias for the layer sticky key SK(MO(n)). - // Emits the same `sk_layer!` as SK(MO(n)), so the action is byte-identical. - let args = split_top_level(strip_call(&key)); - let layer = args[0].parse::().unwrap(); - let profile = sticky_profile_index( - args.get(1).map(|p| p.trim_start_matches('@')), - sticky_profiles, - ); - quote! { ::rmk::sk_layer!(#layer, #profile) } - } else if lower.starts_with("osm(") { - // OSM(modifier) — user-facing alias for the pure-mod sticky key SK(modifier). - // Emits the same `sk_mod!` as SK(modifier), so the action is byte-identical. - let args = split_top_level(strip_call(&key)); - let modifiers = parse_modifiers(&args[0]); - if modifiers.is_empty() { - panic!( - "\n\u{274c} keyboard.toml: OSM(modifier) is not valid! \ - OSM is an alias for SK(modifier). Usage: OSM(LGui) | OSM(LCtrl | LShift)" - ); - } - let profile = sticky_profile_index( - args.get(1).map(|p| p.trim_start_matches('@')), - sticky_profiles, - ); - quote! { ::rmk::sk_mod!(#modifiers, #profile) } - } else if lower.starts_with("sk(") { - let inner = strip_call(&key).trim(); - 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_inner = action_args.join(", "); - let inner = action_inner.trim(); - let inner_lower = inner.to_lowercase(); - if inner_lower.starts_with("mo(") { - // Layer shape: SK(MO(n)) — OSL replacement. - let layer = parse_layer(inner); - quote! { ::rmk::sk_layer!(#layer, #profile) } - } else if inner.contains('[') { - // Tap-key shape: SK(key, [mods]). - let bracket_start = inner.find('[').unwrap(); - let bracket_end = inner.find(']').unwrap_or_else(|| { - panic!( - "\n\u{274c} keyboard.toml: SK has unclosed '['. \ - Usage: SK(LGui) | SK(Tab, [LAlt]) | SK(MO(n))" - ) - }); - - let key_str = inner[..bracket_start].trim().trim_end_matches(',').trim(); - let ident = get_key_with_alias(key_str.to_string()); - - let keep_mods_str = &inner[bracket_start + 1..bracket_end]; - let keep_modifiers = if keep_mods_str.trim().is_empty() { - ModifierCombinationMacro::new() - } else { - parse_modifiers(keep_mods_str) - }; - - // Legacy-tail guard: reject the old 5-positional form. - let after_bracket = inner[bracket_end + 1..].trim_start_matches(',').trim(); - if !after_bracket.is_empty() { - panic!( - "\n\u{274c} keyboard.toml: the 5-positional SK(...) form is removed; use SK(key, [mods]). max_repeat/timeout/release_on_layer_change now live in [behavior.sticky_key]." - ); - } - - quote! { ::rmk::sk!(#ident, #keep_modifiers, #profile) } - } else { - // Pure-mod shape: SK(LGui) — OSM replacement. - // - // A nested action other than MO(n) (e.g. SK(TG(1)), SK(TO(2))) parses as a - // valid `sk_action` in the pest grammar (it accepts the broad `layer_action`) - // but is NOT a supported SK layer shape. Catch it here with a targeted message - // instead of falling through to the generic "not a modifier" panic below. - if inner.contains('(') { - panic!( - "\n\u{274c} keyboard.toml: SK only supports MO(n) as its layer shape (got `{inner}`). \ - Usage: SK(LGui) | SK(Tab, [LAlt]) | SK(MO(n))" - ); - } - - let modifiers = parse_modifiers(inner); - if modifiers.is_empty() { - panic!( - "\n\u{274c} keyboard.toml: SK(modifier) is not valid! \ - Usage: SK(LGui) | SK(Tab, [LAlt]) | SK(MO(n))" - ); - } - quote! { ::rmk::sk_mod!(#modifiers, #profile) } - } } else { let action = parse_action(&key, sticky_profiles); quote! { ::rmk::types::action::KeyAction::Single(#action) } @@ -778,6 +680,40 @@ mod tests { assert_eq!(canonical, aliases); } + #[test] + fn profiled_sticky_aliases_use_the_same_action() { + let profile = || StickyKeyProfile { + timeout_ms: None, + activate_on_keypress: None, + max_repeat: None, + release_mode: None, + }; + let profiles = Some(HashMap::from([("nav".to_string(), profile())])); + + for (alias, canonical) in [ + ("OSM(LShift, @nav)", "SK(LShift, @nav)"), + ("OSL(2, @nav)", "SK(MO(2), @nav)"), + ] { + assert_eq!( + squash(&parse_key(alias.into(), &None, &profiles).to_string()), + squash(&parse_key(canonical.into(), &None, &profiles).to_string()), + ); + } + } + + #[test] + fn sticky_aliases_match_canonical_actions_in_supported_tap_hold_slots() { + let cases = [ + ("MT(OSM(LShift), LCtrl)", "MT(SK(LShift), LCtrl)"), + ("TH(OSM(LShift), OSL(2))", "TH(SK(LShift), SK(MO(2)))"), + ("LT(1, OSL(2))", "LT(1, SK(MO(2)))"), + ]; + + for (alias, canonical) in cases { + assert_eq!(squash(&expand(alias)), squash(&expand(canonical))); + } + } + #[test] fn plain_mt_th_lt_still_expand() { assert!( @@ -862,4 +798,28 @@ mod tests { .contains("profile:1u8") ); } + + #[test] + #[should_panic(expected = "the 5-positional SK(...) form is removed")] + fn legacy_five_positional_sticky_key_is_rejected() { + parse_key("SK(Tab, [LAlt], 2, 1000, true)".into(), &None, &None); + } + + #[test] + fn unsupported_sticky_layer_actions_share_the_canonical_error() { + for action in ["SK(TG(1))", "SK(TO(1))", "SK(DF(1))"] { + let panic = std::panic::catch_unwind(|| parse_key(action.into(), &None, &None)) + .expect_err("unsupported sticky layer action should panic"); + let message = panic + .downcast_ref::() + .map(String::as_str) + .or_else(|| panic.downcast_ref::<&str>().copied()) + .expect("parser panic should contain a message"); + + assert!( + message.contains("SK only supports MO(n) as its layer shape"), + "unexpected error for {action}: {message}" + ); + } + } } diff --git a/rmk/src/host/via/keycode_convert.rs b/rmk/src/host/via/keycode_convert.rs index fbe601fcc..3a059a5ba 100644 --- a/rmk/src/host/via/keycode_convert.rs +++ b/rmk/src/host/via/keycode_convert.rs @@ -940,4 +940,19 @@ mod test { assert_eq!(to_via_keycode(tap_key_sticky_key), 0); } + + #[test] + fn test_vial_does_not_convert_profiled_sticky_keys_to_osm_or_osl() { + let profiled_modifier = KeyAction::Single(Action::StickyKey(StickyKeyAction { + effect: StickyKeyEffect::Modifier(ModifierCombination::LCTRL), + profile: 0, + })); + let profiled_layer = KeyAction::Single(Action::StickyKey(StickyKeyAction { + effect: StickyKeyEffect::Layer(1), + profile: 0, + })); + + assert_eq!(to_via_keycode(profiled_modifier), 0); + assert_eq!(to_via_keycode(profiled_layer), 0); + } } diff --git a/rmk/src/layout_macro.rs b/rmk/src/layout_macro.rs index e15ad280a..cd02fc10b 100644 --- a/rmk/src/layout_macro.rs +++ b/rmk/src/layout_macro.rs @@ -760,3 +760,26 @@ macro_rules! steno { )) }; } + +#[cfg(test)] +mod tests { + use crate::types::modifier::ModifierCombination; + + #[test] + fn one_shot_aliases_equal_canonical_sticky_key_macros() { + assert_eq!( + crate::osm!(ModifierCombination::LSHIFT), + crate::sk_mod!(ModifierCombination::LSHIFT) + ); + assert_eq!(crate::osl!(2), crate::sk_layer!(2)); + } + + #[test] + fn profiled_one_shot_aliases_equal_canonical_sticky_key_macros() { + assert_eq!( + crate::osm!(ModifierCombination::LCTRL, 3), + crate::sk_mod!(ModifierCombination::LCTRL, 3) + ); + assert_eq!(crate::osl!(4, 3), crate::sk_layer!(4, 3)); + } +} From 0df473dc3b93d77120ea6206981768c936453412 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:50:58 -0500 Subject: [PATCH 107/119] docs(sticky-key): clarify composition and migration --- docs/docs/main/docs/configuration/behavior.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/docs/main/docs/configuration/behavior.md b/docs/docs/main/docs/configuration/behavior.md index 5ffffdccc..b939b2f38 100644 --- a/docs/docs/main/docs/configuration/behavior.md +++ b/docs/docs/main/docs/configuration/behavior.md @@ -72,8 +72,15 @@ release; releasing the physical key then completes the action normally. Modifier and layer Sticky Keys have independent latches. They can be active at the same time, retain their own profile, deadline, and release policy, and expire -without clearing each other. Tap-key Sticky Keys are exclusive because starting -a different tap-key sequence replaces the key/modifier pair being cycled. +without clearing each other. A tap-key Sticky Key is exclusive with both: starting +one releases any modifier/layer latch, and starting a modifier or layer Sticky Key +releases the tap-key sequence being cycled. + +RMK's layer state is a boolean rather than a reference-counted owner set. A layer +Sticky Key therefore never claims a layer that was already active, and its cleanup +will not turn that pre-existing layer off. Overlapping the same layer with `MO`, +`TG`, or another automatic layer producer is still subject to the normal boolean +layer semantics; prefer a one-shot layer that is not simultaneously owned elsewhere. For example, an Alt+Tab profile can release on another key press or either direction of a layer transition: @@ -94,7 +101,7 @@ Default values: timeout = "1s" activate_on_keypress = false max_repeat = 0 -# release_mode = "other_key_release | layer_exit | double_tap" +# release_mode is intentionally unset; shape-native defaults apply ``` OSSM example (pure-mod SK activates on key press): @@ -122,7 +129,7 @@ For keymap usage, see `SK(...)` in the [keymap configuration](./layout#keyboard- ### Migration from OSM / OSL -`OSM(mod)` and `OSL(n)` are **still supported** as aliases — they desugar to `SK(mod)` and `SK(MO(n))` respectively, so existing keymaps keep working unchanged. The `SK` forms are the canonical spelling; use whichever you prefer. The old 5-positional `SK` form and the `[behavior.one_shot]` / `[behavior.one_shot_modifiers]` TOML tables, however, are **removed** — using them in `keyboard.toml` is a build error. Rust configurations retain the legacy `BehaviorConfig::one_shot` and `BehaviorConfig::one_shot_modifiers` fields as a compatibility adapter; RMK normalizes them into the default Sticky Key profile once when the keymap is built. +`OSM(mod)` and `OSL(n)` are **still supported** as aliases — they desugar to `SK(mod)` and `SK(MO(n))` respectively, so existing keymaps keep working unchanged. The `SK` forms are the canonical spelling; use whichever you prefer. The old 5-positional `SK` form and the `[behavior.one_shot]` / `[behavior.one_shot_modifiers]` TOML tables, however, are **removed** — using them in `keyboard.toml` is a build error. Rust configurations retain the legacy `BehaviorConfig::one_shot` and `BehaviorConfig::one_shot_modifiers` fields as a compatibility adapter. RMK resolves them before the keymap is built; legacy `quick_release` remains specific to pure-mod/OSM behavior rather than changing layer or tap-key defaults. | Old | New (canonical) | Alias still accepted | |-----|-----------------|----------------------| From 9a2ac5365f699c80b2c2ad6700bc36e0248c73e1 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:52:38 -0500 Subject: [PATCH 108/119] fix(sticky-key): harden ownership and action identity --- rmk/src/keyboard/auto_mouse_layer.rs | 34 ++++++++- rmk/src/keyboard/sticky_key.rs | 100 ++++++++++++++++---------- rmk/tests/keyboard_sticky_key_test.rs | 83 +++++++++++++++++++++ 3 files changed, 179 insertions(+), 38 deletions(-) diff --git a/rmk/src/keyboard/auto_mouse_layer.rs b/rmk/src/keyboard/auto_mouse_layer.rs index 9c025e78f..7b3354290 100644 --- a/rmk/src/keyboard/auto_mouse_layer.rs +++ b/rmk/src/keyboard/auto_mouse_layer.rs @@ -19,7 +19,7 @@ use embassy_futures::select::{Either, Either3, select, select3}; use embassy_time::{Duration, Instant, Timer}; use heapless::Vec; use rmk_macro::processor; -use rmk_types::action::Action; +use rmk_types::action::{Action, StickyKeyEffect}; use rmk_types::keycode::{HidKeyCode, KeyCode}; use rmk_types::modifier::ModifierCombination; @@ -328,6 +328,11 @@ fn keypress_step(entries: &mut [EntryState], action: Action, now: Instant) -> Ve !cfg.extra_mouse_keys.contains(&KeyCode::Hid(hid)) } } + Action::StickyKey(sticky_key) => match sticky_key.effect { + StickyKeyEffect::TapKey { key, .. } if key.is_mouse_key() => false, + StickyKeyEffect::TapKey { key, .. } => !cfg.extra_mouse_keys.contains(&KeyCode::Hid(key)), + StickyKeyEffect::Modifier(_) | StickyKeyEffect::Layer(_) => false, + }, // A modifier-only action (e.g. MT hold) deactivates unless every // contained modifier is covered by a modifier keycode listed in // `extra_mouse_keys` — mirroring how plain modifier keys behave. @@ -809,6 +814,33 @@ mod tests { assert_eq!(entries[0].deadline, Some(at(1000))); } + #[test] + fn keypress_step_classifies_tap_key_sticky_key_by_its_hid_key() { + use rmk_types::action::StickyKeyAction; + + let tap_key = |key| { + Action::StickyKey(StickyKeyAction { + effect: StickyKeyEffect::TapKey { + key, + modifiers: ModifierCombination::LALT, + }, + profile: 0, + }) + }; + + let mut keyboard_entries = [holding_entry_with_deactivate(3, &[])]; + let released = keypress_step(&mut keyboard_entries, tap_key(HidKeyCode::Tab), at(2000)); + assert_eq!(released.as_slice(), &[3]); + assert!(!keyboard_entries[0].self_activated); + assert!(keyboard_entries[0].deadline.is_none()); + + let mut mouse_entries = [holding_entry_with_deactivate(3, &[])]; + let released = keypress_step(&mut mouse_entries, tap_key(HidKeyCode::MouseBtn1), at(2000)); + assert!(released.is_empty()); + assert!(mouse_entries[0].self_activated); + assert_eq!(mouse_entries[0].deadline, Some(at(1000))); + } + #[test] fn keypress_step_keeps_layer_active_for_all_mouse_key_variants() { // Guards against silent divergence if HidKeyCode's mouse range (MouseUp..=MouseAccel2) is extended. diff --git a/rmk/src/keyboard/sticky_key.rs b/rmk/src/keyboard/sticky_key.rs index d6b86f7d1..00fc1d193 100644 --- a/rmk/src/keyboard/sticky_key.rs +++ b/rmk/src/keyboard/sticky_key.rs @@ -34,7 +34,6 @@ struct Latch { source: KeyboardEventPos, policy: StickyKeyPolicy, phase: LatchPhase, - pressed_count: u8, repeat_count: u16, deadline: Option, /// External layer transitions at or before this generation are stale. @@ -55,7 +54,6 @@ impl Latch { source, policy, phase: LatchPhase::Pressed, - pressed_count: 1, repeat_count: 1, deadline: deadline_from_timeout(policy.timeout), layer_generation: LayerTransitionGeneration::current(), @@ -70,7 +68,6 @@ impl Latch { self.source = source; self.policy = policy; self.phase = LatchPhase::Pressed; - self.pressed_count = self.pressed_count.saturating_add(1).max(1); self.deadline = deadline_from_timeout(policy.timeout); self.layer_generation = LayerTransitionGeneration::current(); } @@ -80,20 +77,12 @@ impl Latch { return PhysicalRelease::Ignored; } match self.phase { - LatchPhase::Pressed | LatchPhase::Held if self.pressed_count > 1 => { - self.pressed_count -= 1; - PhysicalRelease::Ignored - } LatchPhase::Pressed => { - self.pressed_count = 0; self.phase = LatchPhase::Latched; self.deadline = deadline_from_timeout(self.policy.timeout); PhysicalRelease::Latched } - LatchPhase::Held => { - self.pressed_count = 0; - PhysicalRelease::Released - } + LatchPhase::Held => PhysicalRelease::Released, LatchPhase::Latched => PhysicalRelease::Ignored, } } @@ -146,12 +135,43 @@ enum TimeoutDisposition { Release, } -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] struct TapKeyEffect { key: HidKeyCode, modifiers: ModifierCombination, } +/// Counted physical ownership used only by accumulated sticky modifiers. +/// Combo outputs may release from a different constituent position, so they +/// cannot use the source-specific ownership used by layer and tap-key effects. +#[derive(Clone, Copy, Debug)] +struct StickyModifierEffect { + modifiers: ModifierCombination, + pressed_count: u8, +} + +impl StickyModifierEffect { + fn new(modifiers: ModifierCombination) -> Self { + Self { + modifiers, + pressed_count: 1, + } + } + + fn begin_press(&mut self, modifiers: ModifierCombination) { + self.modifiers |= modifiers; + self.pressed_count = self.pressed_count.saturating_add(1).max(1); + } + + fn on_physical_release(&mut self) -> bool { + if self.pressed_count == 0 { + return false; + } + self.pressed_count -= 1; + self.pressed_count == 0 + } +} + /// Layer state owned by a Sticky Key lifecycle. #[derive(Clone, Copy, Debug)] struct StickyLayerEffect { @@ -179,7 +199,7 @@ pub(crate) struct StickyKeyUpdate { /// phases, sources, and deadlines. A tap key is exclusive with both. #[derive(Clone, Copy, Debug, Default)] pub(crate) struct StickyKeyState { - modifier: Option>, + modifier: Option>, layer: Option>, tap_key: Option>, } @@ -217,7 +237,7 @@ impl StickyKeyState { if let Some(modifier) = self.modifier && (pressed || modifier.phase == LatchPhase::Held) { - modifiers |= modifier.value; + modifiers |= modifier.value.modifiers; } if let Some(tap_key) = self.tap_key { modifiers |= tap_key.value.modifiers; @@ -260,11 +280,12 @@ impl Keyboard<'_> { match &mut self.sticky_key_state.modifier { Some(latch) => { - latch.value |= modifiers; + latch.value.begin_press(modifiers); latch.begin_press(event.pos, policy); } None => { - self.sticky_key_state.modifier = Some(Latch::new(modifiers, event.pos, policy)); + self.sticky_key_state.modifier = + Some(Latch::new(StickyModifierEffect::new(modifiers), event.pos, policy)); } } if policy.activate_on_keypress { @@ -273,7 +294,7 @@ impl Keyboard<'_> { } else if let Some(latch) = &mut self.sticky_key_state.modifier { // Combo outputs may be released by a different constituent // position, so modifier producers use counted ownership. - if latch.on_physical_release(None) == PhysicalRelease::Released { + if latch.value.on_physical_release() && latch.on_physical_release(None) == PhysicalRelease::Released { self.release_sticky_modifier().await; } } @@ -331,14 +352,16 @@ impl Keyboard<'_> { self.release_sticky_modifier().await; self.release_sticky_layer(); + let effect = TapKeyEffect { key, modifiers }; let same_tap_key = self .sticky_key_state .tap_key - .is_some_and(|latch| latch.source == event.pos && latch.value.key == key); - if self - .sticky_key_state - .tap_key - .is_some_and(|latch| latch.is_double_tap(event.pos, policy)) + .is_some_and(|latch| latch.source == event.pos && latch.value == effect); + if same_tap_key + && self + .sticky_key_state + .tap_key + .is_some_and(|latch| latch.is_double_tap(event.pos, policy)) { self.release_tap_key().await; return; @@ -354,16 +377,11 @@ impl Keyboard<'_> { if policy.max_repeat > 0 && latch.repeat_count > policy.max_repeat { deactivate = true; } else { - latch.policy = policy; - latch.phase = LatchPhase::Pressed; - latch.pressed_count = 1; - latch.deadline = deadline_from_timeout(policy.timeout); - latch.layer_generation = LayerTransitionGeneration::current(); + latch.begin_press(event.pos, policy); } } None => { - self.sticky_key_state.tap_key = - Some(Latch::new(TapKeyEffect { key, modifiers }, event.pos, policy)); + self.sticky_key_state.tap_key = Some(Latch::new(effect, event.pos, policy)); } } @@ -547,38 +565,46 @@ mod tests { } #[test] - fn latch_counts_overlapping_physical_producers() { + fn modifier_effect_counts_overlapping_physical_producers() { let mut latch = Latch::new( - ModifierCombination::LCTRL, + StickyModifierEffect::new(ModifierCombination::LCTRL), pos(0), policy(StickyKeyReleaseMode::OTHER_KEY_RELEASE), ); + latch.value.begin_press(ModifierCombination::LSHIFT); latch.begin_press(pos(1), policy(StickyKeyReleaseMode::OTHER_KEY_RELEASE)); - assert_eq!(latch.on_physical_release(None), PhysicalRelease::Ignored); + assert!(!latch.value.on_physical_release()); assert_eq!(latch.phase, LatchPhase::Pressed); + assert!(latch.value.on_physical_release()); assert_eq!(latch.on_physical_release(None), PhysicalRelease::Latched); assert_eq!(latch.phase, LatchPhase::Latched); + assert_eq!( + latch.value.modifiers, + ModifierCombination::LCTRL | ModifierCombination::LSHIFT + ); } #[test] fn held_latch_releases_after_last_physical_producer() { let mut latch = Latch::new( - ModifierCombination::LCTRL, + StickyModifierEffect::new(ModifierCombination::LCTRL), pos(0), policy(StickyKeyReleaseMode::OTHER_KEY_RELEASE), ); + latch.value.begin_press(ModifierCombination::LSHIFT); latch.begin_press(pos(1), policy(StickyKeyReleaseMode::OTHER_KEY_RELEASE)); latch.mark_foreign_key(); - assert_eq!(latch.on_physical_release(None), PhysicalRelease::Ignored); + assert!(!latch.value.on_physical_release()); + assert!(latch.value.on_physical_release()); assert_eq!(latch.on_physical_release(None), PhysicalRelease::Released); } #[test] fn timeout_is_deferred_while_physical_producer_is_down() { let mut latch = Latch::new( - ModifierCombination::LCTRL, + StickyModifierEffect::new(ModifierCombination::LCTRL), pos(0), policy(StickyKeyReleaseMode::OTHER_KEY_RELEASE), ); @@ -592,7 +618,7 @@ mod tests { #[test] fn external_layer_events_only_release_their_current_lifecycle() { let mut latch = Latch::new( - ModifierCombination::LCTRL, + StickyModifierEffect::new(ModifierCombination::LCTRL), pos(0), policy(StickyKeyReleaseMode::LAYER_ENTER), ); diff --git a/rmk/tests/keyboard_sticky_key_test.rs b/rmk/tests/keyboard_sticky_key_test.rs index 0f44df7be..1730ba9c1 100644 --- a/rmk/tests/keyboard_sticky_key_test.rs +++ b/rmk/tests/keyboard_sticky_key_test.rs @@ -100,6 +100,11 @@ const KEYMAP_LAYER_OWNERSHIP: [[[KeyAction; 3]; 1]; 2] = [ [[a!(Transparent), a!(Transparent), k!(C)]], ]; +const KEYMAP_OVERLAPPING_STICKY_LAYERS: [[[KeyAction; 3]; 1]; 2] = [ + [[sk_layer!(1), sk_layer!(1), k!(A)]], + [[a!(Transparent), a!(Transparent), k!(C)]], +]; + fn create_mixed_report_keyboard_with_policy( activate_on_keypress: bool, release_mode: StickyKeyReleaseMode, @@ -309,6 +314,38 @@ fn same_sticky_layer_repress_preserves_cleanup_ownership() { }; } +#[test] +fn overlapping_sources_for_same_sticky_layer_release_cleanly() { + let behavior = Box::leak(Box::new(BehaviorConfig { + sticky_key: sticky_key_config_with_release_mode(StickyKeyReleaseMode::OTHER_KEY_RELEASE), + ..BehaviorConfig::default() + })); + let positional = Box::leak(Box::new(PositionalConfig::<1, 3>::default())); + key_sequence_test! { + keyboard: Keyboard::new(wrap_keymap( + KEYMAP_OVERLAPPING_STICKY_LAYERS, + positional, + behavior, + )), + sequence: [ + [0, 0, true, 0], + [0, 1, true, 0], + [0, 0, false, 0], + [0, 1, false, 0], + [0, 2, true, 0], + [0, 2, false, 0], + [0, 2, true, 0], + [0, 2, false, 0], + ], + expected_reports: [ + [0, [kc_to_u8!(C), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + [0, [kc_to_u8!(A), 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + ] + }; +} + // KEYMAP (release_on_layer_change=true is set in the helper config, not per-key) // Layer 0: A B C MO(1) LShift No // Layer 1: SK(Tab,LAlt) SK(Tab,LCtrl) SK(Tab,LCtrl|LShift) Transparent Transparent No @@ -1286,6 +1323,11 @@ const KEYMAP_TAP_SK: [[[KeyAction; 2]; 1]; 1] = [[[sk!(Tab, ModifierCombination: const KEYMAP_PROFILED_TAP_SK: [[[KeyAction; 2]; 1]; 1] = [[[sk!(Tab, ModifierCombination::LALT, 0), k!(A)]]]; +const KEYMAP_SAME_SOURCE_TAP_SK_MODIFIERS: [[[KeyAction; 2]; 1]; 2] = [ + [[sk!(Tab, ModifierCombination::LALT, 0), mo!(1)]], + [[sk!(Tab, ModifierCombination::LCTRL, 0), a!(Transparent)]], +]; + const KEYMAP_PROFILED_PURE_MODS: [[[KeyAction; 3]; 1]; 1] = [[[ sk_mod!(ModifierCombination::LSHIFT, 0), sk_mod!(ModifierCombination::LCTRL, 1), @@ -1367,6 +1409,47 @@ fn tap_key_other_key_release_keeps_modifier_through_release_report() { }; } +#[test] +fn same_source_and_key_replaces_changed_tap_key_modifiers() { + let mut sticky_key = StickyKeyConfig::default(); + sticky_key + .profiles + .push(StickyKeyProfile { + release_mode: Some(StickyKeyReleaseMode::OTHER_KEY_RELEASE), + ..StickyKeyProfile::default() + }) + .unwrap(); + let behavior = Box::leak(Box::new(BehaviorConfig { + sticky_key, + ..BehaviorConfig::default() + })); + let positional = Box::leak(Box::new(PositionalConfig::<1, 2>::default())); + + key_sequence_test! { + keyboard: Keyboard::new(wrap_keymap( + KEYMAP_SAME_SOURCE_TAP_SK_MODIFIERS, + positional, + behavior, + )), + sequence: [ + [0, 0, true, 0], + [0, 0, false, 0], + [0, 1, true, 0], + [0, 0, true, 0], + [0, 0, false, 0], + [0, 1, false, 0], + ], + expected_reports: [ + [KC_LALT, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], + [KC_LALT, [0, 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + [KC_LCTRL, [kc_to_u8!(Tab), 0, 0, 0, 0, 0]], + [KC_LCTRL, [0, 0, 0, 0, 0, 0]], + [0, [0, 0, 0, 0, 0, 0]], + ] + }; +} + #[test] fn tap_key_double_tap_releases_instead_of_cycling() { key_sequence_test! { From c8eed0d4b1208850303e3ccfb9d7e27ec1bd5d6c Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Wed, 22 Jul 2026 23:28:10 -0500 Subject: [PATCH 109/119] chore: update example lockfiles --- examples/use_config/esp32_ble_split/Cargo.lock | 2 ++ examples/use_config/esp32c3_ble/Cargo.lock | 2 ++ examples/use_config/esp32c6_ble/Cargo.lock | 2 ++ examples/use_config/esp32h2_ble/Cargo.lock | 2 ++ examples/use_config/esp32s3_ble/Cargo.lock | 2 ++ examples/use_config/nrf52832_ble/Cargo.lock | 2 ++ examples/use_config/nrf52840_ble/Cargo.lock | 2 ++ examples/use_config/nrf52840_ble_split/Cargo.lock | 2 ++ examples/use_config/nrf52840_ble_split_direct_pin/Cargo.lock | 2 ++ examples/use_config/nrf52840_ble_split_dongle/Cargo.lock | 2 ++ examples/use_config/nrf52840_embassy_boot/Cargo.lock | 2 ++ examples/use_config/pi_pico_w_ble/Cargo.lock | 2 ++ examples/use_config/pi_pico_w_ble_split/Cargo.lock | 2 ++ examples/use_config/rp2040/Cargo.lock | 2 ++ examples/use_config/rp2040_dfu_split/Cargo.lock | 2 ++ examples/use_config/rp2040_direct_pin/Cargo.lock | 2 ++ examples/use_config/rp2040_embassy_boot/Cargo.lock | 2 ++ examples/use_config/rp2040_oled/Cargo.lock | 2 ++ examples/use_config/rp2040_pointing_modes/Cargo.lock | 2 ++ examples/use_config/rp2040_split/Cargo.lock | 2 ++ examples/use_config/rp2040_split_pio/Cargo.lock | 2 ++ examples/use_config/stm32f1/Cargo.lock | 2 ++ examples/use_config/stm32f4/Cargo.lock | 2 ++ examples/use_config/stm32h7/Cargo.lock | 2 ++ examples/use_rust/custom_renderer/Cargo.lock | 2 ++ examples/use_rust/esp32c3_ble/Cargo.lock | 2 ++ examples/use_rust/esp32c6_ble/Cargo.lock | 2 ++ examples/use_rust/esp32h2_ble/Cargo.lock | 2 ++ examples/use_rust/esp32s3_ble/Cargo.lock | 2 ++ examples/use_rust/nrf52832_ble/Cargo.lock | 2 ++ examples/use_rust/nrf52840/Cargo.lock | 2 ++ examples/use_rust/nrf52840_ble/Cargo.lock | 2 ++ examples/use_rust/nrf52840_ble_split/Cargo.lock | 2 ++ examples/use_rust/nrf52840_ble_split_dongle/Cargo.lock | 2 ++ examples/use_rust/nrf52840_embassy_boot/Cargo.lock | 2 ++ examples/use_rust/nrf54l15_ble/Cargo.lock | 2 ++ examples/use_rust/nrf54lm20_ble/Cargo.lock | 2 ++ examples/use_rust/pi_pico_w_ble/Cargo.lock | 2 ++ examples/use_rust/pi_pico_w_ble_split/Cargo.lock | 2 ++ examples/use_rust/rp2040/Cargo.lock | 2 ++ examples/use_rust/rp2040_dfu_split/Cargo.lock | 2 ++ examples/use_rust/rp2040_direct_pin/Cargo.lock | 2 ++ examples/use_rust/rp2040_embassy_boot/Cargo.lock | 2 ++ examples/use_rust/rp2040_embassy_boot_split/Cargo.lock | 2 ++ examples/use_rust/rp2040_oled/Cargo.lock | 2 ++ examples/use_rust/rp2040_pointing_modes/Cargo.lock | 2 ++ examples/use_rust/rp2040_split/Cargo.lock | 2 ++ examples/use_rust/rp2040_split_pio/Cargo.lock | 2 ++ examples/use_rust/rp2350/Cargo.lock | 2 ++ examples/use_rust/sf32lb52x_ble/Cargo.lock | 2 ++ examples/use_rust/stm32f1/Cargo.lock | 2 ++ examples/use_rust/stm32f4/Cargo.lock | 2 ++ examples/use_rust/stm32g4/Cargo.lock | 2 ++ examples/use_rust/stm32h7/Cargo.lock | 2 ++ 54 files changed, 108 insertions(+) diff --git a/examples/use_config/esp32_ble_split/Cargo.lock b/examples/use_config/esp32_ble_split/Cargo.lock index 93853f94d..3dc3a051f 100644 --- a/examples/use_config/esp32_ble_split/Cargo.lock +++ b/examples/use_config/esp32_ble_split/Cargo.lock @@ -2228,6 +2228,7 @@ dependencies = [ name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci 0.9.0", "byteorder", "cortex-m", @@ -2262,6 +2263,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_config/esp32c3_ble/Cargo.lock b/examples/use_config/esp32c3_ble/Cargo.lock index 367cda611..a59d55462 100644 --- a/examples/use_config/esp32c3_ble/Cargo.lock +++ b/examples/use_config/esp32c3_ble/Cargo.lock @@ -2225,6 +2225,7 @@ dependencies = [ name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -2258,6 +2259,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_config/esp32c6_ble/Cargo.lock b/examples/use_config/esp32c6_ble/Cargo.lock index c13ec86e8..1aece1d9e 100644 --- a/examples/use_config/esp32c6_ble/Cargo.lock +++ b/examples/use_config/esp32c6_ble/Cargo.lock @@ -2235,6 +2235,7 @@ dependencies = [ name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -2268,6 +2269,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_config/esp32h2_ble/Cargo.lock b/examples/use_config/esp32h2_ble/Cargo.lock index f67f07ae5..301c6b1f1 100644 --- a/examples/use_config/esp32h2_ble/Cargo.lock +++ b/examples/use_config/esp32h2_ble/Cargo.lock @@ -2231,6 +2231,7 @@ dependencies = [ name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -2264,6 +2265,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_config/esp32s3_ble/Cargo.lock b/examples/use_config/esp32s3_ble/Cargo.lock index 4ae33f05b..9b72898bd 100644 --- a/examples/use_config/esp32s3_ble/Cargo.lock +++ b/examples/use_config/esp32s3_ble/Cargo.lock @@ -2261,6 +2261,7 @@ dependencies = [ name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -2294,6 +2295,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_config/nrf52832_ble/Cargo.lock b/examples/use_config/nrf52832_ble/Cargo.lock index a63df2114..77f7754fb 100644 --- a/examples/use_config/nrf52832_ble/Cargo.lock +++ b/examples/use_config/nrf52832_ble/Cargo.lock @@ -1879,6 +1879,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -1913,6 +1914,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_config/nrf52840_ble/Cargo.lock b/examples/use_config/nrf52840_ble/Cargo.lock index b516468e8..0e993aa1c 100644 --- a/examples/use_config/nrf52840_ble/Cargo.lock +++ b/examples/use_config/nrf52840_ble/Cargo.lock @@ -1875,6 +1875,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -1909,6 +1910,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_config/nrf52840_ble_split/Cargo.lock b/examples/use_config/nrf52840_ble_split/Cargo.lock index b516468e8..0e993aa1c 100644 --- a/examples/use_config/nrf52840_ble_split/Cargo.lock +++ b/examples/use_config/nrf52840_ble_split/Cargo.lock @@ -1875,6 +1875,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -1909,6 +1910,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_config/nrf52840_ble_split_direct_pin/Cargo.lock b/examples/use_config/nrf52840_ble_split_direct_pin/Cargo.lock index b516468e8..0e993aa1c 100644 --- a/examples/use_config/nrf52840_ble_split_direct_pin/Cargo.lock +++ b/examples/use_config/nrf52840_ble_split_direct_pin/Cargo.lock @@ -1875,6 +1875,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -1909,6 +1910,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_config/nrf52840_ble_split_dongle/Cargo.lock b/examples/use_config/nrf52840_ble_split_dongle/Cargo.lock index b516468e8..0e993aa1c 100644 --- a/examples/use_config/nrf52840_ble_split_dongle/Cargo.lock +++ b/examples/use_config/nrf52840_ble_split_dongle/Cargo.lock @@ -1875,6 +1875,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -1909,6 +1910,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_config/nrf52840_embassy_boot/Cargo.lock b/examples/use_config/nrf52840_embassy_boot/Cargo.lock index d2a62d33f..766fd144e 100644 --- a/examples/use_config/nrf52840_embassy_boot/Cargo.lock +++ b/examples/use_config/nrf52840_embassy_boot/Cargo.lock @@ -1926,6 +1926,7 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci 0.9.0", "byteorder", "cortex-m", @@ -1962,6 +1963,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_config/pi_pico_w_ble/Cargo.lock b/examples/use_config/pi_pico_w_ble/Cargo.lock index 1ae8dfd43..e27528e27 100644 --- a/examples/use_config/pi_pico_w_ble/Cargo.lock +++ b/examples/use_config/pi_pico_w_ble/Cargo.lock @@ -2672,6 +2672,7 @@ dependencies = [ name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -2710,6 +2711,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_config/pi_pico_w_ble_split/Cargo.lock b/examples/use_config/pi_pico_w_ble_split/Cargo.lock index 1ae8dfd43..e27528e27 100644 --- a/examples/use_config/pi_pico_w_ble_split/Cargo.lock +++ b/examples/use_config/pi_pico_w_ble_split/Cargo.lock @@ -2672,6 +2672,7 @@ dependencies = [ name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -2710,6 +2711,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_config/rp2040/Cargo.lock b/examples/use_config/rp2040/Cargo.lock index 47f0e9532..dd7442e1a 100644 --- a/examples/use_config/rp2040/Cargo.lock +++ b/examples/use_config/rp2040/Cargo.lock @@ -2014,6 +2014,7 @@ checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -2052,6 +2053,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_config/rp2040_dfu_split/Cargo.lock b/examples/use_config/rp2040_dfu_split/Cargo.lock index 0a6a76717..2063ea457 100644 --- a/examples/use_config/rp2040_dfu_split/Cargo.lock +++ b/examples/use_config/rp2040_dfu_split/Cargo.lock @@ -2064,6 +2064,7 @@ checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -2105,6 +2106,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_config/rp2040_direct_pin/Cargo.lock b/examples/use_config/rp2040_direct_pin/Cargo.lock index 47f0e9532..dd7442e1a 100644 --- a/examples/use_config/rp2040_direct_pin/Cargo.lock +++ b/examples/use_config/rp2040_direct_pin/Cargo.lock @@ -2014,6 +2014,7 @@ checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -2052,6 +2053,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_config/rp2040_embassy_boot/Cargo.lock b/examples/use_config/rp2040_embassy_boot/Cargo.lock index d6c84e160..69160c178 100644 --- a/examples/use_config/rp2040_embassy_boot/Cargo.lock +++ b/examples/use_config/rp2040_embassy_boot/Cargo.lock @@ -2065,6 +2065,7 @@ checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -2106,6 +2107,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_config/rp2040_oled/Cargo.lock b/examples/use_config/rp2040_oled/Cargo.lock index 4cc80492e..d01bd6216 100644 --- a/examples/use_config/rp2040_oled/Cargo.lock +++ b/examples/use_config/rp2040_oled/Cargo.lock @@ -2144,6 +2144,7 @@ checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -2186,6 +2187,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_config/rp2040_pointing_modes/Cargo.lock b/examples/use_config/rp2040_pointing_modes/Cargo.lock index 47f0e9532..dd7442e1a 100644 --- a/examples/use_config/rp2040_pointing_modes/Cargo.lock +++ b/examples/use_config/rp2040_pointing_modes/Cargo.lock @@ -2014,6 +2014,7 @@ checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -2052,6 +2053,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_config/rp2040_split/Cargo.lock b/examples/use_config/rp2040_split/Cargo.lock index 1e2ea8a40..76a3190a7 100644 --- a/examples/use_config/rp2040_split/Cargo.lock +++ b/examples/use_config/rp2040_split/Cargo.lock @@ -2014,6 +2014,7 @@ checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -2052,6 +2053,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_config/rp2040_split_pio/Cargo.lock b/examples/use_config/rp2040_split_pio/Cargo.lock index 1e2ea8a40..76a3190a7 100644 --- a/examples/use_config/rp2040_split_pio/Cargo.lock +++ b/examples/use_config/rp2040_split_pio/Cargo.lock @@ -2014,6 +2014,7 @@ checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -2052,6 +2053,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_config/stm32f1/Cargo.lock b/examples/use_config/stm32f1/Cargo.lock index d3a8dec63..fcfd53e60 100644 --- a/examples/use_config/stm32f1/Cargo.lock +++ b/examples/use_config/stm32f1/Cargo.lock @@ -1393,6 +1393,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "cortex-m", "crc32fast", "document-features", @@ -1418,6 +1419,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_config/stm32f4/Cargo.lock b/examples/use_config/stm32f4/Cargo.lock index d74309006..4da1d0940 100644 --- a/examples/use_config/stm32f4/Cargo.lock +++ b/examples/use_config/stm32f4/Cargo.lock @@ -1791,6 +1791,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -1825,6 +1826,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_config/stm32h7/Cargo.lock b/examples/use_config/stm32h7/Cargo.lock index 65a42a5a1..387613b52 100644 --- a/examples/use_config/stm32h7/Cargo.lock +++ b/examples/use_config/stm32h7/Cargo.lock @@ -1428,6 +1428,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "byteorder", "cortex-m", "crc32fast", @@ -1454,6 +1455,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_rust/custom_renderer/Cargo.lock b/examples/use_rust/custom_renderer/Cargo.lock index 110ad7588..799a31dd5 100644 --- a/examples/use_rust/custom_renderer/Cargo.lock +++ b/examples/use_rust/custom_renderer/Cargo.lock @@ -1277,6 +1277,7 @@ checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "cortex-m", "crc32fast", @@ -1309,6 +1310,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_rust/esp32c3_ble/Cargo.lock b/examples/use_rust/esp32c3_ble/Cargo.lock index 367cda611..a59d55462 100644 --- a/examples/use_rust/esp32c3_ble/Cargo.lock +++ b/examples/use_rust/esp32c3_ble/Cargo.lock @@ -2225,6 +2225,7 @@ dependencies = [ name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -2258,6 +2259,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_rust/esp32c6_ble/Cargo.lock b/examples/use_rust/esp32c6_ble/Cargo.lock index c13ec86e8..1aece1d9e 100644 --- a/examples/use_rust/esp32c6_ble/Cargo.lock +++ b/examples/use_rust/esp32c6_ble/Cargo.lock @@ -2235,6 +2235,7 @@ dependencies = [ name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -2268,6 +2269,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_rust/esp32h2_ble/Cargo.lock b/examples/use_rust/esp32h2_ble/Cargo.lock index f67f07ae5..301c6b1f1 100644 --- a/examples/use_rust/esp32h2_ble/Cargo.lock +++ b/examples/use_rust/esp32h2_ble/Cargo.lock @@ -2231,6 +2231,7 @@ dependencies = [ name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -2264,6 +2265,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_rust/esp32s3_ble/Cargo.lock b/examples/use_rust/esp32s3_ble/Cargo.lock index 4ae33f05b..9b72898bd 100644 --- a/examples/use_rust/esp32s3_ble/Cargo.lock +++ b/examples/use_rust/esp32s3_ble/Cargo.lock @@ -2261,6 +2261,7 @@ dependencies = [ name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -2294,6 +2295,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_rust/nrf52832_ble/Cargo.lock b/examples/use_rust/nrf52832_ble/Cargo.lock index a63df2114..77f7754fb 100644 --- a/examples/use_rust/nrf52832_ble/Cargo.lock +++ b/examples/use_rust/nrf52832_ble/Cargo.lock @@ -1879,6 +1879,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -1913,6 +1914,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_rust/nrf52840/Cargo.lock b/examples/use_rust/nrf52840/Cargo.lock index a781ecbe3..28afa8a56 100644 --- a/examples/use_rust/nrf52840/Cargo.lock +++ b/examples/use_rust/nrf52840/Cargo.lock @@ -1662,6 +1662,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -1696,6 +1697,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_rust/nrf52840_ble/Cargo.lock b/examples/use_rust/nrf52840_ble/Cargo.lock index 2fcc7ab6e..35296c807 100644 --- a/examples/use_rust/nrf52840_ble/Cargo.lock +++ b/examples/use_rust/nrf52840_ble/Cargo.lock @@ -1875,6 +1875,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -1909,6 +1910,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_rust/nrf52840_ble_split/Cargo.lock b/examples/use_rust/nrf52840_ble_split/Cargo.lock index b516468e8..0e993aa1c 100644 --- a/examples/use_rust/nrf52840_ble_split/Cargo.lock +++ b/examples/use_rust/nrf52840_ble_split/Cargo.lock @@ -1875,6 +1875,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -1909,6 +1910,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_rust/nrf52840_ble_split_dongle/Cargo.lock b/examples/use_rust/nrf52840_ble_split_dongle/Cargo.lock index b516468e8..0e993aa1c 100644 --- a/examples/use_rust/nrf52840_ble_split_dongle/Cargo.lock +++ b/examples/use_rust/nrf52840_ble_split_dongle/Cargo.lock @@ -1875,6 +1875,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -1909,6 +1910,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_rust/nrf52840_embassy_boot/Cargo.lock b/examples/use_rust/nrf52840_embassy_boot/Cargo.lock index a2ce886fe..5bf044efd 100644 --- a/examples/use_rust/nrf52840_embassy_boot/Cargo.lock +++ b/examples/use_rust/nrf52840_embassy_boot/Cargo.lock @@ -1695,6 +1695,7 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -1731,6 +1732,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_rust/nrf54l15_ble/Cargo.lock b/examples/use_rust/nrf54l15_ble/Cargo.lock index f5fa70e1b..84c19842b 100644 --- a/examples/use_rust/nrf54l15_ble/Cargo.lock +++ b/examples/use_rust/nrf54l15_ble/Cargo.lock @@ -1875,6 +1875,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -1909,6 +1910,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_rust/nrf54lm20_ble/Cargo.lock b/examples/use_rust/nrf54lm20_ble/Cargo.lock index 2c3884962..f8acc00ca 100644 --- a/examples/use_rust/nrf54lm20_ble/Cargo.lock +++ b/examples/use_rust/nrf54lm20_ble/Cargo.lock @@ -1877,6 +1877,7 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -1911,6 +1912,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_rust/pi_pico_w_ble/Cargo.lock b/examples/use_rust/pi_pico_w_ble/Cargo.lock index 1ae8dfd43..e27528e27 100644 --- a/examples/use_rust/pi_pico_w_ble/Cargo.lock +++ b/examples/use_rust/pi_pico_w_ble/Cargo.lock @@ -2672,6 +2672,7 @@ dependencies = [ name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -2710,6 +2711,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_rust/pi_pico_w_ble_split/Cargo.lock b/examples/use_rust/pi_pico_w_ble_split/Cargo.lock index 1ae8dfd43..e27528e27 100644 --- a/examples/use_rust/pi_pico_w_ble_split/Cargo.lock +++ b/examples/use_rust/pi_pico_w_ble_split/Cargo.lock @@ -2672,6 +2672,7 @@ dependencies = [ name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -2710,6 +2711,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_rust/rp2040/Cargo.lock b/examples/use_rust/rp2040/Cargo.lock index 47f0e9532..dd7442e1a 100644 --- a/examples/use_rust/rp2040/Cargo.lock +++ b/examples/use_rust/rp2040/Cargo.lock @@ -2014,6 +2014,7 @@ checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -2052,6 +2053,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_rust/rp2040_dfu_split/Cargo.lock b/examples/use_rust/rp2040_dfu_split/Cargo.lock index e9d1e8e9f..ff3d4568d 100644 --- a/examples/use_rust/rp2040_dfu_split/Cargo.lock +++ b/examples/use_rust/rp2040_dfu_split/Cargo.lock @@ -2064,6 +2064,7 @@ checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -2105,6 +2106,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_rust/rp2040_direct_pin/Cargo.lock b/examples/use_rust/rp2040_direct_pin/Cargo.lock index 47f0e9532..dd7442e1a 100644 --- a/examples/use_rust/rp2040_direct_pin/Cargo.lock +++ b/examples/use_rust/rp2040_direct_pin/Cargo.lock @@ -2014,6 +2014,7 @@ checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -2052,6 +2053,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_rust/rp2040_embassy_boot/Cargo.lock b/examples/use_rust/rp2040_embassy_boot/Cargo.lock index d6c84e160..69160c178 100644 --- a/examples/use_rust/rp2040_embassy_boot/Cargo.lock +++ b/examples/use_rust/rp2040_embassy_boot/Cargo.lock @@ -2065,6 +2065,7 @@ checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -2106,6 +2107,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_rust/rp2040_embassy_boot_split/Cargo.lock b/examples/use_rust/rp2040_embassy_boot_split/Cargo.lock index 8a9fd9370..00595933d 100644 --- a/examples/use_rust/rp2040_embassy_boot_split/Cargo.lock +++ b/examples/use_rust/rp2040_embassy_boot_split/Cargo.lock @@ -2064,6 +2064,7 @@ checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -2105,6 +2106,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_rust/rp2040_oled/Cargo.lock b/examples/use_rust/rp2040_oled/Cargo.lock index e749ff730..5511da818 100644 --- a/examples/use_rust/rp2040_oled/Cargo.lock +++ b/examples/use_rust/rp2040_oled/Cargo.lock @@ -2012,6 +2012,7 @@ checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "byteorder", "cortex-m", "crc32fast", @@ -2052,6 +2053,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_rust/rp2040_pointing_modes/Cargo.lock b/examples/use_rust/rp2040_pointing_modes/Cargo.lock index 47f0e9532..dd7442e1a 100644 --- a/examples/use_rust/rp2040_pointing_modes/Cargo.lock +++ b/examples/use_rust/rp2040_pointing_modes/Cargo.lock @@ -2014,6 +2014,7 @@ checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -2052,6 +2053,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_rust/rp2040_split/Cargo.lock b/examples/use_rust/rp2040_split/Cargo.lock index 1e2ea8a40..76a3190a7 100644 --- a/examples/use_rust/rp2040_split/Cargo.lock +++ b/examples/use_rust/rp2040_split/Cargo.lock @@ -2014,6 +2014,7 @@ checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -2052,6 +2053,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_rust/rp2040_split_pio/Cargo.lock b/examples/use_rust/rp2040_split_pio/Cargo.lock index 1e2ea8a40..76a3190a7 100644 --- a/examples/use_rust/rp2040_split_pio/Cargo.lock +++ b/examples/use_rust/rp2040_split_pio/Cargo.lock @@ -2014,6 +2014,7 @@ checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -2052,6 +2053,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_rust/rp2350/Cargo.lock b/examples/use_rust/rp2350/Cargo.lock index 121ee782c..6d64ff07a 100644 --- a/examples/use_rust/rp2350/Cargo.lock +++ b/examples/use_rust/rp2350/Cargo.lock @@ -2015,6 +2015,7 @@ checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -2049,6 +2050,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_rust/sf32lb52x_ble/Cargo.lock b/examples/use_rust/sf32lb52x_ble/Cargo.lock index 53db54974..da96a6d1b 100644 --- a/examples/use_rust/sf32lb52x_ble/Cargo.lock +++ b/examples/use_rust/sf32lb52x_ble/Cargo.lock @@ -1846,6 +1846,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct 0.13.0", "bt-hci 0.9.0", "byteorder", "cortex-m", @@ -1882,6 +1883,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct 0.13.0", "config", "once_cell", "paste", diff --git a/examples/use_rust/stm32f1/Cargo.lock b/examples/use_rust/stm32f1/Cargo.lock index d2fd266b5..169521440 100644 --- a/examples/use_rust/stm32f1/Cargo.lock +++ b/examples/use_rust/stm32f1/Cargo.lock @@ -1390,6 +1390,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "cortex-m", "crc32fast", "document-features", @@ -1415,6 +1416,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_rust/stm32f4/Cargo.lock b/examples/use_rust/stm32f4/Cargo.lock index d74309006..4da1d0940 100644 --- a/examples/use_rust/stm32f4/Cargo.lock +++ b/examples/use_rust/stm32f4/Cargo.lock @@ -1791,6 +1791,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -1825,6 +1826,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_rust/stm32g4/Cargo.lock b/examples/use_rust/stm32g4/Cargo.lock index 4ab0d8a73..802e95ee0 100644 --- a/examples/use_rust/stm32g4/Cargo.lock +++ b/examples/use_rust/stm32g4/Cargo.lock @@ -1787,6 +1787,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "cortex-m", "crc32fast", @@ -1817,6 +1818,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", diff --git a/examples/use_rust/stm32h7/Cargo.lock b/examples/use_rust/stm32h7/Cargo.lock index ee287f4bf..a56f8774d 100644 --- a/examples/use_rust/stm32h7/Cargo.lock +++ b/examples/use_rust/stm32h7/Cargo.lock @@ -1787,6 +1787,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" name = "rmk" version = "0.8.2" dependencies = [ + "bitfield-struct", "bt-hci", "byteorder", "cortex-m", @@ -1821,6 +1822,7 @@ dependencies = [ name = "rmk-config" version = "0.6.1" dependencies = [ + "bitfield-struct", "config", "once_cell", "paste", From d5026f71066a626c33154865f65b9a238ec3043e Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:01:57 -0500 Subject: [PATCH 110/119] docs(sticky-key): clarify configuration semantics --- docs/docs/main/docs/configuration/appendix.md | 2 +- docs/docs/main/docs/configuration/behavior.md | 10 +++++----- docs/docs/main/docs/configuration/rmk_config.md | 4 ++-- rmk/CHANGELOG.md | 2 ++ 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/docs/main/docs/configuration/appendix.md b/docs/docs/main/docs/configuration/appendix.md index 4780d145f..64c6f7aad 100644 --- a/docs/docs/main/docs/configuration/appendix.md +++ b/docs/docs/main/docs/configuration/appendix.md @@ -120,7 +120,7 @@ sticky_key = { timeout = "1s", activate_on_keypress = false, max_repeat = 0, - # release_mode = "other_key_release | layer_exit | double_tap", + # release_mode is unset; shape-native defaults apply } [behavior.morse] diff --git a/docs/docs/main/docs/configuration/behavior.md b/docs/docs/main/docs/configuration/behavior.md index b939b2f38..8b6fe2bc9 100644 --- a/docs/docs/main/docs/configuration/behavior.md +++ b/docs/docs/main/docs/configuration/behavior.md @@ -41,9 +41,9 @@ The `[behavior.sticky_key]` table configures the unified **Sticky Key** (`SK`) f | Shape | Syntax | Behavior | |-------|--------|----------| -| Pure-mod | `SK(LGui)` (modifiers chain like `WM`, e.g. `SK(LCtrl\|LShift)`) | One-shot modifier — the modifier is held for the next key press, then released automatically. | -| Layer | `SK(MO(n))` | One-shot layer — layer `n` is active for the next key press, then released. | -| Tap-key | `SK(Tab, [LAlt])` (the modifier list is in `[ ]`; modifiers chain, e.g. `SK(Tab, [LCtrl\|LShift])`) | The modifier stays held across **repeated presses of the same key** (Alt+Tab-style window/tab cycling): the first press sends `modifier + key`, each subsequent press keeps the modifier held. Releases automatically when any non-SK, non-modifier key is pressed. | +| Pure-mod | `SK(LGui)` (modifiers chain like `WM`, e.g. `SK(LCtrl\|LShift)`) | One-shot modifier — applies the modifier to the next key and, by default, releases it with that key. | +| Layer | `SK(MO(n))` | One-shot layer — layer `n` applies to the next key and, by default, releases with that key. | +| Tap-key | `SK(Tab, [LAlt])` (the modifier list is in `[ ]`; modifiers chain, e.g. `SK(Tab, [LCtrl\|LShift])`) | Alt+Tab-style cycling: the first press sends `modifier + key`, and repeated presses keep the modifier held. By default, another non-SK, non-modifier key press ends the sequence. | ### Config fields @@ -51,7 +51,7 @@ The `[behavior.sticky_key]` table configures the unified **Sticky Key** (`SK`) f |-------|---------|---------| | `timeout` | `"1s"` | Auto-release an unused sticky key after this idle time. String suffixed `s` or `ms`. | | `activate_on_keypress` | `false` | **Pure-mod SKs only.** When `true`, send the modifier immediately as the SK key itself is pressed, instead of waiting and applying it to the next key. (Also known as One-Shot Sticky Modifiers / OSSM.) | -| `max_repeat` | `0` | **Tap-key SKs only.** Caps how many repeated presses of the key keep the modifier held; `0` = unlimited. Pure-mod (`SK(LGui)`) and layer (`SK(MO(n))`) SKs ignore this — they always apply to exactly one following key. | +| `max_repeat` | `0` | **Tap-key SKs only.** Maximum total key activations in one sequence, including the first; `0` = unlimited. For example, `1` prevents a second cycling press. Pure-mod and layer SKs ignore this. | | `release_mode` | unset | Optional `|`-separated release triggers: `other_key_press`, `other_key_release`, `layer_enter`, `layer_exit`, and `double_tap`. | The default table applies to every Sticky Key. Define named overrides in @@ -129,7 +129,7 @@ For keymap usage, see `SK(...)` in the [keymap configuration](./layout#keyboard- ### Migration from OSM / OSL -`OSM(mod)` and `OSL(n)` are **still supported** as aliases — they desugar to `SK(mod)` and `SK(MO(n))` respectively, so existing keymaps keep working unchanged. The `SK` forms are the canonical spelling; use whichever you prefer. The old 5-positional `SK` form and the `[behavior.one_shot]` / `[behavior.one_shot_modifiers]` TOML tables, however, are **removed** — using them in `keyboard.toml` is a build error. Rust configurations retain the legacy `BehaviorConfig::one_shot` and `BehaviorConfig::one_shot_modifiers` fields as a compatibility adapter. RMK resolves them before the keymap is built; legacy `quick_release` remains specific to pure-mod/OSM behavior rather than changing layer or tap-key defaults. +`OSM(mod)` and `OSL(n)` are **still supported** as aliases — they desugar to `SK(mod)` and `SK(MO(n))` respectively, so existing keymaps keep working unchanged. The `SK` forms are the canonical spelling; use whichever you prefer. The old 5-positional `SK` form and the `[behavior.one_shot]` / `[behavior.one_shot_modifiers]` TOML tables, however, are **removed** — using them in `keyboard.toml` is a build error. Rust configurations retain the legacy `BehaviorConfig::one_shot` and `BehaviorConfig::one_shot_modifiers` fields as a compatibility adapter. Avoid setting legacy and canonical fields together: legacy timeout fills only the canonical default, legacy `activate_on_keypress = true` enables the canonical default, and an explicit canonical `release_mode` overrides legacy `quick_release`. Otherwise, `quick_release` remains specific to pure-mod/OSM behavior. | Old | New (canonical) | Alias still accepted | |-----|-----------------|----------------------| diff --git a/docs/docs/main/docs/configuration/rmk_config.md b/docs/docs/main/docs/configuration/rmk_config.md index ce0ab69ca..876277b63 100644 --- a/docs/docs/main/docs/configuration/rmk_config.md +++ b/docs/docs/main/docs/configuration/rmk_config.md @@ -19,7 +19,7 @@ fork_max_num = 8 # Maximum number of morse keys keyboard can store (max 256) morse_max_num = 8 # Optional maximum number of named Sticky Key profiles (max 255). -# Omit this to derive the capacity from [behavior.sticky_key.profiles]. +# Omit this to use at least 4 slots, growing to fit configured profiles. 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 @@ -62,7 +62,7 @@ Increasing the number of combos, forks, morses (tap dances), and macros will inc - `combo_max_length`: Maximum number of keys that can be pressed simultaneously in a combo, default value is 4. - `fork_max_num`: Maximum number of forks for conditional key actions, default value is 8. This value must be between 0 and 256. - `morse_max_num`: Maximum number of morses that can be stored, default value is 8. This value must be between 0 and 256. -- `sticky_key_profile_max_num`: Optional capacity override for the named Sticky Key profile table. When omitted, TOML configurations derive the capacity from the configured profile count; Rust-only configurations reserve 4 entries. Set it to `0` to opt out or to a larger value when profiles are added at runtime. This value must be between 0 and 255. +- `sticky_key_profile_max_num`: Optional capacity override for named Sticky Key profiles. When omitted, capacity is the greater of 4 or the configured profile count. Set it to `0` to opt out or higher when profiles are added from Rust. Valid range: 0–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. diff --git a/rmk/CHANGELOG.md b/rmk/CHANGELOG.md index ff4183794..2f95833d7 100644 --- a/rmk/CHANGELOG.md +++ b/rmk/CHANGELOG.md @@ -13,9 +13,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `bootmagic` config: hold a designated key during boot to drop into the chip bootloader. Works on unibody and on each half of a split independently. Particularly useful for split peripherals whose BOOTSEL button is physically inaccessible ([#457](https://github.com/HaoboGu/rmk/issues/457)). - Make `rmk::boot` module public so user code can call `boot::jump_to_bootloader()` directly - Add auto mouse layer behavior: automatically activate a configured layer when X/Y cursor motion from a pointing device is detected, and deactivate it after a `timeout` of inactivity ([#781](https://github.com/HaoboGu/rmk/issues/781)) with `deactivate_on_key` (with `extra_mouse_keys`) and `reset_timeout_on_key` options; entry capacity is auto-derived from `keyboard.toml`, overridable via `[rmk].auto_mouse_layer_max_num` +- Add unified Sticky Keys with modifier, layer, and Alt+Tab-style tap-key shapes, named profiles, and configurable release triggers ### Changed +- **BREAKING**: Replace the `one_shot` and `one_shot_modifiers` TOML tables and 5-positional `SK` form with `[behavior.sticky_key]`; `OSM` and `OSL` keymap aliases remain supported - **BREAKING**: `CompositeReportType` discriminants are renumbered (`Keyboard=1`, `Mouse=2`, `Media=3`, `System=4`): the BLE report map carries the keyboard report as id 1, and the mouse/media/system report ids shift to 2/3/4 on both BLE and USB. USB hosts re-read report ids on every enumeration so nothing changes for them; BLE hosts bonded to an older firmware must forget and re-pair the keyboard - **BREAKING**: `PollingController::INTERVAL` constant is now `PollingController::interval()` method, allowing dynamic interval configuration at runtime - **BREAKING**: PointingDevice and PointingProcessor replace Pmw3610Device and Pmw3610Processor. For the Pmw3610 the calls of ::new() for these stay the same, only the name changes. If using Rust to configure the keyboard change the calls, if using Toml nothing needs to be done. From b5e05a1a16ca6dcc5a7edb0e16f19d6a07aaa3de Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:51:33 -0500 Subject: [PATCH 111/119] fix sticky key timeout codegen path --- rmk-macro/src/codegen/behavior.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rmk-macro/src/codegen/behavior.rs b/rmk-macro/src/codegen/behavior.rs index b9a4b5520..a8aa6c98d 100644 --- a/rmk-macro/src/codegen/behavior.rs +++ b/rmk-macro/src/codegen/behavior.rs @@ -88,7 +88,7 @@ fn expand_sticky_key_profile( }; quote! { ::rmk::config::StickyKeyProfile { - timeout: ::embassy_time::Duration::from_millis(#timeout), + timeout: ::rmk::embassy_time::Duration::from_millis(#timeout), activate_on_keypress: #activate_on_keypress, max_repeat: #max_repeat, release_mode: #release_mode, From dae94f6315a15ff86067b5e75903ae54a6d66f53 Mon Sep 17 00:00:00 2001 From: ldsands <7889104+ldsands@users.noreply.github.com> Date: Sun, 2 Aug 2026 09:21:26 -0500 Subject: [PATCH 112/119] refactor(sticky-key): address PR review feedback --- docs/docs/main/docs/configuration/behavior.md | 2 + .../main/docs/development/rynk_protocol.md | 2 +- .../src/default_config/event_default.toml | 5 - rmk-config/src/lib.rs | 1 - rmk-config/src/resolved/behavior.rs | 69 +++- rmk-config/src/resolved/build_constants.rs | 51 --- rmk-macro/src/codegen/action_parser.rs | 27 +- rmk-macro/src/codegen/behavior.rs | 2 +- rmk-types/src/action/key_action.rs | 9 +- rmk-types/src/action/mod.rs | 56 --- rmk-types/src/lib.rs | 1 + rmk-types/src/protocol/rynk/payload/keymap.rs | 10 +- rmk-types/src/protocol/rynk/payload/system.rs | 5 +- .../protocol/rynk/snapshots/wire_frames.snap | 2 +- .../protocol/rynk/snapshots/wire_values.snap | 164 ++++---- rmk-types/src/protocol/rynk/tests.rs | 13 +- rmk-types/src/sticky_key.rs | 29 ++ rmk/src/config/behavior.rs | 24 +- rmk/src/event/state.rs | 77 ---- rmk/src/host/via/keycode_convert.rs | 57 ++- rmk/src/keyboard.rs | 263 ++++-------- rmk/src/keyboard/auto_mouse_layer.rs | 26 +- rmk/src/keyboard/oneshot.rs | 222 ---------- rmk/src/keyboard/sticky_key.rs | 387 ++++++++++++------ rmk/src/keymap.rs | 65 +-- rmk/src/layout_macro.rs | 41 +- rmk/tests/scenarios/README.md | 2 +- rmk/tests/scenarios/one_shot.toml | 15 +- rmk/tests/scenarios/rynk_errors.toml | 2 +- rmk/tests/scenarios/rynk_system.toml | 4 +- rmk/tests/scenarios/rynk_topics.toml | 6 +- rmk/tests/scenarios/sticky_key.toml | 39 +- 32 files changed, 683 insertions(+), 995 deletions(-) create mode 100644 rmk-types/src/sticky_key.rs delete mode 100644 rmk/src/keyboard/oneshot.rs diff --git a/docs/docs/main/docs/configuration/behavior.md b/docs/docs/main/docs/configuration/behavior.md index 202efaebb..206d4b2aa 100644 --- a/docs/docs/main/docs/configuration/behavior.md +++ b/docs/docs/main/docs/configuration/behavior.md @@ -61,6 +61,8 @@ Each field may be omitted. Named profiles inherit omitted fields from `[behavior 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. +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 In the `combo` sub-table, you can configure the keyboard's combo key functionality. Combo allows you to define a group of keys that, when pressed simultaneously, will trigger a specific output action. 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/default_config/event_default.toml b/rmk-config/src/default_config/event_default.toml index 577b5ee79..e6d054cfb 100644 --- a/rmk-config/src/default_config/event_default.toml +++ b/rmk-config/src/default_config/event_default.toml @@ -24,11 +24,6 @@ channel_size = 1 pubs = 2 subs = 1 -[event.layer_transition] -channel_size = 2 -pubs = 1 -subs = 1 - [event.wpm_update] channel_size = 1 pubs = 1 diff --git a/rmk-config/src/lib.rs b/rmk-config/src/lib.rs index a0f998fbd..1c399a4c1 100644 --- a/rmk-config/src/lib.rs +++ b/rmk-config/src/lib.rs @@ -497,7 +497,6 @@ define_event_config!( keyboard, // Keyboard state events layer_change, - layer_transition, wpm_update, led_indicator, sleep_state, diff --git a/rmk-config/src/resolved/behavior.rs b/rmk-config/src/resolved/behavior.rs index bfb2f1124..975ef8750 100644 --- a/rmk-config/src/resolved/behavior.rs +++ b/rmk-config/src/resolved/behavior.rs @@ -1,39 +1,74 @@ use std::collections::HashMap; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct StickyKeyReleaseMode(u8); +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(1 << 0); - pub const OTHER_KEY_RELEASE: Self = Self(1 << 1); - pub const LAYER_ENTER: Self = Self(1 << 2); - pub const LAYER_EXIT: Self = Self(1 << 3); - pub const DOUBLE_TAP: Self = Self(1 << 4); + 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.0 + (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 bits = 0; + let mut mode = Self::default(); for part in value.split('|').map(str::trim).filter(|part| !part.is_empty()) { - bits |= match part { - "other_key_press" => Self::OTHER_KEY_PRESS.0, - "other_key_release" => Self::OTHER_KEY_RELEASE.0, - "layer_enter" => Self::LAYER_ENTER.0, - "layer_exit" => Self::LAYER_EXIT.0, - "double_tap" => Self::DOUBLE_TAP.0, + 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 bits == 0 { + if mode == Self::default() { return Err("Sticky Key release_mode must contain at least one trigger".to_string()); } - Ok(Self(bits)) + Ok(mode) } } diff --git a/rmk-config/src/resolved/build_constants.rs b/rmk-config/src/resolved/build_constants.rs index 72046bb29..88975086b 100644 --- a/rmk-config/src/resolved/build_constants.rs +++ b/rmk-config/src/resolved/build_constants.rs @@ -103,7 +103,6 @@ impl crate::KeyboardTomlConfig { modifier, keyboard, layer_change, - layer_transition, wpm_update, led_indicator, sleep_state, @@ -192,25 +191,6 @@ impl crate::KeyboardTomlConfig { } } - let layer_transition = events - .iter() - .find(|event| event.name == "layer_transition") - .expect("layer_transition is a built-in event"); - if layer_transition.channel_size == 0 { - return Err("[event.layer_transition].channel_size must be at least 1".to_string()); - } - if layer_transition.subs == 0 { - return Err( - "[event.layer_transition].subs must be at least 1 because Keyboard subscribes to it".to_string(), - ); - } - if !auto_mouse_layers.is_empty() && layer_transition.pubs == 0 { - return Err( - "[event.layer_transition].pubs must be at least 1 when [[behavior.auto_mouse_layer]] is configured" - .to_string(), - ); - } - // Host capability fields are u8/u16 on the wire; check the values no deserializer bound // covers (morse_max_num and split_peripherals_num can also be auto-raised past 255). validate_u8_capability("morse_max_num", rmk.morse_max_num)?; @@ -399,37 +379,6 @@ mod tests { assert!(err.contains("at most 255 named profiles")); } - #[test] - fn layer_transition_requires_keyboard_resources() { - for (field, toml) in [ - ( - "channel_size", - "[event.layer_transition]\nchannel_size = 0\npubs = 1\nsubs = 1\n", - ), - ( - "subs", - "[event.layer_transition]\nchannel_size = 2\npubs = 1\nsubs = 0\n", - ), - ] { - let err = match parse(toml).build_constants(&[]) { - Ok(_) => panic!("expected layer-transition validation failure"), - Err(err) => err, - }; - assert!(err.contains("[event.layer_transition]")); - assert!(err.contains(field)); - } - } - - #[test] - fn auto_mouse_requires_layer_transition_publisher() { - let toml = "[event.layer_transition]\nchannel_size = 2\npubs = 0\nsubs = 1\n\n[[behavior.auto_mouse_layer]]\ntarget_layer = 1\n"; - let err = match parse(toml).build_constants(&[]) { - Ok(_) => panic!("expected layer-transition publisher validation failure"), - Err(err) => err, - }; - assert!(err.contains("[event.layer_transition].pubs")); - } - #[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 691079d9a..58be25c6a 100644 --- a/rmk-macro/src/codegen/action_parser.rs +++ b/rmk-macro/src/codegen/action_parser.rs @@ -258,21 +258,21 @@ fn parse_sticky_action( }; let action = action_args.join(", "); - let effect = match alias { + 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::StickyKeyEffect::Modifier(#modifiers) } + quote! { ::rmk::types::action::Action::Modifier(#modifiers) } } Some("layer") => { let layer = action.parse::().unwrap(); - quote! { ::rmk::types::action::StickyKeyEffect::Layer(#layer) } + quote! { ::rmk::types::action::Action::LayerOn(#layer) } } None if action.to_lowercase().starts_with("mo(") => { let layer = parse_layer(&action); - quote! { ::rmk::types::action::StickyKeyEffect::Layer(#layer) } + quote! { ::rmk::types::action::Action::LayerOn(#layer) } } None if action.contains('[') => { let start = action.find('[').unwrap(); @@ -297,7 +297,7 @@ fn parse_sticky_action( } else { parse_modifiers(&action[start + 1..end]) }; - quote! { ::rmk::types::action::StickyKeyEffect::TapKey { key: ::rmk::types::keycode::HidKeyCode::#key_ident, modifiers: #modifiers } } + quote! { ::rmk::types::action::Action::KeyWithModifier(::rmk::types::keycode::HidKeyCode::#key_ident, #modifiers) } } None => { if action.contains('(') { @@ -309,15 +309,12 @@ fn parse_sticky_action( if modifiers.is_empty() { panic!("\n❌ keyboard.toml: SK(modifier) is not valid"); } - quote! { ::rmk::types::action::StickyKeyEffect::Modifier(#modifiers) } + quote! { ::rmk::types::action::Action::Modifier(#modifiers) } } _ => unreachable!(), }; Some(quote! { - ::rmk::types::action::Action::StickyKey(::rmk::types::action::StickyKeyAction { - effect: #effect, - profile: #profile, - }) + ::rmk::types::action::KeyAction::Sticky(#action, #profile) }) } @@ -334,8 +331,10 @@ fn parse_action_with_profiles( ) -> TokenStream2 { let lower = key.to_lowercase(); - if let Some(action) = parse_sticky_action(key, sticky_profiles) { - return action; + 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(") { @@ -513,7 +512,7 @@ pub(crate) fn parse_key( let lower = key.to_lowercase(); if let Some(action) = parse_sticky_action(&key, sticky_profiles) { - return quote! { ::rmk::types::action::KeyAction::Single(#action) }; + return action; } if lower.starts_with("mt(") { @@ -750,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::StickyKey")); + 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 a8aa6c98d..4733fe6bc 100644 --- a/rmk-macro/src/codegen/behavior.rs +++ b/rmk-macro/src/codegen/behavior.rs @@ -82,7 +82,7 @@ fn expand_sticky_key_profile( let release_mode = match profile.release_mode.or(fallback.release_mode) { Some(mode) => { let bits = mode.into_bits(); - quote! { ::core::option::Option::Some(::rmk::config::StickyKeyReleaseMode::from_bits(#bits)) } + quote! { ::core::option::Option::Some(::rmk::types::sticky_key::StickyKeyReleaseMode::from_bits(#bits)) } } None => quote! { ::core::option::Option::None }, }; 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 4f31846d1..233054a35 100644 --- a/rmk-types/src/action/mod.rs +++ b/rmk-types/src/action/mod.rs @@ -29,34 +29,6 @@ use crate::modifier::ModifierCombination; #[cfg(feature = "steno")] use crate::steno::StenoKey; -/// Effect produced by a sticky-key action. -#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, MaxSize)] -#[cfg_attr(feature = "defmt", derive(defmt::Format))] -#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] -pub enum StickyKeyEffect { - /// Apply modifiers to the next key. - Modifier(ModifierCombination), - /// Activate a layer for the next key. - Layer(u8), - /// Tap a HID key while retaining modifiers between repetitions. - TapKey { - key: HidKeyCode, - modifiers: ModifierCombination, - }, -} - -/// Parameters for a sticky-key action. -#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, MaxSize)] -#[cfg_attr(feature = "defmt", derive(defmt::Format))] -#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] -#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))] -pub struct StickyKeyAction { - pub effect: StickyKeyEffect, - /// Profile-table index. `u8::MAX` selects the default sticky-key profile. - pub profile: u8, -} - /// A single basic action that a keyboard can execute. #[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, MaxSize)] #[cfg_attr(feature = "defmt", derive(defmt::Format))] @@ -89,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 @@ -117,26 +83,4 @@ pub enum Action { #[cfg(not(feature = "steno"))] #[doc(hidden)] ReservedSteno, - /// Configurable sticky modifier, layer, or tap-key behavior. - StickyKey(StickyKeyAction), -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn sticky_key_round_trips_with_a_stable_wire_slot() { - let action = Action::StickyKey(StickyKeyAction { - effect: StickyKeyEffect::TapKey { - key: HidKeyCode::Tab, - modifiers: ModifierCombination::LALT, - }, - profile: 7, - }); - let mut bytes = [0; 32]; - let encoded = postcard::to_slice(&action, &mut bytes).unwrap(); - assert_eq!(encoded[0], 22); - assert_eq!(postcard::from_bytes::(encoded).unwrap(), action); - } } 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 c572ef487..77e748e27 100644 --- a/rmk-types/src/protocol/rynk/payload/keymap.rs +++ b/rmk-types/src/protocol/rynk/payload/keymap.rs @@ -116,7 +116,7 @@ mod tests { use heapless::Vec; use super::super::*; - use crate::action::{Action, KeyAction, StickyKeyAction, StickyKeyEffect}; + use crate::action::{Action, KeyAction}; use crate::keycode::HidKeyCode; use crate::modifier::ModifierCombination; use crate::protocol::rynk::payload::bulk_capacity::MAX_BULK_KEYS; @@ -128,13 +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::StickyKey(StickyKeyAction { - effect: StickyKeyEffect::TapKey { - key: HidKeyCode::A, - modifiers: ModifierCombination::new(), - }, - profile: u8::MAX, - }); + 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: