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
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,40 @@ In QMK an `AlternativeRepeatKey` is supported. This functionality is not impleme
## Caps Word

RMK includes `CapsWordToggle`. It can be aliased with any of `caps_word` or `cword` in a keymap. Caps word capitalizes all characters until a breaking character such as space occurs.

## LatchTap

`LatchTap(modifier, key)` is a keymap action that **latches** a modifier for the lifetime of the current layer and **taps a key under it on each press**.
It is designed for Alt/Ctrl/Gui-Tab style window switching, where you want to hold a modifier across several taps of a key without holding the modifier key yourself.

Behavior:

1. **First press**: the modifier is latched (engaged and kept active) and `key` is sent together with it. For example `LatchTap(LAlt, Tab)` sends `Alt+Tab`.
2. **Release**: only `key` is released; the modifier stays latched. So after releasing you are left with `LAlt` still held.
3. **Subsequent presses**: `key` is tapped again while the modifier remains engaged (e.g. `Tab` cycles through windows, `Alt` stays down).
4. **Layer exit**: the latched modifier is released automatically when the layer it was engaged on is deactivated (for example when the momentary layer key `MO(n)` is released).

This differs from the related action:

- Unlike `LM(layer, modifier)`, the modifier is **not** bound to the layer-switch key. It is bound to this dedicated key, so you can place several independent `LatchTap` keys (with different modifiers/keys) on the **same** layer.

`LatchTap` cooperates with other modifiers: its latched modifier is combined into the same HID report as held modifier keys, one-shot modifiers, and `WM`/`SHIFTED` keys.
For example, if `LatchTap(LCtrl, Tab)` has latched `Ctrl` and you then activate `OSM(LShift)`, the next `LatchTap(LCtrl, Tab)` press reports `Ctrl+Shift+Tab`.

Syntax: `LatchTap(modifier, key)` — the modifier comes first, the key second (matching the modifier-first order).
The modifier accepts the same names as other actions (`LShift`, `LCtrl`, `LAlt`, `LGui`, `RShift`, `RCtrl`, `RAlt`, `RGui`), optionally combined with `|`.

Example layout — a layer with three independent latching window-switch keys:

```toml
[[layer]]
keys = """
LatchTap(LCtrl, Tab) LatchTap(LAlt, Tab) LatchTap(LGui, Tab)
"""
```

Typical usage: put `MO(n)` on a thumb key to enter the layer above, hold it, then tap `LatchTap(LAlt, Tab)` repeatedly to Alt-Tab through windows. Releasing `MO(n)` releases the latched `Alt`.

::: note Rust API / Vial
In a Rust keymap use the `latchtap!` macro, e.g. `latchtap!(ModifierCombination::LCTRL, Tab)`. `LatchTap` is not yet representable as a Vial keycode, so it can only be configured via `keyboard.toml` or the Rust API.
:::
17 changes: 10 additions & 7 deletions rmk-config/src/keymap.pest
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
WHITESPACE = _{ " " | "\t" | "\n" | "\r" }

// Optional comments
COMMENT = _{ "//" ~ (!"\n" ~ ANY)* }
COMMENT = _{ "//" ~ (!"\n" ~ ANY)* }

// --- Helper Rules ---

Expand All @@ -26,7 +26,7 @@ number = @{ ASCII_DIGIT+ }
layer_number = @{ number }
layer_name = @{ loose_identifier }

// The order is important here, as we want to match the number first
// The order is important here, as we want to match the number first
layer_reference = _{ layer_number | layer_name }

// Modifier Names
Expand All @@ -49,14 +49,11 @@ no_action = @{ ^"No" ~ !(ASCII_ALPHANUMERIC) } // Case-insensitive "No" not foll


// Rule 3: Transparent Key
transparent_action = @{ ("_")+ | (^"Trns" ~ !ASCII_ALPHANUMERIC) } // One or more underscores or "Trns" followed by non-alphanumeric
transparent_action = @{ ("_")+ | (^"Trns" ~ !ASCII_ALPHANUMERIC) } // One or more underscores or "Trns" followed by non-alphanumeric

// 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 ~ ")" }

