Skip to content

feat: add tap-hold-release-order action#1970

Merged
jtroo merged 6 commits intojtroo:mainfrom
tompassarelli:feat/tap-hold-release-order
Mar 15, 2026
Merged

feat: add tap-hold-release-order action#1970
jtroo merged 6 commits intojtroo:mainfrom
tompassarelli:feat/tap-hold-release-order

Conversation

@tompassarelli
Copy link
Copy Markdown
Contributor

Add tap-hold-release-order: event-driven tap-hold disambiguation

A new tap-hold variant that resolves based on release order rather than timing:

  • If another key is pressed and released while the hold-tap key is still held → Hold
  • If the hold-tap key is released first → Tap

This is useful for spacebar-as-modifier setups where timeout-based approaches either add latency (high timeout) or misfire during fast typing (low timeout). Release-order makes the decision purely event-driven.

Parameters

(tap-hold-release-order $tap-repress-timeout $buffer-ms $tap-action $hold-action)
Parameter Description
$tap-repress-timeout If the key is tapped and pressed again within this window (ms), it bypasses hold-tap logic and immediately fires as tap — allowing double-tap-and-hold to repeat. 0 to disable.
$buffer-ms Grace period (ms) after the initial press during which release-order logic is ignored. Key presses within this window always resolve as tap regardless of release order, preventing misfires during fast typing. 0 to disable.

Example

(defalias spacesym (tap-hold-release-order 200 50 spc (layer-while-held symbols)))
  • Tap space → outputs space
  • Hold space, press a, release a (while space still held) → activates symbols layer
  • Hold space, press a, release space first → outputs space then a
  • Within 50ms of pressing space: fast typing always outputs space (buffer)
  • Double-tap space within 200ms and hold → space repeats (tap-repress)

Checklist

  • Add documentation to docs/config.adoc
    • Yes
  • Add example and basic docs to cfg_samples/kanata.kbd
    • Yes
  • Update error messages
    • Yes
  • Added tests, or did manual testing
    • Yes — 5 unit tests (clean tap, hold, tap, buffer ignores fast typing, hold after buffer)

@tompassarelli tompassarelli force-pushed the feat/tap-hold-release-order branch from 3fb0d84 to 6e2d884 Compare March 11, 2026 15:04
@malpern
Copy link
Copy Markdown
Contributor

malpern commented Mar 11, 2026

Nice work — release-order disambiguation fills a real gap. A couple thoughts:

Naming

My main concern is tap-hold-release-order living right next to tap-hold-release in the docs. They're one word apart but fundamentally different — tap-hold-release cares whether an interrupt key was released at all during hold, while this cares about which key releases first.

For context, the ecosystem is already a bit of a maze here:

Firmware Name Behavior
QMK PERMISSIVE_HOLD Hold on other key release
Kanata tap-hold-release Same as above
ZMK balanced Similar, timeout-gated
This PR tap-hold-release-order Whichever key releases first wins

Some alternatives that might read more distinctly:

  • tap-hold-release-first — "resolve based on which releases first"
  • tap-hold-order — shorter, emphasizes the core concept

Not sure what's best though — @jtroo you have much better sense of how users navigate the existing variant names. What do you think?

Implementation

Code is clean — slots into HoldTapConfig naturally. Minor things I noticed:

  • Multi-key test: All five tests use two keys. Might be worth one test with hold-tap + two other keys (A down, B down, A released while B still held) to document the iterator behavior in that case.
  • Buffer tick math: self.ticks.saturating_sub(q.since) < buffer is correct but the relationship isn't obvious from the variable names alone — a short comment could help future readers.
  • timeout: u16::MAX: Makes sense for purely event-driven resolution. A one-line comment noting the intent would clarify it's deliberate.

All minor — overall this looks like a solid addition.

@jtroo
Copy link
Copy Markdown
Owner

jtroo commented Mar 13, 2026

Thanks for the contribution!

Two main things:

  1. please pull from latest main and add handling for the require-prior-idle option
  2. agree the naming is potentially confusing when comparing against tap-hold-release

To expand more on naming, I believe tap-hold-order would be a nicer name because the behaviours are quite different; this action doesn't have a timeout where tap-hold-release does.

