Skip to content
Merged
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
4 changes: 4 additions & 0 deletions cfg_samples/kanata.kbd
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,10 @@ If you need help, please feel welcome to ask in the GitHub discussions.
;; This is useful for home row mods where fast typing should not trigger modifiers.
utk (tap-hold-tap-keys 200 200 u @msc (a o e))

;; tap-hold-order resolves by release order instead of timeout.
;; tap: a hold: lctl buffer: 50ms (fast typing grace period)
aor (tap-hold-order 200 50 a lctl)

;; tap for capslk, hold for lctl
cap (tap-hold 200 200 caps lctl)

Expand Down
13 changes: 13 additions & 0 deletions docs/config.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -2018,6 +2018,7 @@ results in `$tap-action` activating.
(tap-hold-except-keys $tap-repress-timeout $hold-timeout $tap-action $hold-action $tap-keys)
(tap-hold-tap-keys $tap-repress-timeout $hold-timeout $tap-action $hold-action $tap-keys)
(tap-hold-opposite-hand $timeout $tap-action $hold-action [options...])
(tap-hold-order $tap-repress-timeout $buffer-ms $tap-action $hold-action [options...])
----

[cols="1,2"]
Expand Down Expand Up @@ -2073,6 +2074,18 @@ This is useful for home row mods where fast typing should not trigger modifiers.
| Resolves to `$hold-action` when a key from the opposite hand (per `defhands`) is pressed.
Requires a `defhands` directive. Supports list-form options for fine-grained control.
See <<defhands and tap-hold-opposite-hand>> below.

| `tap-hold-order`
| Resolves purely by key release order, with no timeout.
If the tap-hold key is released before the other key, activates `$tap-action`;
if the other key is released first (while the tap-hold key is still held), activates `$hold-action`.
`$buffer-ms` is a grace period after the tap-hold key is pressed during which
release-order logic is ignored so that fast typing resolves as tap.
Optionally, `(require-prior-idle <ms>)` can short-circuit to tap when a different key
was pressed within that many milliseconds before the tap-hold key;
`require-prior-idle` checks prior typing activity before release-order logic begins,
whereas `buffer-ms` creates an unconditional tap-only window after the key is pressed.
This option is available on all tap-hold variants; see <<tap-hold-require-prior-idle>>.
|===