Expand All @@ -72,6 +69,9 @@ lt_action = { ^"LT" ~ "(" ~ layer_reference ~ "," ~ nestable_action ~ ("," ~ pro
// Rule 4.5: OSL(n) - One-Shot Layer
osl_action = { ^"OSL" ~ "(" ~ layer_reference ~ ")" }

// Rule 4.6: OSM(modifier) - One-Shot Modifier
osm_action = { ^"OSM" ~ "(" ~ modifier_combination ~ ")" }

// Rule 4.7: TT(n) - Layer Activate or Tap Toggle
tt_action = { ^"TT" ~ "(" ~ layer_reference ~ ")" }

Expand Down Expand Up @@ -113,12 +113,15 @@ morse_action = { (^"TD" | ^"MORSE") ~ "(" ~ number ~ ")" }
// Rule 9: Macro(n) - Trigger Macro
trigger_macro_action = { ^"MACRO" ~ "(" ~ number ~ ")" }

// Rule 10: LatchTap(modifier, key) - Latch modifier for the layer, tap key
latchtap_action = { ^"LatchTap" ~ "(" ~ modifier_combination ~ "," ~ keycode_name ~ ")" }

// --- 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 | latchtap_action | layer_action | mt_action | th_action | shifted_action | morse_action | trigger_macro_action | no_action | transparent_action | simple_keycode
}

// The entire key map string: Start, zero or more key actions, End.
Expand Down
38 changes: 38 additions & 0 deletions rmk-config/src/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,44 @@ mod tests {
}
}

#[test]
fn test_latchtap_grammar() {
// LatchTap(modifier, key) is parsed as a single top-level action and
// forwarded verbatim (no layer references to resolve).
let aliases = HashMap::new();
let layer_names = HashMap::new();

let keymap = "LatchTap(LCtrl, Tab) LatchTap(LAlt, Tab) LatchTap(LGui, Tab)";
let result = KeyboardTomlConfig::keymap_parser(keymap, &aliases, &layer_names);

assert!(result.is_ok(), "{:?}", result);
assert_eq!(
result.unwrap(),
vec![
"LatchTap(LCtrl, Tab)",
"LatchTap(LAlt, Tab)",
"LatchTap(LGui, Tab)",
]
);

// The grammar recognizes it as `latchtap_action`, case-insensitively.
for input in ["LatchTap(LCtrl, Tab)", "latchtap(LGui, Escape)", "LATCHTAP(RAlt, Home)"] {
let parsed = ConfigParser::parse(Rule::key_map, input);
assert!(parsed.is_ok(), "Failed to parse: {}", input);
let mut found = None;
for pair in parsed.unwrap() {
if pair.as_rule() == Rule::key_map {
for inner in pair.into_inner() {
if inner.as_rule() == Rule::latchtap_action {
found = Some(inner.as_rule());
}
}
}
}
assert_eq!(found, Some(Rule::latchtap_action), "Input: {}", input);
}
}