@tompassarelli tompassarelli force-pushed the feat/tap-hold-release-order branch from 6e2d884 to 5f4b8b9 Compare March 13, 2026 07:09
tompassarelli and others added 5 commits March 13, 2026 14:37
…guation

Adds a new HoldTapConfig variant (HoldOnOtherKeyPressWithGap) that
resolves tap vs hold based on the time gap between the tap-hold keydown
and the next keydown, rather than using a timeout. If the gap is below
the threshold, resolves as tap (fast typing overlap). If above, resolves
as hold (intentional modifier). One parameter: min-gap-ms.

Config: (tap-hold-press-gap <min-gap-ms> <tap-action> <hold-action>)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Pure event-driven tap-hold disambiguation with zero parameters.
After both keys are down, the first release determines intent:
- Other key releases first (modifier still held) → Hold
- Modifier releases first (other key still held) → Tap

No timers, no thresholds. Decision on the 3rd event only.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…riod

Key presses within the buffer (ms) after the hold-tap key press are
ignored by release-order logic, resolving as Tap regardless of release
order.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Allows double-tap-and-hold to bypass hold-tap logic and repeat the tap
action. Syntax is now:
(tap-hold-release-order <tap-repress-timeout> <buffer-ms> <tap> <hold>)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@tompassarelli tompassarelli force-pushed the feat/tap-hold-release-order branch from c30e122 to 6f8643c Compare March 13, 2026 07:45
@tompassarelli
Copy link
Copy Markdown
Contributor Author

Updated per feedback:

  • Renamed to tap-hold-order
  • Added optional require-prior-idle-ms as trailing parameter (defaults to 0/disabled), matching the interface contract across other tap-hold variants
  • Added multi-key test (TH + two other keys, documents iterator behavior)
  • Added inline comments on the saturating_sub/buffer comparison and the timeout: u16::MAX
  • Rebased onto latest main (picks up feat: add tap-hold-require-prior-idle defcfg option #1960)

Copy link
Copy Markdown
Owner

@jtroo jtroo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add some simulation tests to test closer to end-to-end. Also please add a test case for a press within the buffer window; I didn't spot one in the keyberon tests.

https://github.com/jtroo/kanata/blob/main/src/tests/sim_tests/tap_hold_tests.rs

Simulation tests can be run like so:

cargo test --features=simulated_output sim_tests

@tompassarelli
Copy link
Copy Markdown
Contributor Author

Please add some simulation tests to test closer to end-to-end. Also please add a test case for a press within the buffer window; I didn't spot one in the keyberon tests.

https://github.com/jtroo/kanata/blob/main/src/tests/sim_tests/tap_hold_tests.rs

Simulation tests can be run like so:

cargo test --features=simulated_output sim_tests

Updated with simulation tests and keyberon buffer test per your feedback. Tests cover: clean tap, hold via release-order, tap via release-order, buffer ignoring fast typing, hold after buffer expires, require-prior-idle short-circuit, and require-prior-idle passthrough. All 184 sim tests pass

@tompassarelli tompassarelli requested a review from jtroo March 14, 2026 20:25
@jtroo jtroo merged commit ea1733f into jtroo:main Mar 15, 2026
5 checks passed
jtroo pushed a commit that referenced this pull request Mar 15, 2026
Add tap-hold-order: event-driven tap-hold disambiguation

A new `tap-hold` variant that resolves based on **release order** rather
than timing:

- If another key is pressed **and released** while the hold-tap key is
still held → **Hold**
- If the hold-tap key is released first → **Tap**

This is useful for spacebar-as-modifier setups where timeout-based
approaches either add latency (high timeout) or misfire during fast
typing (low timeout). Release-order makes the decision purely
event-driven.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
malpern added a commit to malpern/kanata that referenced this pull request Mar 17, 2026
Brings in upstream commits through jtroo#1977, including:
- tap-hold-order action (jtroo#1970)
- per-action require-prior-idle upstream merge (jtroo#1969)
- macOS output readiness refactor (jtroo#1964)
- layer change tick fix (jtroo#1977)

Conflicts in per-action require-prior-idle code resolved by
taking upstream's canonical version.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants