Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
179 changes: 179 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,161 @@ 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 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