#[test]
fn test_nested_actions_in_tap_hold_slots() {
let aliases = HashMap::new();
Expand Down
32 changes: 32 additions & 0 deletions rmk-macro/src/codegen/action_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,26 @@ fn parse_action(key: &str) -> TokenStream2 {
);
}
return quote! { ::rmk::types::action::Action::LayerOnWithModifier(#layer, #modifiers) };
} else if lower.starts_with("latchtap(") {
let keys = split_top_level(strip_call(key));
if keys.len() != 2 {
panic!(
"\n\u{274c} keyboard.toml: LatchTap(modifier, key) invalid, please check the documentation: https://rmk.rs/docs/features/configuration/layout.html"
);
}
let modifiers = parse_modifiers(&keys[0]);
if modifiers.is_empty() {
panic!(
"\n\u{274c} keyboard.toml: modifier in LatchTap(modifier, key) is not valid! Please check the documentation: https://rmk.rs/docs/features/configuration/layout.html"
);
}
let ident = get_key_with_alias(keys[1].clone());
return quote! {
::rmk::types::action::Action::LatchTap(
#modifiers,
::rmk::types::keycode::KeyCode::Hid(::rmk::types::keycode::HidKeyCode::#ident),
)
};
} else if lower.starts_with("mo(") {
let layer = parse_layer(key);
return quote! { ::rmk::types::action::Action::LayerOn(#layer) };
Expand Down Expand Up @@ -516,6 +536,18 @@ mod tests {
assert!(squash(&expand("OSM(LShift)")).contains("Action::OneShotModifier"));
}

#[test]
fn latchtap_parses_modifier_and_key() {
let out = squash(&expand("LatchTap(LCtrl, Tab)"));
assert!(out.contains("Action::LatchTap("));
assert!(out.contains("ModifierCombination::new_from"));
assert!(out.contains("HidKeyCode::Tab"));
// Modifier first, key second (matches the Action variant order).
let (before, after) = out.split_once("Action::LatchTap(").unwrap();
assert!(before.ends_with("::rmk::types::action::"));
assert!(after.find("ModifierCombination").unwrap() < after.find("HidKeyCode::Tab").unwrap());
}

#[test]
fn mt_accepts_nested_with_modifier_tap() {
let out = squash(&expand("MT(WM(P, RAlt), LShift)"));
Expand Down
17 changes: 17 additions & 0 deletions rmk-types/src/action/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,23 @@ pub enum Action {
Modifier(ModifierCombination),
/// Key stroke with modifier combination triggered.
KeyWithModifier(KeyCode, ModifierCombination),
/// Latch a modifier for the lifetime of the current layer and tap a key
/// under it on each press.
///
/// On the first press the modifier is latched (engaged and kept active) and
/// the key is sent together with it. On release the key is released but the
/// modifier stays latched. Subsequent presses send the key again while the
/// modifier remains engaged, so the key can be "cycled" (e.g. `Tab` for
/// Alt/Ctrl/Gui-Tab style window switching). The latched modifier is
/// released automatically when the layer it was engaged on is deactivated
/// (for example when the momentary layer key is released).
///
/// Unlike [`Action::OneShotModifier`] the modifier is not released on the
/// next key press; unlike [`Action::LayerOnWithModifier`] the modifier is
/// not bound to the layer-switch key but to this dedicated key, allowing
/// several independent `LatchTap` keys (different modifiers/keys) on the
/// same layer.
LatchTap(ModifierCombination, KeyCode),
/// Activate a layer
LayerOn(u8),
/// Activate a layer with modifier combination triggered.
Expand Down
57 changes: 56 additions & 1 deletion rmk/src/keyboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,12 @@ pub struct Keyboard<'a> {
/// Oneshot Modifier state
osm_state: OneShotState<ModifierCombination>,

/// LatchTap state: a modifier latched by a `LatchTap` action and the layer
/// it was engaged on. The modifier stays engaged until that layer is
/// deactivated (see [`Keyboard::cleanup_latch_tap`]).
latched_modifiers: ModifierCombination,
latched_layer: Option<u8>,

/// Caps Word state machine
caps_word: CapsWordState,

Expand Down Expand Up @@ -260,6 +266,8 @@ impl<'a> Keyboard<'a> {
last_press_time: Instant::now(),
osl_state: OneShotState::default(),
osm_state: OneShotState::default(),
latched_modifiers: ModifierCombination::default(),
latched_layer: None,
caps_word: CapsWordState::default(),
with_modifiers: ModifierCombination::default(),
macro_texting: false,
Expand Down Expand Up @@ -377,6 +385,10 @@ impl<'a> Keyboard<'a> {
} else {
self.process_key_action(key_action, event, false, event_time).await
}

// Release any latched LatchTap modifier whose layer was just deactivated
// (e.g. when the momentary layer key is released).
self.cleanup_latch_tap().await;
}

async fn process_key_action(
Expand Down Expand Up @@ -1310,6 +1322,7 @@ impl<'a> Keyboard<'a> {
self.update_osl(event);
}
Action::OneShotKey(_k) => warn!("One-shot key is not supported: {:?}", action),
Action::LatchTap(modifiers, key) => self.process_action_latch_tap(modifiers, key, event).await,
Action::Light(_light_action) => warn!("Light controll is not supported"),
Action::KeyboardControl(c) => self.process_action_keyboard_control(c, event).await,
Action::Special(special_key) => self.process_action_special(special_key, event).await,
Expand Down Expand Up @@ -1362,7 +1375,7 @@ impl<'a> Keyboard<'a> {
/// - 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
let mut result = self.held_modifiers;
let mut result = self.held_modifiers | self.latched_modifiers;

// OneShotState::Held keeps the temporary modifiers active until the key is released
if pressed {
Expand Down Expand Up @@ -1588,6 +1601,48 @@ impl<'a> Keyboard<'a> {
}
}

/// Process a `LatchTap` action: latch a modifier for the lifetime of the
/// current layer and tap `key` under it on each press.
///
/// On press the modifier is engaged (or extended) and the key is sent
/// together with it. On release the key is released while the modifier
/// stays latched, so repeated presses cycle the key with the modifier held.
/// The latched modifier is released by [`Keyboard::cleanup_latch_tap`] when
/// the layer it was engaged on is deactivated.
///
/// The latched modifier is folded into the resolved modifiers, so it
/// combines naturally with other modifiers (held keys, one-shot modifiers,
/// `KeyWithModifier`, ...): a `LatchTap(LCtrl, Tab)` tapped while an OSM
/// `LShift` is active reports `LCtrl+LShift+Tab`.
async fn process_action_latch_tap(&mut self, modifiers: ModifierCombination, key: KeyCode, event: KeyboardEvent) {
if event.pressed {
// Engage (or extend) the latched modifier, and remember the layer
// it belongs to so cleanup releases it when that layer goes away.
self.latched_modifiers |= modifiers;
self.latched_layer = Some(self.keymap.get_activated_layer());
}
// Sending the key through the normal path means the report already
// includes the latched modifier (plus any OSM/held modifiers). On
// release only the key is lifted; the latch is intentionally kept.
self.process_action_key(key, event).await;
}

/// Release any latched `LatchTap` modifier whose layer is no longer active.
///
/// Called after every processed key event. When the layer a `LatchTap` was
/// engaged on is deactivated (e.g. the momentary layer key is released),
/// the latched modifier is dropped from the report so the host sees it as
/// released.
async fn cleanup_latch_tap(&mut self) {
if let Some(layer) = self.latched_layer {
if !self.keymap.is_layer_active(layer) {
self.latched_modifiers = ModifierCombination::default();
self.latched_layer = None;
self.send_keyboard_report_with_resolved_modifiers(false).await;
}
}
}

/// Process consumer control action. Consumer control keys are keys in hid consumer page, such as media keys.
async fn process_action_consumer_control(&mut self, key: ConsumerKey, event: KeyboardEvent) {
self.media_report.usage_id = if event.pressed { key as u16 } else { 0 };
Expand Down
26 changes: 26 additions & 0 deletions rmk/src/layout_macro.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,32 @@ macro_rules! wm {
/// a!(No) // KeyAction::No - empty action
/// a!(Transparent) // KeyAction::Transparent - pass through to next layer
/// ```
/// Create a `LatchTap` action: latch a modifier for the lifetime of the
/// current layer and tap a key under it on each press.
///
/// This is useful for Alt/Ctrl/Gui-Tab style window switching: tap the key to
/// send `modifier+key`, release it (the modifier stays latched), tap again to
/// cycle `key` while the modifier is held, and the modifier is released when
/// the layer is deactivated.
///
/// # Parameters
/// - `$m`: A `ModifierCombination` expression
/// - `$k`: The HID keycode identifier to tap
///
/// # Example
/// ```ignore
/// latchtap!(ModifierCombination::LCTRL, Tab) // Ctrl+Tab, latched until layer exit
/// ```
#[macro_export]
macro_rules! latchtap {
($m: expr, $k: ident) => {
$crate::types::action::KeyAction::Single($crate::types::action::Action::LatchTap(
$m,
$crate::types::keycode::KeyCode::Hid($crate::types::keycode::HidKeyCode::$k),
))
};
}

#[macro_export]
macro_rules! a {
($a: ident) => {
Expand Down
Loading
Loading