Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/docs/main/docs/configuration/appendix.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ one_shot = {
# One Shot Modifiers configuration
one_shot_modifiers = {
activate_on_keypress = false,
tap_on_timeout = false,
tap_on_double_press = false,
retap_cancel = false,
}

[behavior.morse]
Expand Down
25 changes: 24 additions & 1 deletion docs/docs/main/docs/configuration/behavior.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,28 @@ By default, one-shot modifiers do not activate on keypress and will be sent only
You can change this behavior by setting `activate_on_keypress` to `true`.
This behavior is also known as One-Shot Sticky Modifiers (OSSM).

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

When you press the same OSM key again while one-shot is active, the behavior depends on these options:

- `tap_on_double_press` (default: `false`) — send a bare modifier tap to the host and consume the one-shot state. Useful for intentionally firing the modifier by itself (e.g., tapping LGui twice to open the Start menu).
- `retap_cancel` (default: `false`) — cancel the one-shot silently without sending anything.

If neither is enabled, re-pressing the same OSM key has no effect (the one-shot remains active). If both are enabled, `retap_cancel` takes priority.

Pressing a *different* OSM key while one-shot is active will stack the modifiers (e.g., OSM-Shift then OSM-Ctrl applies both to the next key).

### Timeout behavior

- `tap_on_timeout` (default: `false`) — when the one-shot timeout expires with no follow-up key, send a bare modifier tap to the host instead of silently cancelling. Useful for triggering OS actions tied to a lone modifier press (e.g., tapping LGui to open the Start menu).

Default values:
```toml
[behavior.one_shot_modifiers]
activate_on_keypress = false
tap_on_timeout = false
tap_on_double_press = false
retap_cancel = false
```

OSSM example:
Expand All @@ -64,6 +80,13 @@ OSSM example:
activate_on_keypress = true
```

Example with re-press and timeout behaviors:
```toml
[behavior.one_shot_modifiers]
tap_on_timeout = true
tap_on_double_press = 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.
Expand Down
3 changes: 3 additions & 0 deletions rmk-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,9 @@ pub(crate) struct OneShotConfig {
#[serde(deny_unknown_fields)]
pub struct OneShotModifiersConfig {
pub activate_on_keypress: Option<bool>,
pub tap_on_timeout: Option<bool>,
pub tap_on_double_press: Option<bool>,
pub retap_cancel: Option<bool>,
}

/// Configurations for combos
Expand Down
6 changes: 6 additions & 0 deletions rmk-config/src/resolved/behavior.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ pub struct Behavior {

pub struct OneShot {
pub activate_on_keypress: Option<bool>,
pub tap_on_timeout: Option<bool>,
pub tap_on_double_press: Option<bool>,
pub retap_cancel: Option<bool>,
}

pub struct Combos {
Expand Down Expand Up @@ -103,6 +106,9 @@ impl crate::KeyboardTomlConfig {

let one_shot_modifiers = toml_behavior.one_shot_modifiers.map(|o| OneShot {
activate_on_keypress: o.activate_on_keypress,
tap_on_timeout: o.tap_on_timeout,
tap_on_double_press: o.tap_on_double_press,
retap_cancel: o.retap_cancel,
});

let combos = toml_behavior.combo.map(|c| Combos {
Expand Down
18 changes: 18 additions & 0 deletions rmk-macro/src/codegen/behavior.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,27 @@ fn expand_one_shot_modifiers(one_shot_modifiers: &Option<OneShot>) -> proc_macro
None => quote! {},
};

let tap_on_timeout = match one_shot_modifier.tap_on_timeout {
Some(value) => quote! { tap_on_timeout: #value, },
None => quote! {},
};

let tap_on_double_press = match one_shot_modifier.tap_on_double_press {
Some(value) => quote! { tap_on_double_press: #value, },
None => quote! {},
};

let retap_cancel = match one_shot_modifier.retap_cancel {
Some(value) => quote! { retap_cancel: #value, },
None => quote! {},
};

quote! {
::rmk::config::OneShotModifiersConfig {
#activate_on_keypress
#tap_on_timeout
#tap_on_double_press
#retap_cancel
..Default::default()
}
}
Expand Down
6 changes: 6 additions & 0 deletions rmk/src/config/behavior.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ impl Default for OneShotConfig {
pub struct OneShotModifiersConfig {
/// Should modifiers be active from keypress (sticky modifiers)
pub activate_on_keypress: bool,
/// When timeout expires with no follow-up key, send a bare tap of the modifier
pub tap_on_timeout: bool,
/// When OSM key is pressed again while one-shot is active, send a bare tap of the modifier
pub tap_on_double_press: bool,
/// When OSM key is pressed again while one-shot is active, cancel silently
pub retap_cancel: bool,
}

/// Config for combo behavior
Expand Down
7 changes: 7 additions & 0 deletions rmk/src/keyboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1480,6 +1480,13 @@ impl<'a> Keyboard<'a> {

/// Process mouse key action with acceleration support.
async fn process_action_mouse(&mut self, key: HidKeyCode, event: KeyboardEvent) {
// Mouse reports don't carry keyboard modifiers. When an OSM modifier
// is active and a mouse button is pressed, send a keyboard report with
// the modifier first so the host applies it to the click (Ctrl+Click).
if event.pressed && self.osm_state.value().is_some() {
self.send_keyboard_report_with_resolved_modifiers(true).await;
}

let action = {
let config = self.keymap.mouse_key_config();
self.mouse.process(key, event.pressed, &config)
Expand Down
90 changes: 57 additions & 33 deletions rmk/src/keyboard/oneshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,41 +31,46 @@ impl<T> OneShotState<T> {

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;
let osm_config = self.keymap.one_shot_modifiers_config();
let activate_on_keypress = osm_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
// Check for re-press of same OSM key (modifier bits overlap with active one-shot)
if let Some(&active_mods) = self.osm_state.value() {
let is_repress = active_mods & new_modifiers == new_modifiers;
if is_repress {
if osm_config.retap_cancel {
// Cancel one-shot silently
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)
self.osm_state = OneShotState::None;
if activate_on_keypress {
self.send_keyboard_report_with_resolved_modifiers(false).await;
}
} else {
OneShotState::Single(cur_modifiers | new_modifiers)
return;
} else if osm_config.tap_on_double_press {
// Send bare modifier tap and consume one-shot
self.unprocessed_events.retain(|e| e.pos != event.pos);
self.send_keyboard_report_with_resolved_modifiers(true).await;
self.osm_state = OneShotState::None;
self.send_keyboard_report_with_resolved_modifiers(false).await;
return;
}
}
}

// 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) => 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 {
if activate_on_keypress {
self.send_keyboard_report_with_resolved_modifiers(true).await;
}
} else {
Expand All @@ -75,13 +80,29 @@ impl<'a> Keyboard<'a> {
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;
// Timeout fired. Guard against the select race where
// the timer is polled first and wins even though a key
// event arrived at the subscriber at the same instant.
if let Some(e) = self.keyboard_event_subscriber.try_next_message_pure() {
// A key event was pending — one-shot is consumed, not timed out
if self.unprocessed_events.push(e).is_err() {
warn!("Unprocessed event queue is full, dropping event");
}
} else {
// Genuinely timed out with no pending key event
self.update_osl(event);
if osm_config.tap_on_timeout {
// Send bare modifier tap before clearing state
self.send_keyboard_report_with_resolved_modifiers(true).await;
self.osm_state = OneShotState::None;
self.send_keyboard_report_with_resolved_modifiers(false).await;
} else {
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) => {
Expand Down Expand Up @@ -160,13 +181,16 @@ impl<'a> Keyboard<'a> {
}
}

pub(crate) fn update_osm(&mut self, event: KeyboardEvent) {
pub(crate) fn update_osm(&mut self, _event: KeyboardEvent) {
match self.osm_state {
OneShotState::Initial(m) => self.osm_state = OneShotState::Held(m),
OneShotState::Single(_) => {
if !event.pressed {
self.osm_state = OneShotState::None;
}
// Once any key is pressed or released after an OSM tap,
// the one-shot is consumed. On press, the modifier was already
// included in the HID report (resolve_modifiers runs before
// update_osm), so clearing here is safe and prevents the
// state from lingering as Single between key press and release.
self.osm_state = OneShotState::None;
}
_ => (),
}
Expand Down
122 changes: 122 additions & 0 deletions rmk/tests/keyboard_one_shot_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -549,5 +549,127 @@ mod one_shot_test {
]
};
}

/// OSM tap_on_timeout: when timeout expires, send bare modifier tap
#[test]
fn test_osm_tap_on_timeout() {
key_sequence_test! {
keyboard: create_test_keyboard_with_behavior_config(
BehaviorConfig {
one_shot: OneShotConfig {
timeout: Duration::from_millis(100),
..OneShotConfig::default()
},
one_shot_modifiers: OneShotModifiersConfig {
tap_on_timeout: true,
..OneShotModifiersConfig::default()
},
..BehaviorConfig::default()
}
),
sequence: [
[0, 0, true, 10], // Press OSM LShift
[0, 0, false, 10], // Release OSM LShift
[0, 2, true, 150], // Press A after timeout (delay > 100ms)
[0, 2, false, 10], // Release A
],
expected_reports: [
[KC_LSHIFT, [0, 0, 0, 0, 0, 0]], // Bare modifier press (tap_on_timeout)
[0, [0, 0, 0, 0, 0, 0]], // Modifier released
[0, [kc_to_u8!(A), 0, 0, 0, 0, 0]], // A without modifier
[0, [0, 0, 0, 0, 0, 0]], // All released
]
};
}

/// OSM tap_on_double_press: re-pressing same OSM sends bare modifier tap
#[test]
fn test_osm_tap_on_double_press() {
key_sequence_test! {
keyboard: create_test_keyboard_with_behavior_config(
BehaviorConfig {
one_shot: OneShotConfig {
timeout: Duration::from_millis(100),
..OneShotConfig::default()
},
one_shot_modifiers: OneShotModifiersConfig {
tap_on_double_press: true,
..OneShotModifiersConfig::default()
},
..BehaviorConfig::default()
}
),
sequence: [
[0, 0, true, 10], // Press OSM LShift
[0, 0, false, 10], // Release OSM LShift (Single state)
[0, 0, true, 50], // Press OSM LShift again (before 100ms timeout)
[0, 0, false, 10], // Release (no-op, state cleared)
],
expected_reports: [
[KC_LSHIFT, [0, 0, 0, 0, 0, 0]], // Bare modifier press (double tap)
[0, [0, 0, 0, 0, 0, 0]], // Modifier released
]
};
}

/// OSM retap_cancel: re-pressing same OSM cancels silently
#[test]
fn test_osm_retap_cancel() {
key_sequence_test! {
keyboard: create_test_keyboard_with_behavior_config(
BehaviorConfig {
one_shot: OneShotConfig {
timeout: Duration::from_millis(100),
..OneShotConfig::default()
},
one_shot_modifiers: OneShotModifiersConfig {
retap_cancel: true,
..OneShotModifiersConfig::default()
},
..BehaviorConfig::default()
}
),
sequence: [
[0, 0, true, 10], // Press OSM LShift
[0, 0, false, 10], // Release OSM LShift (Single state)
[0, 0, true, 50], // Press OSM LShift again (cancels)
[0, 0, false, 10], // Release (no-op)
[0, 2, true, 10], // Press A (no modifier)
[0, 2, false, 10], // Release A
],
expected_reports: [
[0, [kc_to_u8!(A), 0, 0, 0, 0, 0]], // A without modifier (OSM was cancelled)
[0, [0, 0, 0, 0, 0, 0]], // All released
]
};
}

/// tap_on_double_press with different OSM still stacks modifiers
#[test]
fn test_osm_double_press_different_modifier_still_stacks() {
key_sequence_test! {
keyboard: create_test_keyboard_with_behavior_config(
BehaviorConfig {
one_shot_modifiers: OneShotModifiersConfig {
tap_on_double_press: true,
..OneShotModifiersConfig::default()
},
..BehaviorConfig::default()
}
),
sequence: [
[0, 0, true, 10], // Press OSM LShift
[0, 0, false, 10], // Release OSM LShift (Single state)
[0, 4, true, 50], // Press OSM LCtrl (different modifier, should stack)
[0, 4, false, 10], // Release OSM LCtrl
[0, 2, true, 10], // Press A
[0, 2, false, 10], // Release A
],
expected_reports: [
[KC_LSHIFT | KC_LCTRL, [kc_to_u8!(A), 0, 0, 0, 0, 0]], // A with both modifiers
[0, [0, 0, 0, 0, 0, 0]], // All released
]
};
}
}
}