Skip to content

Commit 1a5b4a6

Browse files
committed
feat(osm): add configurable quick_release option for one-shot modifiers
Add `quick_release` boolean to `OneShotModifiersConfig` (default false, preserving existing chain-mode behavior). When enabled, OSM releases on the next key press instead of release, equivalent to ZMK's `&skq`. Key changes: - oneshot.rs: update_osm checks quick_release to decide release timing - keyboard.rs: send follow-up report after OSM clear in quick-release mode to stop host from repeating the modified key - Fix combo interaction: reset_sub_combos replaces reset_combo to avoid premature OSM clearing from buffered combo constituent keys - Config pipeline: quick_release field threaded tesolved config, and codegen - Tests: cover both quick-release and chain-mode behaviors, including combo interactions Configuration example (keyboard.toml): [behavior.one_shot_modifiers] quick_release = true
1 parent 34b0b9e commit 1a5b4a6

9 files changed

Lines changed: 149 additions & 17 deletions

File tree

docs/docs/main/docs/configuration/behavior.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,16 @@ This behavior is also known as One-Shot Sticky Modifiers (OSSM).
5252

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

55+
The `quick_release` option controls when the one-shot modifier is released:
56+
57+
- `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.
58+
- `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.
59+
5560
Default values:
5661
```toml
5762
[behavior.one_shot_modifiers]
5863
activate_on_keypress = false
64+
quick_release = false
5965
```
6066

