Expand the support for custom tap-hold function
Insipration: https://github.com/kmonad/kmonad/issues/351
Interesting code:
keyberon's HoldTapConfig::Custom
https://github.com/jtroo/keyberon/blob/d3f529a797122d45758574e8f2b7b0daef29cdb4/src/action.rs#L74
keyberon's Stacked
https://github.com/jtroo/keyberon/blob/d3f529a797122d45758574e8f2b7b0daef29cdb4/src/layout.rs#L476
keyberon's Event
https://github.com/jtroo/keyberon/blob/d3f529a797122d45758574e8f2b7b0daef29cdb4/src/layout.rs#L69
The function passed into custom has the following info:
- timing (ms) of events since the hold-tap key was pressed with
since - the up/down status of the key, in
event - the
OsCodebeing pressed - thejindex ofeventmaps toOsCode.
Related issue in keyberon for additional inspiration: https://github.com/TeXitoi/keyberon/issues/35
@SignSpice here's the issue, for your interest
Cool!
For me I am looking to unify my mac and linux boxes, so I'll work on the macOS support first.
I added some support for this in #343, which is in release 1.3.0-prerelease-2. It only makes use of key information right now; no customizable timing information. However, having the existing example code with all the type system trickery being done should help others play around with the code if desired.
Hi!
An idea that was suggested to me (which apparently is available in ZMK), is to allow to specify a blacklist for a tap-hold key.
Indeed, to avoid rolling errors, I don’t want my left Ctrl homerow mod to be triggered with keys on the left side of the keyboard, but only with the ones on the right side.
This could look like this:
;; Home-row mods
;; Must be hold long enough (200ms) to become a modifier.
ss (tap-hold-except-keys 200 200 s lmet (q w e r t a s d f g z x c v b))
dd (tap-hold-except-keys 200 200 d lalt (q w e r t a s d f g z x c v b))
ff (tap-hold-except-keys 200 200 f lctl (q w e r t a s d f g z x c v b))
jj (tap-hold-except-keys 200 200 j rctl (y u i o p h j k l ; n m , . /))
kk (tap-hold-except-keys 200 200 k lalt (y u i o p h j k l ; n m , . /))
ll (tap-hold-except-keys 200 200 l rmet (y u i o p h j k l ; n m , . /))
So whatever is the state of the timeout, if I press F and then Q it outputs fq and not Ctrl+Q
I don’t know Rust, so I’ve made a very sketchy and buggy implementation of what I want, but if someone is interested there you go https://github.com/cyxae/kanata/commit/27d23711e9226f652da2e47a6e2e642f2a0f5c8d
Hi @cyxae, the action you've added sounds suspiciously similar to the existing tap-hold-release-keys functionality, but I guess the difference is there is that no early hold activation like with tap-hold-release.
There is definitely room for simplifying, but thanks for sharing the code! If you'd like to polish it up and make a PR, the big thing I would change is to see how parse_tap_hold_release_keys and https://github.com/jtroo/kanata/blob/main/parser/src/cfg/custom_tap_hold.rs work, and do it that way which should hopefully reduce the amount of code that needs to be added to accomodate the change.
Hi, thank you for the answer !
You got it right, it looks like tap-hold-release-keys but there is another trick in addition to the "no early hold activation". What we want is that even when the timeout expires, the tap hold key pressed behaves as a "tap" if followed by one of the mentioned keys.
Example:
If I press F long enough to reach the timeout, and then I press Q, if F is a tap-hold-release-keys it will output Ctrl+Q whereas a tap-hold-except-keys will output fq.
This implies that the program should not enter the Waiting::Timeout state before it knows what is the next key event, to be able to interpret it as a tap if it is a key in the list. Hence the hacky skip_timeout boolean !
I updated my code as you proposed, here is the potential PR https://github.com/jtroo/kanata/compare/main...cyxae:kanata:main
Example: If I press F long enough to reach the timeout, and then I press Q, if F is a
tap-hold-release-keysit will output Ctrl+Q whereas atap-hold-except-keyswill outputfq.
I like the idea to have an easier fast-typing-control (easier than https://github.com/jtroo/kanata/issues/502#issuecomment-1779875881 I mean)
I just wonder, would "hold only F long" produce "f" or "fff..." or nothing? I asume nothing?
will f-down pause f-up q-tap still produce "fq"?
Is there a way to overwrite the "except" if I hold it for "very long"? (asuming most people expect that)
like (tap-hold-except-keys 200 200 f (tap-hold-except-keys 500 500 lctl (f)) (q w e r t a s d f g z x c v b))
If I hold only F long, it produces the hold behavior, that is to say Ctrl.
Mhm for now the goal is not to allow overwrite of the except if the key is held for very long, that may be another feature in the future ^^
I'd say sunku's blog covers the ultimate desirable behaviour for things like home-row mods, (as this discussion shows, a main use of custom tap-hold.)
If you haven't used his qmk patch, and you use home-row mods, I highly recommend trying it. Homerow mods Just Work, with no change in typing technique and very few accidental activations.
I'd say sunku's blog covers the ultimate desirable behaviour for things like home-row mods, (as this discussion shows, a main use of custom tap-hold.)
sunku's article is a realy interessting read. So even after reading I'm not sure he really covered all homerow mod issues, I think only trying out will tell. (I am missing the propercase problem where you get OZ instead of Oz bcause of holding/chording the Shift key)
The great thing about a solution like this sunku's is, it can all be configured in defcfg without messing with the rest of your keyboard config, wich is really user friendly.
Still it is worth mentioning even today, you can already achieve all/most of this with kanata by auto switching to a fast-typing layer and swichtching back with on-fakekey-idle. (I had to fix one additional chord thereafter, but this might depend on personal typing speed).
I wanted to ask if there is a way to hold multiple home-row modifiers while using tap-hold-release-keys without having to wait for hold timeout. Currently if I want to press Ctrl+Shift for example I must press the shift home-row key and wait for its timeout to pass before I can press the Ctrl home-row key.
I think this could be solved if the decision to use the tap considered on the key release event instead of the key press one
It's certainly possible 🙂.
The code that parses tap-hold-release-keys is here:
https://github.com/jtroo/kanata/blob/8b665b3a31cd044d31562bbd5901d44145473e98/parser/src/cfg/mod.rs#L1346-L1348
https://github.com/jtroo/kanata/blob/8b665b3a31cd044d31562bbd5901d44145473e98/parser/src/cfg/custom_tap_hold.rs#L10-L13
For context on what seems to be the motivation for handling on release, as an interesting read:
Taken from: https://github.com/urob/zmk-config#timeless-homerow-mods
Homerow mods (aka "HRMs") can be a game changer -- at least in theory. In practice, they require some finicky timing: In its most naive implementation, in order to produce a "mod", they must be held longer than tapping-term-ms. In order to produce a "tap", they must be held less than tapping-term-ms. This requires very consistent typing speeds that, alas, I do not possess. Hence my quest for a "timer-less" HRM setup.2
After months of tweaking, I eventually ended up with a HRM setup that is essentially timer-less, resulting in virtually no misfires. Yet it provides a fluent typing experience with mostly no delays.
Let's suppose for a moment we set tapping-term-ms to something ridiculously large, say 5 seconds. This makes the configuration timer-less of sorts. But it has two problems: (1) To activate a mod we will have to hold the HRM keys for what feels like eternity. (2) During regular typing, there are delays between the press of a key and the time it appears on the screen.3 Enter two of ZMK's best configuration options:
- To address the first problem, I use ZMK's balanced flavor, which produces a "hold" if another key is both pressed and released within the tapping-term. Because that is exactly what I normally do with HRMs, there is virtually never a need to wait past my long tapping term (see below for two exceptions).
- To address the typing delay, I use ZMK's require-prior-idle-ms property, which immediately resolves a HRM as "tap" when it is pressed shortly after another key has been tapped. This all but completely eliminates the delay.
This is great but there are still a few rough edges:
- When rolling keys, I sometimes unintentionally end up with "nested" key sequences: key 1 down, key 2 down and up, key 1 up. Because of the balanced flavor, this would falsely register key 1 as a mod. As a remedy, I use ZMK's positional hold-tap feature to force HRMs to always resolve as "tap" when the next key is on the same side of the keyboard. Problem solved.
- ... or at least almost. By default, positional-hold-tap performs the positional check when the next key is pressed. This is not ideal, because it prevents combining multiple modifiers on the same hand. To fix this, I use the hold-trigger-on-release setting, which delays the positional-hold-tap decision until the next key's release. With the setting, multiple mods can be combined when held, while I still get the benefit from positional-hold-tap when keys are tapped.
- So far, nothing of the configuration depends on the duration of tapping-term-ms. In practice, there are two reasons why I don't set it to infinity:
- Sometimes, in rare circumstances, I want to combine a mod with a alpha-key on the same hand (e.g., when using the mouse with the other hand). My positional hold-tap configuration prevents this within the tapping term. By setting the tapping term to something large but not crazy large (I use 280ms), I can still use same-hand mod + alpha shortcuts by holding the mod for just a little while before tapping the alpha-key.
- Sometimes, I want to press a modifier without another key (e.g., on Windows, tapping Win opens the search menu). Because the balanced flavour only kicks in when another key is pressed, this also requires waiting past tapping-term-ms.
- Finally, it is worth noting that this setup works best in combination with a dedicated shift for capitalization during normal typing (I like sticky-shift on a home-thumb). This is because shifting alphas is the one scenario where pressing a mod may conflict with require-prior-idle-ms, which may result in false negatives when typing fast.
@jtroo I've modified the code to handle the early tap case on release event, postponing it on press event, to keep the behavior as it was for the normal case. Here is a code snippet:
move |mut queued: QueuedIter| -> (Option<WaitingAction>, bool) {
let match_key = |j: u16| keys.iter().copied().map(u16::from).any(|j2| j2 == j);
while let Some(q) = queued.next() {
if q.event().is_release() {
let (_, j) = q.event().coord();
// If any key matches the input on key release event, do a tap right away.
if match_key(j) {
return (Some(WaitingAction::Tap), false);
}
}
if q.event().is_press() {
let (i, j) = q.event().coord();
// If any key matches the input on key press, postpone taking decision to
// key release
if match_key(j) {
return (None, false);
}
// Otherwise do the PermissiveHold algorithm.
let target = Event::Release(i, j);
if queued.clone().copied().any(|q| q.event() == target) {
return (Some(WaitingAction::Hold), false);
}
}
}
(None, false)
},
I think this is working as expected, and I think I can use the same implementation in custom_tap_hold_except(...) method. I'll provide a PR for that. The question is which branch should be the target for my PR?
By inspection, the code does not look correct to me. The match_key check in the press branch will always return early and the release branch will never execute.
It seems to me that the correct code is simply to delete:
// If any key matches the input on key press, postpone taking decision to
// key release
if match_key(j) {
return (None, false);
}
The main branch is the target branch for PRs.
As a note, please do not change the behaviour of existing actions and instead add new variants.
Sorry I had the hold-time variable set to a big number which led to false positive by this naive implementation. I'll try to provide a new one.
Very thanks for the information
@AmmarAbouZor btw, there is a potentially helpful way to test such changes described here: https://github.com/jtroo/kanata/blob/main/docs/config.adoc#test-your-config
You define an input file with the key events, e.g.,
- press home-row-Shift
- wait time 0.1s (< holdout of 0.5 sec)
- press home-row-Control
- ...
and get the output of what kanata will translate this into (in this case as far as I understood your issue, the Control will not trigger a mod), and then you can tweak kanata and see whether your new function will trigger it properly