jtroo / kanata

Improve keyboard comfort and usability with advanced customization
GNU Lesser General Public License v3.0
2.88k stars 124 forks source link

Expand the support for custom tap-hold function #128

Open jtroo opened 2 years ago

jtroo commented 2 years ago

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:

jtroo commented 2 years ago

Related issue in keyberon for additional inspiration: https://github.com/TeXitoi/keyberon/issues/35

jtroo commented 2 years ago

@SignSpice here's the issue, for your interest

SignSpice commented 2 years ago

Cool!

For me I am looking to unify my mac and linux boxes, so I'll work on the macOS support first.

jtroo commented 1 year ago

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.

cyxae commented 11 months ago

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

jtroo commented 11 months ago

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.

cyxae commented 11 months ago

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 !

cyxae commented 11 months ago

I updated my code as you proposed, here is the potential PR https://github.com/jtroo/kanata/compare/main...cyxae:kanata:main

gerhard-h commented 11 months ago

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.

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))

cyxae commented 11 months ago

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 ^^

BlueDrink9 commented 7 months ago

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.

gerhard-h commented 7 months ago

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).

AmmarAbouZor commented 7 months ago

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

jtroo commented 7 months ago

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

jtroo commented 7 months ago

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.
AmmarAbouZor commented 7 months ago

@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?

jtroo commented 7 months ago

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.

AmmarAbouZor commented 7 months ago

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

eugenesvk commented 7 months ago

@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.,

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

sooheon commented 1 month ago

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).

Can you point me to more detail on this? I want to switch to fast-typing layer when any two taps have registered within N ms, and then switch back to hold-receptive layer after timeout.

gerhard-h commented 1 month ago

I'm sure there is already an extensive thread about this, but I couldn't find it.

here is the core of my config in general just wrap every key x in (multi x @.tp) example is j

I did some extra rolls control to favor Fd over FD and fd over D df over ctl+f

the (multi @.base rctl) instead of just rctl looks like overkill but I asume I had good a reason.

(defcfg
   process-unmapped-keys yes
   delegate-to-first-layer yes
)

(defvirtualkeys
  to-base (layer-switch base)
)

(defalias
  .base (layer-while-held base)

  ;; @.tp diables tap-hold keys if typing rapidly by switching to a layer without home row mods and upper case but restore after a shord idle period
  .tp (multi
    (one-shot 95 (layer-while-held typing))
    (on-idle  95 tap-virtualkey to-base )
  )
  .tpf (multi
    (one-shot 145 lsft)
  )

;; *** HOME ROW MODS *** 
  .f   (tap-hold 1 180 (fork f (multi f nop7 @.tpf) (rsft)) (multi nop6 lsft) ) 
  .j   (tap-hold 1 160 (multi j @.tp) rsft ) 

  .d   (multi (tap-hold 1 300 (fork (fork d spc (nop6)) (unicode d) (nop7)) (multi @.base lctl) ) @.tp) 
  .k   (multi (tap-hold 1 300 k (multi @.base rctl) ) @.tp)
) 
(defsrc  
     d f  j k                
)

(deflayer base
 @.d  @.f     @.j   @.k    
 @.nav
)
;; *** fast Typing layer - prevents Upper Case within words when typing fast ***
(deflayer typing
   (unmod d) (unmod f)  (unmod j) (unmod k)        
)
jtroo commented 1 month ago

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).

Can you point me to more detail on this? I want to switch to fast-typing layer when any two taps have registered within N ms, and then switch back to hold-receptive layer after timeout.

https://github.com/jtroo/kanata/issues/502

The solution I wrote - and still use - doesn't check for two taps but instead any tap-activation instead of hold-activation.

gerhard-h commented 1 month ago

502 thats the post I wanted to refer to. Also found my old post #375 that explains why I perfer one-shot over layer-switch

gerhard-h commented 1 month ago

I want to switch to fast-typing layer when any two taps have registered within N ms, and then switch back to hold-receptive layer after timeout.

you could have a virtual key that "counts" taps

(defvirtualkeys
  one nop1
)

counting done like this for a letter a tap

(switch
  (nop1) (multi a @.tp) break
  ()  (multi a (on-press press-virtualkey one)  (on-idle  95 release-virtualkey one ) ) break
)

and @.tp must now release the counter (also all your hold action should release the counter)

  .tp (multi
    (layer-switch typing)
    (on-press release-virtualkey one )
    (on-idle  95 tap-virtualkey to-base )
  )

I still wonder if waiting for a second tap is better than doing it on the first tap

also while "on-idle-to-base" works (on-idle 95 release-virtualkey one ) may never trigger because nop1 is pressed and the keyboard might not be considered idle (in that case you could go for a three layer aproach: base-layer, same-as-base-after-one-tap, fast-typing)