All `tap-hold` variants support an optional trailing `(require-prior-idle <ms>)` option
Expand Down
9 changes: 9 additions & 0 deletions keyberon/src/action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ pub enum HoldTapConfig<'a> {
/// not used in the flow of typing, like escape for example. If
/// you are annoyed by accidental tap, you can try this behavior.
HoldOnOtherKeyPress,
/// Resolves based on release order after both keys are down.
/// If the other key releases first (modifier still held) → Hold.
/// If the modifier releases first (other key still held) → Tap.
/// The buffer field specifies a grace period in ticks (ms) after the
/// initial press during which release-order logic is ignored and fast
/// typing will resolve as Tap.
Order { buffer: u16 },
/// If there is a press and release of another key, the hold
/// action is activated.
///
Expand Down Expand Up @@ -100,6 +107,7 @@ impl Debug for HoldTapConfig<'_> {
match self {
HoldTapConfig::Default => f.write_str("Default"),
HoldTapConfig::HoldOnOtherKeyPress => f.write_str("HoldOnOtherKeyPress"),
HoldTapConfig::Order { .. } => f.write_str("Order"),
HoldTapConfig::PermissiveHold => f.write_str("PermissiveHold"),
HoldTapConfig::Custom(_) => f.write_str("Custom"),
}
Expand All @@ -113,6 +121,7 @@ impl PartialEq for HoldTapConfig<'_> {
(HoldTapConfig::Default, HoldTapConfig::Default)
| (HoldTapConfig::HoldOnOtherKeyPress, HoldTapConfig::HoldOnOtherKeyPress)
| (HoldTapConfig::PermissiveHold, HoldTapConfig::PermissiveHold) => true,
(HoldTapConfig::Order { .. }, HoldTapConfig::Order { .. }) => true,
_ => false,
}
}
Expand Down
225 changes: 225 additions & 0 deletions keyberon/src/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,30 @@ impl<'a, T: std::fmt::Debug> WaitingState<'a, T> {
return Some(WaitingAction::Hold);
}
}
HoldTapConfig::Order { buffer, .. } => {
// Like PermissiveHold: if another key was pressed AND released
// (while modifier is still held), resolve as Hold.
// If modifier is released first, the fallthrough below handles Tap.
//
// Buffer: key presses that occurred within `buffer` ticks of the
// hold-tap key press are ignored by release-order logic, allowing
// fast typing to resolve as Tap regardless of release order.
let mut queued = queued.iter();
while let Some(q) = queued.next() {
if q.event.is_press() {
// Elapsed ticks since this key entered the queue, compared against buffer window.
let press_tick = self.ticks.saturating_sub(q.since);
if press_tick < buffer {
continue;
}
let (i, j) = q.event.coord();
let target = Event::Release(i, j);
if queued.clone().any(|q| q.event == target) {
return Some(WaitingAction::Hold);
}
}
}
}
HoldTapConfig::PermissiveHold => {
let mut queued = queued.iter();
while let Some(q) = queued.next() {
Expand Down Expand Up @@ -2518,6 +2542,207 @@ mod test {
assert_keys(&[], layout.keycodes());
}

#[test]
fn order_clean_tap() {
// Press and release modifier with no other keys → Tap.
static LAYERS: Layers<2, 1> = &[[[
HoldTap(&HoldTapAction {
on_press_reset_timeout_to: None,
timeout: u16::MAX,
hold: k(LAlt),
timeout_action: k(Space),
tap: k(Space),
config: HoldTapConfig::Order { buffer: 0 },
tap_hold_interval: 0,
require_prior_idle: None,
}),
k(Enter),
]]];
let mut layout = Layout::new(LAYERS);

layout.event(Press(0, 0));
assert_eq!(CustomEvent::NoEvent, layout.tick());
assert_keys(&[], layout.keycodes());
for _ in 0..50 {
assert_eq!(CustomEvent::NoEvent, layout.tick());
assert_keys(&[], layout.keycodes());
}
layout.event(Release(0, 0));
assert_eq!(CustomEvent::NoEvent, layout.tick());
assert_keys(&[Space], layout.keycodes());
assert_eq!(CustomEvent::NoEvent, layout.tick());
assert_keys(&[], layout.keycodes());
}

#[test]
fn order_hold() {
// Modifier down → other down → other up first → Hold.
static LAYERS: Layers<2, 1> = &[[[
HoldTap(&HoldTapAction {
on_press_reset_timeout_to: None,
timeout: u16::MAX,
hold: k(LAlt),
timeout_action: k(Space),
tap: k(Space),
config: HoldTapConfig::Order { buffer: 0 },
tap_hold_interval: 0,
require_prior_idle: None,
}),
k(Enter),
]]];
let mut layout = Layout::new(LAYERS);

layout.event(Press(0, 0));
assert_eq!(CustomEvent::NoEvent, layout.tick());
assert_keys(&[], layout.keycodes());
layout.event(Press(0, 1));
assert_eq!(CustomEvent::NoEvent, layout.tick());
assert_keys(&[], layout.keycodes());
// Other key releases first → Hold
layout.event(Release(0, 1));
assert_eq!(CustomEvent::NoEvent, layout.tick());
assert_keys(&[LAlt], layout.keycodes());
assert_eq!(CustomEvent::NoEvent, layout.tick());
assert_keys(&[LAlt, Enter], layout.keycodes());
assert_eq!(CustomEvent::NoEvent, layout.tick());
assert_keys(&[LAlt], layout.keycodes());
// Release modifier
layout.event(Release(0, 0));
assert_eq!(CustomEvent::NoEvent, layout.tick());
assert_keys(&[], layout.keycodes());
}

#[test]
fn order_tap() {
// Modifier down → other down → modifier up first → Tap.
static LAYERS: Layers<2, 1> = &[[[
HoldTap(&HoldTapAction {
on_press_reset_timeout_to: None,
timeout: u16::MAX,
hold: k(LAlt),
timeout_action: k(Space),
tap: k(Space),
config: HoldTapConfig::Order { buffer: 0 },
tap_hold_interval: 0,
require_prior_idle: None,
}),
k(Enter),
]]];
let mut layout = Layout::new(LAYERS);

layout.event(Press(0, 0));
assert_eq!(CustomEvent::NoEvent, layout.tick());
assert_keys(&[], layout.keycodes());
layout.event(Press(0, 1));
assert_eq!(CustomEvent::NoEvent, layout.tick());
assert_keys(&[], layout.keycodes());
// Modifier releases first → Tap
layout.event(Release(0, 0));
assert_eq!(CustomEvent::NoEvent, layout.tick());
assert_keys(&[Space], layout.keycodes());
assert_eq!(CustomEvent::NoEvent, layout.tick());
assert_keys(&[Space, Enter], layout.keycodes());
}

#[test]
fn order_multi_key_hold() {
// TH down → A down → B down → A up (while B still held) → TH up.
// A's press+release cycle completes while TH is held → Hold.
static LAYERS: Layers<3, 1> = &[[[
HoldTap(&HoldTapAction {
on_press_reset_timeout_to: None,
timeout: u16::MAX,
hold: k(LAlt),
timeout_action: k(Space),
tap: k(Space),
config: HoldTapConfig::Order { buffer: 0 },
tap_hold_interval: 0,
require_prior_idle: None,
}),
k(Enter),
k(Tab),
]]];
let mut layout = Layout::new(LAYERS);

// TH down
layout.event(Press(0, 0));
assert_eq!(CustomEvent::NoEvent, layout.tick());
assert_keys(&[], layout.keycodes());
// A down
layout.event(Press(0, 1));
assert_eq!(CustomEvent::NoEvent, layout.tick());
assert_keys(&[], layout.keycodes());
// B down
layout.event(Press(0, 2));
assert_eq!(CustomEvent::NoEvent, layout.tick());
assert_keys(&[], layout.keycodes());
// A up — A's press+release cycle is complete → Hold resolves
layout.event(Release(0, 1));
assert_eq!(CustomEvent::NoEvent, layout.tick());
assert_keys(&[LAlt], layout.keycodes());
// Queued keys replay: Enter press, Tab press, Enter release
assert_eq!(CustomEvent::NoEvent, layout.tick());
assert_keys(&[LAlt, Enter], layout.keycodes());
assert_eq!(CustomEvent::NoEvent, layout.tick());
assert_keys(&[LAlt, Enter, Tab], layout.keycodes());
assert_eq!(CustomEvent::NoEvent, layout.tick());
assert_keys(&[LAlt, Tab], layout.keycodes());
// Release B
layout.event(Release(0, 2));
assert_eq!(CustomEvent::NoEvent, layout.tick());
assert_keys(&[LAlt], layout.keycodes());
// Release TH
layout.event(Release(0, 0));
assert_eq!(CustomEvent::NoEvent, layout.tick());
assert_keys(&[], layout.keycodes());
}

#[test]
fn order_buffer_ignores_press_within_window() {
// TH down (buffer=50) → other key pressed+released within 50 ticks.
// Without buffer this would be Hold (other key's press+release cycle
// completes while TH held). With buffer=50, the press is ignored by
// release-order logic, so TH remains unresolved. Releasing TH → Tap.
static LAYERS: Layers<2, 1> = &[[[
HoldTap(&HoldTapAction {
on_press_reset_timeout_to: None,
require_prior_idle: None,
timeout: u16::MAX,
hold: k(LAlt),
timeout_action: k(Space),
tap: k(Space),
config: HoldTapConfig::Order { buffer: 50 },
tap_hold_interval: 0,
}),
k(Enter),
]]];
let mut layout = Layout::new(LAYERS);

// TH down
layout.event(Press(0, 0));
assert_eq!(CustomEvent::NoEvent, layout.tick());
assert_keys(&[], layout.keycodes());
// Other key pressed at tick ~1 (well within 50-tick buffer)
layout.event(Press(0, 1));
assert_eq!(CustomEvent::NoEvent, layout.tick());
assert_keys(&[], layout.keycodes());
// Other key released — would normally trigger Hold, but press is buffered
layout.event(Release(0, 1));
assert_eq!(CustomEvent::NoEvent, layout.tick());
assert_keys(&[], layout.keycodes());
// TH released → Tap (buffered press was ignored).
// Space activates, then queued Enter press+release replays.
layout.event(Release(0, 0));
assert_eq!(CustomEvent::NoEvent, layout.tick());
assert_keys(&[Space], layout.keycodes());
assert_eq!(CustomEvent::NoEvent, layout.tick());
assert_keys(&[Space, Enter], layout.keycodes());
assert_eq!(CustomEvent::NoEvent, layout.tick());
assert_keys(&[Space], layout.keycodes());
assert_eq!(CustomEvent::NoEvent, layout.tick());
assert_keys(&[], layout.keycodes());
}

#[test]
fn permissive_hold() {
static LAYERS: Layers<2, 1> = &[[[
Expand Down
2 changes: 2 additions & 0 deletions parser/src/cfg/list_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ pub const CLIPBOARD_RESTORE: &str = "clipboard-restore";
pub const CLIPBOARD_SAVE_SET: &str = "clipboard-save-set";
pub const CLIPBOARD_SAVE_CMD_SET: &str = "clipboard-save-cmd-set";
pub const CLIPBOARD_SAVE_SWAP: &str = "clipboard-save-swap";
pub const TAP_HOLD_ORDER: &str = "tap-hold-order";
pub const TAP_HOLD_OPPOSITE_HAND: &str = "tap-hold-opposite-hand";

pub fn is_list_action(ac: &str) -> bool {
Expand Down Expand Up @@ -272,6 +273,7 @@ pub fn is_list_action(ac: &str) -> bool {
CLIPBOARD_SAVE_SET,
CLIPBOARD_SAVE_CMD_SET,
CLIPBOARD_SAVE_SWAP,
TAP_HOLD_ORDER,
TAP_HOLD_OPPOSITE_HAND,
];
LIST_ACTIONS.contains(&ac)
Expand Down
1 change: 1 addition & 0 deletions parser/src/cfg/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1508,6 +1508,7 @@ fn parse_action_list(ac: &[SExpr], s: &ParserState) -> Result<&'static KanataAct
TAP_HOLD_PRESS | TAP_HOLD_PRESS_A => {
parse_tap_hold(&ac[1..], s, HoldTapConfig::HoldOnOtherKeyPress)
}
TAP_HOLD_ORDER => parse_tap_hold_order(&ac[1..], s),
TAP_HOLD_RELEASE | TAP_HOLD_RELEASE_A => {
parse_tap_hold(&ac[1..], s, HoldTapConfig::PermissiveHold)
}
Expand Down
34 changes: 34 additions & 0 deletions parser/src/cfg/tap_hold.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,40 @@ pub(crate) fn parse_tap_hold_timeout(
}))))
}

pub(crate) fn parse_tap_hold_order(
ac_params: &[SExpr],
s: &ParserState,
) -> Result<&'static KanataAction> {
let n_opts = count_trailing_options(ac_params, s);
let n_positional = ac_params.len() - n_opts;
if n_positional != 4 {
bail!(
r"tap-hold-order expects 4 items after it, got {}.
Params in order:
<tap-repress-timeout> <buffer-ms> <tap-action> <hold-action>",
n_positional,
)
}
let tap_repress_timeout = parse_u16(&ac_params[0], s, "tap repress timeout")?;
let buffer = parse_u16(&ac_params[1], s, "buffer")?;
let tap_action = parse_action(&ac_params[2], s)?;
let hold_action = parse_action(&ac_params[3], s)?;
if matches!(tap_action, Action::HoldTap { .. }) {
bail!("tap-hold does not work in the tap-action of tap-hold")
}
let opts = parse_tap_hold_options(&ac_params[n_positional..], s)?;
Ok(s.a.sref(Action::HoldTap(s.a.sref(HoldTapAction {
config: HoldTapConfig::Order { buffer },
tap_hold_interval: tap_repress_timeout,
timeout: u16::MAX, // Resolution is purely event-driven, not timeout-based.
tap: *tap_action,
hold: *hold_action,
timeout_action: *tap_action,
on_press_reset_timeout_to: None,
require_prior_idle: opts.require_prior_idle,
}))))
}

pub(crate) fn parse_tap_hold_keys(
ac_params: &[SExpr],
s: &ParserState,
Expand Down
Loading
Loading