6167
OSSM example:
@@ -64,6 +70,12 @@ OSSM example:
6470
activate_on_keypress = true
6571
```
6672

73+
Quick-release example:
74+
```toml
75+
[behavior.one_shot_modifiers]
76+
quick_release = true
77+
```
78+
6779
## Combo
6880

6981
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.

rmk-config/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,7 @@ pub(crate) struct OneShotConfig {
619619
#[serde(deny_unknown_fields)]
620620
pub struct OneShotModifiersConfig {
621621
pub activate_on_keypress: Option<bool>,
622+
pub quick_release: Option<bool>,
622623
}
623624

624625
/// Configurations for combos

rmk-config/src/resolved/behavior.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ pub struct Behavior {
1313

1414
pub struct OneShot {
1515
pub activate_on_keypress: Option<bool>,
16+
pub quick_release: Option<bool>,
1617
}
1718

1819
pub struct Combos {
@@ -104,6 +105,7 @@ impl crate::KeyboardTomlConfig {
104105

105106
let one_shot_modifiers = toml_behavior.one_shot_modifiers.map(|o| OneShot {
106107
activate_on_keypress: o.activate_on_keypress,
108+
quick_release: o.quick_release,
107109
});
108110

109111
let combos = toml_behavior.combo.map(|c| Combos {

rmk-macro/src/codegen/behavior.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,15 @@ fn expand_one_shot_modifiers(one_shot_modifiers: &Option<OneShot>) -> proc_macro
4747
Some(value) => quote! { activate_on_keypress: #value, },
4848
None => quote! {},
4949
};
50+
let quick_release = match one_shot_modifier.quick_release {
51+
Some(value) => quote! { quick_release: #value, },
52+
None => quote! {},
53+
};
5054

5155
quote! {
5256
::rmk::config::OneShotModifiersConfig {
5357
#activate_on_keypress
58+
#quick_release
5459
..Default::default()
5560
}
5661
}

rmk/src/config/behavior.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ impl Default for OneShotConfig {
7777
pub struct OneShotModifiersConfig {
7878
/// Should modifiers be active from keypress (sticky modifiers)
7979
pub activate_on_keypress: bool,
80+
/// If true, OSM releases on next key press (ZMK skq); if false, on next key release (ZMK skn)
81+
pub quick_release: bool,
8082
}
8183

8284
/// Config for combo behavior

rmk/src/keyboard.rs

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1004,21 +1004,21 @@ impl<'a> Keyboard<'a> {
10041004
self.process_key_action(&action, new_event, true, Instant::now()).await;
10051005
debug!("[Combo] {:?} triggered", action);
10061006
embassy_time::Timer::after_millis(20).await;
1007-
// Reset other combos' state
1008-
self.reset_combo(key_action);
1007+
// Reset non-triggered sub-combos of the combo that just won.
1008+
self.reset_sub_combos(&combo_actions);
10091009
}
10101010
}
10111011

1012-
// Reset combos that contain a key_action but not triggered yet
1013-
fn reset_combo(&mut self, key_action: &KeyAction) {
1014-
// Reset other sub-combo states
1012+
/// Reset all non-triggered combos whose keys are a subset of the triggered combo's keys.
1013+
/// Prevents sub-combos (e.g. E+T when E+R+T triggered) from firing spuriously,
1014+
/// while preserving state bits for overlapping combos that share some but not all keys.
1015+
fn reset_sub_combos(&mut self, triggered_actions: &[KeyAction]) {
10151016
self.keymap.with_combos_mut(|combos| {
1016-
combos.iter_mut().filter_map(|c| c.as_mut()).for_each(|c| {
1017-
if c.is_all_pressed() && !c.is_triggered() && c.config.contains(key_action) {
1018-
info!("Resetting combo: {:?}", c,);
1019-
c.reset();
1017+
for combo in combos.iter_mut().filter_map(|c| c.as_mut()) {
1018+
if !combo.is_triggered() && combo.config.actions.iter().all(|a| triggered_actions.contains(a)) {
1019+
combo.reset();
10201020
}
1021-
});
1021+
}
10221022
});
10231023
}
10241024

@@ -1100,22 +1100,22 @@ impl<'a> Keyboard<'a> {
11001100
));
11011101

11021102
// Only one combo is updated, and triggered
1103-
let next_action = self.keymap.with_combos_mut(|combos| {
1103+
let triggered_combo = self.keymap.with_combos_mut(|combos| {
11041104
combos.iter_mut().filter_map(|c| c.as_mut()).find_map(|c| {
11051105
if c.is_all_pressed() && !c.is_triggered() && c.size() == max_size {
1106-
Some(c.trigger())
1106+
Some((c.trigger(), c.config.actions.clone()))
11071107
} else {
11081108
None
11091109
}
11101110
})
11111111
});
11121112

1113-
if let Some(next_action) = next_action {
1113+
if let Some((next_action, combo_actions)) = triggered_combo {
11141114
debug!("[Combo] {:?} triggered", next_action);
11151115
self.held_buffer
11161116
.keys
11171117
.retain(|item| item.state != KeyState::WaitingCombo);
1118-
self.reset_combo(key_action);
1118+
self.reset_sub_combos(&combo_actions);
11191119
return (Some(next_action), true);
11201120
}
11211121
(None, false)
@@ -1533,7 +1533,12 @@ impl<'a> Keyboard<'a> {
15331533
_ => warn!("KeyCode variant not supported: {:?}", key),
15341534
}
15351535

1536+
let quick_release = self.keymap.one_shot_modifiers_config().quick_release;
1537+
let osm_active_before = self.osm_state.value().is_some();
15361538
self.update_osm(event);
1539+
if quick_release && osm_active_before && self.osm_state.value().is_none() && event.pressed {
1540+
self.send_keyboard_report_with_resolved_modifiers(true).await;
1541+
}
15371542
self.update_osl(event);
15381543
}
15391544

rmk/src/keyboard/oneshot.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,9 +161,13 @@ impl<'a> Keyboard<'a> {
161161
}
162162

163163
pub(crate) fn update_osm(&mut self, event: KeyboardEvent) {
164+
let quick_release = self.keymap.one_shot_modifiers_config().quick_release;
164165
match self.osm_state {
165166
OneShotState::Initial(m) => self.osm_state = OneShotState::Held(m),
166-
OneShotState::Single(_) if !event.pressed => {
167+
OneShotState::Single(_) if quick_release && event.pressed => {
168+
self.osm_state = OneShotState::None;
169+
}
170+
OneShotState::Single(_) if !quick_release && !event.pressed => {
167171
self.osm_state = OneShotState::None;
168172
}
169173
_ => (),

rmk/tests/keyboard_combo_test.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
pub mod common;
22

33
use embassy_time::Duration;
4-
use rmk::config::{BehaviorConfig, CombosConfig, MorsesConfig, OneShotConfig};
4+
use rmk::config::{BehaviorConfig, CombosConfig, MorsesConfig, OneShotConfig, OneShotModifiersConfig};
55
use rmk::keyboard::combo::{Combo, ComboConfig};
66
use rmk::types::keycode::HidKeyCode;
77
use rmk::types::modifier::ModifierCombination;
@@ -154,6 +154,10 @@ fn test_combo_with_one_shot_modifier() {
154154
timeout: Duration::from_millis(300),
155155
..Default::default()
156156
},
157+
one_shot_modifiers: OneShotModifiersConfig {
158+
quick_release: true,
159+
..Default::default()
160+
},
157161
..Default::default()
158162
}),
159163
sequence: [
@@ -166,6 +170,7 @@ fn test_combo_with_one_shot_modifier() {
166170
],
167171
expected_reports: [
168172
[KC_LSHIFT, [HidKeyCode::E as u8, 0, 0, 0, 0, 0]],
173+
[0, [HidKeyCode::E as u8, 0, 0, 0, 0, 0]], // Quick-release: modifier removed
169174
[0, [0; 6]],
170175
]
171176
}
@@ -240,6 +245,10 @@ fn test_fully_overlapped_combo() {
240245
key_sequence_test! {
241246
keyboard: create_test_keyboard_with_config(BehaviorConfig {
242247
combo: get_combos_config(),
248+
one_shot_modifiers: OneShotModifiersConfig {
249+
quick_release: true,
250+
..Default::default()
251+
},
243252
..Default::default()
244253
}),
245254
sequence: [
@@ -269,6 +278,7 @@ fn test_fully_overlapped_combo() {
269278
[0, [HidKeyCode::Space as u8, 0, 0, 0, 0, 0]],
270279
[0, [0; 6]],
271280
[KC_LSHIFT, [HidKeyCode::A as u8, 0, 0, 0, 0, 0]],
281+
[0, [HidKeyCode::A as u8, 0, 0, 0, 0, 0]], // Quick-release: modifier removed
272282
[0, [0; 6]],
273283
[0, [HidKeyCode::Space as u8, 0, 0, 0, 0, 0]],
274284
[0, [0; 6]],
@@ -281,6 +291,10 @@ fn test_overlapped_combo() {
281291
key_sequence_test! {
282292
keyboard: create_test_keyboard_with_config(BehaviorConfig {
283293
combo: get_combos_config(),
294+
one_shot_modifiers: OneShotModifiersConfig {
295+
quick_release: true,
296+
..Default::default()
297+
},
284298
..Default::default()
285299
}),
286300
sequence: [
@@ -295,6 +309,7 @@ fn test_overlapped_combo() {
295309
],
296310
expected_reports: [
297311
[KC_LSHIFT, [HidKeyCode::A as u8, 0, 0, 0, 0, 0]],
312+
[0, [HidKeyCode::A as u8, 0, 0, 0, 0, 0]], // Quick-release: modifier removed
298313
[0, [0; 6]],
299314
]
300315
}

0 commit comments

Comments
 (0)