Skip to content

Commit 5a98ab0

Browse files
malpernclaude
andcommitted
feat: add tap-hold-keys action with named key list options
Adds a new tap-hold variant `tap-hold-keys` that uses named optional list parameters instead of positional key lists. This addresses the usability concern of `tap-hold-release-tap-keys-release` having too many positional parameters, and adds the ability to trigger hold early on press for specific keys (closes jtroo#1985). Syntax: (tap-hold-keys <repress-timeout> <hold-timeout> <tap> <hold> (tap-on-press <keys...>) (tap-on-press-release <keys...>) (hold-on-press <keys...>)) All list options are optional. Unlisted keys use PermissiveHold behavior. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent fe99fd5 commit 5a98ab0

File tree

7 files changed

+342
-0
lines changed

7 files changed

+342
-0
lines changed

cfg_samples/kanata.kbd

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,17 @@ If you need help, please feel welcome to ask in the GitHub discussions.
570570
;; This is useful for home row mods where fast typing should not trigger modifiers.
571571
utk (tap-hold-tap-keys 200 200 u @msc (a o e))
572572

573+
;; tap-hold-keys is a flexible tap-hold with named key list options.
574+
;; tap: u hold: misc layer
575+
;; (tap-on-press a o e) — tap immediately if a, o, or e are pressed
576+
;; (tap-on-press-release ' , .) — tap if ', ,, or . are pressed then released
577+
;; (hold-on-press 1 2 3) — hold immediately if 1, 2, or 3 are pressed
578+
;; All list options are optional. Unlisted keys use PermissiveHold behavior.
579+
uthk (tap-hold-keys 200 200 u @msc
580+
(tap-on-press a o e)
581+
(tap-on-press-release ' , .)
582+
(hold-on-press 1 2 3))
583+
573584
;; tap-hold-order resolves by release order instead of timeout.
574585
;; tap: a hold: lctl buffer: 50ms (fast typing grace period)
575586
aor (tap-hold-order 200 50 a lctl)

docs/config.adoc

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2017,6 +2017,7 @@ results in `$tap-action` activating.
20172017
(tap-hold-release-tap-keys-release $tap-repress-timeout $hold-timeout $tap-action $hold-action $tap-trigger-keys-on-press $tap-trigger-keys-on-press-then-release)
20182018
(tap-hold-except-keys $tap-repress-timeout $hold-timeout $tap-action $hold-action $tap-keys)
20192019
(tap-hold-tap-keys $tap-repress-timeout $hold-timeout $tap-action $hold-action $tap-keys)
2020+
(tap-hold-keys $tap-repress-timeout $hold-timeout $tap-action $hold-action [options...])
20202021
(tap-hold-opposite-hand $timeout $tap-action $hold-action [options...])
20212022
(tap-hold-order $tap-repress-timeout $buffer-ms $tap-action $hold-action [options...])
20222023
----
@@ -2070,6 +2071,14 @@ when other keys are pressed and released.
20702071
Waits for full `$hold-timeout` before activating `$hold-action`.
20712072
This is useful for home row mods where fast typing should not trigger modifiers.
20722073

2074+
| `tap-hold-keys`
2075+
| A flexible tap-hold variant with named key list options.
2076+
Options are: `(tap-on-press <keys...>)` activates `$tap-action` early if a listed key is pressed;
2077+
`(tap-on-press-release <keys...>)` activates `$tap-action` early if a listed key is pressed then released;
2078+
`(hold-on-press <keys...>)` activates `$hold-action` early if a listed key is pressed.
2079+
For any other key, activates `$hold-action` when the key is pressed and released (PermissiveHold behavior).
2080+
All list options are optional.
2081+
20732082
| `tap-hold-opposite-hand`
20742083
| Resolves to `$hold-action` when a key from the opposite hand (per `defhands`) is pressed.
20752084
Requires a `defhands` directive. Supports list-form options for fine-grained control.

parser/src/cfg/custom_tap_hold.rs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,64 @@ pub(crate) fn custom_tap_hold_release_trigger_tap_release(
125125
)
126126
}
127127

128+
/// Returns a closure for `tap-hold-keys` with three optional key lists:
129+
/// - `keys_tap_on_press`: trigger tap immediately on press
130+
/// - `keys_tap_on_press_release`: trigger tap when pressed then released
131+
/// - `keys_hold_on_press`: trigger hold immediately on press
132+
/// For any other key, falls back to PermissiveHold behavior.
133+
///
134+
/// Priority when a key appears in multiple lists (checked in order):
135+
/// tap-on-press > hold-on-press > tap-on-press-release > PermissiveHold
136+
pub(crate) fn custom_tap_hold_keys(
137+
keys_tap_on_press: &[OsCode],
138+
keys_tap_on_press_release: &[OsCode],
139+
keys_hold_on_press: &[OsCode],
140+
a: &Allocations,
141+
) -> &'static CustomTapHoldFn {
142+
let keys_tap_on_press =
143+
a.sref_vec(Vec::from_iter(keys_tap_on_press.iter().copied().map(u16::from)));
144+
let keys_tap_on_press_release = a.sref_vec(Vec::from_iter(
145+
keys_tap_on_press_release.iter().copied().map(u16::from),
146+
));
147+
let keys_hold_on_press =
148+
a.sref_vec(Vec::from_iter(keys_hold_on_press.iter().copied().map(u16::from)));
149+
a.sref(
150+
move |mut queued: QueuedIter, _coord: KCoord| -> (Option<WaitingAction>, bool) {
151+
while let Some(q) = queued.next() {
152+
if q.event().is_press() {
153+
let (i, j) = q.event().coord();
154+
if i != REAL_KEY_ROW {
155+
continue;
156+
}
157+
// If key is in tap-on-press list, trigger tap immediately.
158+
if keys_tap_on_press.iter().copied().any(|j2| j2 == j) {
159+
return (Some(WaitingAction::Tap), false);
160+
}
161+
// If key is in hold-on-press list, trigger hold immediately.
162+
if keys_hold_on_press.iter().copied().any(|j2| j2 == j) {
163+
return (Some(WaitingAction::Hold), false);
164+
}
165+
// If key is in tap-on-press-release list and has been released,
166+
// trigger tap.
167+
if keys_tap_on_press_release.iter().copied().any(|j2| j2 == j) {
168+
let target = Event::Release(i, j);
169+
if queued.clone().copied().any(|q| q.event() == target) {
170+
return (Some(WaitingAction::Tap), false);
171+
}
172+
}
173+
// Otherwise do the PermissiveHold algorithm:
174+
// if another key was pressed and released, trigger hold.
175+
let target = Event::Release(i, j);
176+
if queued.clone().copied().any(|q| q.event() == target) {
177+
return (Some(WaitingAction::Hold), false);
178+
}
179+
}
180+
}
181+
(None, false)
182+
},
183+
)
184+
}
185+
128186
pub(crate) fn custom_tap_hold_except(keys: &[OsCode], a: &Allocations) -> &'static CustomTapHoldFn {
129187
let keys = a.sref_vec(Vec::from_iter(keys.iter().copied()));
130188
a.sref(

parser/src/cfg/list_actions.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ pub const TAP_HOLD_EXCEPT_KEYS: &str = "tap-hold-except-keys";
2121
pub const TAP_HOLD_EXCEPT_KEYS_A: &str = "tap⬓⤫keys";
2222
pub const TAP_HOLD_TAP_KEYS: &str = "tap-hold-tap-keys";
2323
pub const TAP_HOLD_TAP_KEYS_A: &str = "tap⬓tapkeys";
24+
pub const TAP_HOLD_KEYS: &str = "tap-hold-keys";
2425
pub const MULTI: &str = "multi";
2526
pub const MACRO: &str = "macro";
2627
pub const MACRO_REPEAT: &str = "macro-repeat";
@@ -160,6 +161,7 @@ pub fn is_list_action(ac: &str) -> bool {
160161
TAP_HOLD_EXCEPT_KEYS_A,
161162
TAP_HOLD_TAP_KEYS,
162163
TAP_HOLD_TAP_KEYS_A,
164+
TAP_HOLD_KEYS,
163165
MULTI,
164166
MACRO,
165167
MACRO_REPEAT,

parser/src/cfg/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1519,6 +1519,7 @@ fn parse_action_list(ac: &[SExpr], s: &ParserState) -> Result<&'static KanataAct
15191519
parse_tap_hold_timeout(&ac[1..], s, HoldTapConfig::PermissiveHold)
15201520
}
15211521
TAP_HOLD_RELEASE_KEYS_TAP_RELEASE => parse_tap_hold_keys_trigger_tap_release(&ac[1..], s),
1522+
TAP_HOLD_KEYS => parse_tap_hold_keys_named_lists(&ac[1..], s),
15221523
TAP_HOLD_RELEASE_KEYS | TAP_HOLD_RELEASE_KEYS_A => {
15231524
parse_tap_hold_keys(&ac[1..], s, TAP_HOLD_RELEASE_KEYS, custom_tap_hold_release)
15241525
}

parser/src/cfg/tap_hold.rs

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ pub(crate) fn parse_tap_hold_options(
6969
}
7070

7171
const TAP_HOLD_OPTION_KEYWORDS: &[&str] = &["require-prior-idle"];
72+
const TAP_HOLD_KEYS_OPTION_KEYWORDS: &[&str] = &[
73+
"require-prior-idle",
74+
"tap-on-press",
75+
"tap-on-press-release",
76+
"hold-on-press",
77+
];
7278

7379
/// Count how many trailing expressions are tap-hold option lists.
7480
/// An option list is a list whose first element is a known option keyword.
@@ -302,3 +308,137 @@ Params in order:
302308
require_prior_idle: opts.require_prior_idle,
303309
}))))
304310
}
311+
312+
pub(crate) fn parse_tap_hold_keys_named_lists(
313+
ac_params: &[SExpr],
314+
s: &ParserState,
315+
) -> Result<&'static KanataAction> {
316+
// Count trailing options using the extended keyword set.
317+
let n_opts = {
318+
let mut count = 0;
319+
for expr in ac_params.iter().rev() {
320+
if let Some(list) = expr.list(s.vars()) {
321+
if let Some(kw) = list.first().and_then(|e| e.atom(s.vars())) {
322+
if TAP_HOLD_KEYS_OPTION_KEYWORDS.contains(&kw) {
323+
count += 1;
324+
continue;
325+
}
326+
}
327+
}
328+
break;
329+
}
330+
count
331+
};
332+
let n_positional = ac_params.len() - n_opts;
333+
if n_positional != 4 {
334+
bail!(
335+
r"{} expects 4 items after it, got {}.
336+
Params in order:
337+
<tap-repress-timeout> <hold-timeout> <tap-action> <hold-action>
338+
Followed by optional lists:
339+
(tap-on-press <keys...>) (tap-on-press-release <keys...>) (hold-on-press <keys...>)
340+
(require-prior-idle <ms>)",
341+
TAP_HOLD_KEYS,
342+
n_positional,
343+
)
344+
}
345+
let tap_repress_timeout = parse_u16(&ac_params[0], s, "tap repress timeout")?;
346+
let hold_timeout = parse_non_zero_u16(&ac_params[1], s, "hold timeout")?;
347+
let tap_action = parse_action(&ac_params[2], s)?;
348+
let hold_action = parse_action(&ac_params[3], s)?;
349+
if matches!(tap_action, Action::HoldTap { .. }) {
350+
bail!("tap-hold does not work in the tap-action of tap-hold")
351+
}
352+
353+
let mut require_prior_idle = None;
354+
let mut tap_on_press: Vec<OsCode> = vec![];
355+
let mut tap_on_press_release: Vec<OsCode> = vec![];
356+
let mut hold_on_press: Vec<OsCode> = vec![];
357+
let mut seen_options: HashSet<&str> = HashSet::default();
358+
359+
for option_expr in &ac_params[n_positional..] {
360+
let Some(option) = option_expr.list(s.vars()) else {
361+
bail_expr!(
362+
option_expr,
363+
"expected option list, e.g. `(tap-on-press a b c)`"
364+
);
365+
};
366+
if option.is_empty() {
367+
bail_expr!(option_expr, "option list cannot be empty");
368+
}
369+
let kw = option[0]
370+
.atom(s.vars())
371+
.ok_or_else(|| anyhow_expr!(&option[0], "option name must be a string"))?;
372+
if !seen_options.insert(kw) {
373+
bail_expr!(&option[0], "duplicate option '{}'", kw);
374+
}
375+
match kw {
376+
"require-prior-idle" => {
377+
require_prior_idle =
378+
Some(parse_require_prior_idle_option(option, option_expr, s)?);
379+
}
380+
"tap-on-press" => {
381+
tap_on_press = parse_key_list_from_option(option, option_expr, s, kw)?;
382+
}
383+
"tap-on-press-release" => {
384+
tap_on_press_release = parse_key_list_from_option(option, option_expr, s, kw)?;
385+
}
386+
"hold-on-press" => {
387+
hold_on_press = parse_key_list_from_option(option, option_expr, s, kw)?;
388+
}
389+
_ => bail_expr!(
390+
&option[0],
391+
"unknown tap-hold-keys option '{}'. \
392+
Valid options: tap-on-press, tap-on-press-release, hold-on-press, require-prior-idle",
393+
kw
394+
),
395+
}
396+
}
397+
398+
Ok(s.a.sref(Action::HoldTap(s.a.sref(HoldTapAction {
399+
config: HoldTapConfig::Custom(custom_tap_hold_keys(
400+
&tap_on_press,
401+
&tap_on_press_release,
402+
&hold_on_press,
403+
&s.a,
404+
)),
405+
tap_hold_interval: tap_repress_timeout,
406+
timeout: hold_timeout,
407+
tap: *tap_action,
408+
hold: *hold_action,
409+
timeout_action: *hold_action,
410+
on_press_reset_timeout_to: None,
411+
require_prior_idle,
412+
}))))
413+
}
414+
415+
/// Parse keys from a named option list like `(tap-on-press a b c)`.
416+
/// The first element is the keyword, remaining elements are key names.
417+
fn parse_key_list_from_option(
418+
option: &[SExpr],
419+
option_expr: &SExpr,
420+
s: &ParserState,
421+
kw: &str,
422+
) -> Result<Vec<OsCode>> {
423+
if option.len() < 2 {
424+
bail_expr!(
425+
option_expr,
426+
"{} expects at least one key, e.g. `({} a b c)`",
427+
kw,
428+
kw
429+
);
430+
}
431+
option[1..].iter().try_fold(vec![], |mut keys, key| {
432+
key.atom(s.vars())
433+
.map(|a| -> Result<()> {
434+
keys.push(str_to_oscode(a).ok_or_else(|| {
435+
anyhow_expr!(key, "string of a known key is expected")
436+
})?);
437+
Ok(())
438+
})
439+
.ok_or_else(|| {
440+
anyhow_expr!(key, "string of a known key is expected, found list instead")
441+
})??;
442+
Ok(keys)
443+
})
444+
}

0 commit comments

Comments
 (0)