Skip to content

feat: add managed-repeat for kanata-managed key repeat with per-key rates#2070

Open
malpern wants to merge 8 commits into
jtroo:mainfrom
malpern:feat/kanata-owned-repeat
Open

feat: add managed-repeat for kanata-managed key repeat with per-key rates#2070
malpern wants to merge 8 commits into
jtroo:mainfrom
malpern:feat/kanata-owned-repeat

Conversation

@malpern

@malpern malpern commented May 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Problem

Users on macOS experience unintended repeated characters when typing under CPU load — holding a key produces stillllllll or aaaaaaaaaand instead of a single character followed by controlled repeat. This is especially problematic with tap-hold (homerow mods), where the processing delay keeps the previous key held just long enough to trigger OS autorepeat (#1441, #422).

USB HID reports model current switch state, not repeat intent. Linux exposes repeat as a higher-level subsystem feature, so allow-hardware-repeat no fully suppresses it. macOS derives repeat heuristically from sustained key presence in the HID report, and there is no API to disable this. The f24 interrupt workaround from #422 doesn't help — f24 enters the report but the original key stays held.

Solution

(defcfg
  managed-repeat yes
  managed-repeat-delay 500
  managed-repeat-interval 30
)

(defrepeat
  (bspc  210 20)
  (del   210 20)
  (left  150 20)
  (right 150 20)
  (up    150 20)
  (down  150 20)
)

When enabled, kanata releases each non-modifier key from the HID report after 5ms (well below OS repeat thresholds of 300-500ms), then manages all repeat via release+re-press cycles on its own timer. Modifiers are exempt. Repeat cancels immediately on physical key release.

This is opt-in and doesn't change default behavior. Kanata has historically treated repeat as the OS's responsibility (#422, #450), and on Linux that works. On macOS there is no alternative — the only way to prevent OS repeat is to remove the key from the report before the threshold fires.

Repeat is implemented as HID press/release cycles (not higher-level character injection) to preserve app compatibility, navigation semantics, and uniform behavior across terminals, editors, and games.

How it works

Three-phase timer per held key:

  1. HeldBeforeRelease (5ms) — key in report long enough for initial character, then released
  2. ReleasedWaiting — key out of report, waiting for configured delay
  3. Repeating — release+re-press each interval tick

Runs in the existing 1ms tick loop via tick_states(). is_idle() returns false when timers are active so the processing loop keeps ticking.

Cross-platform

The primary motivation is macOS, but per-key repeat rates (faster arrows/delete, slower alphas) are useful on all platforms and not available from any OS natively. The implementation uses existing press_key/release_key primitives and works cross-platform. The early-release phase is primarily needed on macOS; on Linux allow-hardware-repeat no already fully suppresses OS repeat.

This is opt-in on all platforms. If community feedback confirms stability on macOS, a future change could default to yes on macOS where OS repeat cannot be suppressed any other way.

Compatibility

Validated with standard Latin text input on macOS. IME workflows, dead key sequences, and accessibility software may warrant additional testing since repeat events become synthetic press cycles rather than sustained holds.

Validation

  • Repeat produces visible characters through Karabiner virtual HID
  • Per-key rates confirmed via log timestamps
  • Stable under CPU load (compile loop)
  • OS repeat fully suppressed
  • 7 simulation tests

Test plan

  • Simulation tests: basic repeat, early release, modifier exemption, per-key overrides, disabled-by-default
  • macOS hardware validation with default and per-key rates
  • Load testing under CPU pressure
  • Tap-hold interaction testing with homerow mods
  • Linux/Windows validation

Related: #1441, #422, #1794, #2042

malpern and others added 7 commits May 14, 2026 14:55
Add macOS equivalent of linux-continue-if-no-devs-found. When enabled,
kanata keeps running if no matching devices are found at startup and
automatically captures them when they connect.

Two capture paths depending on how devices are specified:
- Hash-based IDs: registered unconditionally via register_device_hash,
  grab() starts the listener thread, and device_connected_callback
  captures the device on connection.
- Name-based IDs: polls every 2s until the device appears, then
  registers and grabs.

Relates to jtroo#1982

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Make Drop unconditional to clean up listener thread in deferred state
- Handle recovery loop gracefully when no devices are grabbed
- Add periodic log message to poll_for_devices (every 30s)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a new repeat engine that lets kanata generate key repeats instead of
relying on the OS. This solves the macOS duplicate key bug where tap-hold
processing delays cause unintended OS autorepeat through the HID report model.

- defcfg options: managed-repeat, managed-repeat-delay, managed-repeat-interval
- defrepeat block for per-key delay/interval overrides
- Automatically suppresses OS hardware repeat when enabled
- Modifiers are exempt from repeat
- Validated on real macOS hardware through Karabiner DriverKit virtual HID
- 9 simulation tests covering basic repeat, per-key overrides, modifiers,
  layer-while-held, and disabled-by-default behavior

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The previous approach re-posted the same HID report, which left macOS
free to run its own repeat timer alongside kanata's managed repeat.

Now the repeat timer has three phases:
1. HeldBeforeRelease (5ms) — key in report just long enough for initial char
2. ReleasedWaiting — key removed from report, OS repeat can never fire
3. Repeating — release+re-press cycles produce fresh keydown events

Validated on macOS hardware under CPU load (KeyPath compile loop).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@malpern malpern force-pushed the feat/kanata-owned-repeat branch from 09541e8 to f8bbc6f Compare May 14, 2026 21:56
@malpern

malpern commented May 15, 2026

Copy link
Copy Markdown
Contributor Author

The macOS CI failure is a runner infrastructure issue — cargo test --all fails with unexpected argument 'test' found, which points to a broken Rust toolchain on the macOS runner. Linux, Windows, Android, and fmt all pass. A re-run should clear it.

The reload path was missing the managed_repeat_state and
allow_hardware_repeat updates, so TCP Reload didn't pick up
changes to defrepeat or managed-repeat-delay/interval.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@malpern

malpern commented May 15, 2026

Copy link
Copy Markdown
Contributor Author

I'm using the ability to customize the repeat speed to offer "Fast Navigation" in KeyPath, where alpha's remain with a normal repeat delay and repeat rate but arrow keys and delete can be much faster. It makes the computer feel much snappier without compromising alpha key typing.
image

@oschrenk

Copy link
Copy Markdown

Recently switched from Karabiner Elements and running into the issue of repeating key-strokes basically daily - not sure how it's solved there - the fix maybe in an updated VHIDD driver (or in Karabiner itself)? Would be great to get this fix.

@jtroo jtroo added the macos Issue pertains to macos; jtroo has no macOS devices and does not maintain the support for this OS. label May 17, 2026
@jtroo

jtroo commented May 17, 2026

Copy link
Copy Markdown
Owner

@oschrenk have you had an opportunity to test if this PR solves your repeat issues?

@oschrenk

Copy link
Copy Markdown

@oschrenk have you had an opportunity to test if this PR solves your repeat issues?

No. Need to find a way to compile/build this and then find a way to re-produce the issue (create some load on the system)

@oschrenk

oschrenk commented May 18, 2026

Copy link
Copy Markdown

Was trying to build and use master before trying this. But now my external bluetooth keyboard is not working (was working with 1.11).

I'm unaware of any device include/exclude procedure

;; configuation
(defcfg
  ;; if `yes`, process keys that are not defined in defsrc
  ;; useful if you are only mapping a few keys in defsrc
  ;; (for now) i'm setting it to `no`, to learn kanata
  process-unmapped-keys no
)


;; physical hardware as source
;; F-row mapped to media keys directly because macOS's fn-layer (the
;; thing that turns F1 into brightness when fnState=0) only applies to
;; the built-in Apple keyboard. kanata's virtual HID device looks like
;; an external keyboard to macOS, so the fn-layer translation never
;; applies to keys kanata emits. We replicate it manually.
(defsrc
  f1   f2   f3   f4   f5   f6   f7   f8   f9   f10  f11  f12
  grv     1    2    3    4    5    6    7    8    9    0    -    =   bspc
  tab     q    w    e    r    t    y    u    i    o    p    [    ]   \
  caps    a    s    d    f    g    h    j    k    l    ;    '        ret
  lsft    z    x    c    v    b    n    m    ,    .    /         ▲   rsft
  fn   lctl lalt lmet           spc            rmet ralt     ◀   ▼   ▶
)

;; variables
(defvar
  tap-time 200
  hold-time 200
)

;; aliases
(defalias
  ;; esc when press, lctl when held
  cap (tap-hold $tap-time $hold-time esc lctl)

  ;; Hyper: physical lctl produces ctrl+cmd+option+shift while held.
  ;; Used as a unique global modifier (sketchybar, app shortcuts).
  ;; Note: caps still gives plain Ctrl via @cap above.
  hyper (multi lctl lmet lalt lsft)

  ;; Spotlight via macOS default shortcut Cmd+Space.
  ;; kanata has no built-in spotlight key; mctl/lpad/eject are the only
  ;; macOS-native consumer keys exposed.
  spot (macro M-spc)

  ;; Shell commands
  ;; cmd requires special binary and `danger-enable-cmd` defcfg option
  ;; beep (cmd say "beep")
)

;; logical layer as target
;;
;; F5 (dictation) and F6 (focus mode) are passed through as plain f5/f6
;; because kanata doesn't expose the macOS-internal HID consumer codes
;; for these. Karabiner-Elements DriverKit can send them; kanata's
;; macOS consumer-key support is limited to mctl/lpad/eject + the
;; standard brightness/volume/media keys.
;;
;; TODO: open kanata issues to request:
;;   - dictation key (NX_KEYTYPE_DICTATION) support
;;   - focus / Do Not Disturb key support
;;   https://github.com/jtroo/kanata/issues
(deflayer querty
  brdn brup mctl @spot f5   f6   prev pp   next mute vold volu
  grv     1    2    3    4    5    6    7    8    9    0    -    =   bspc
  tab     q    w    e    r    t    y    u    i    o    p    [    ]   \
  @cap    a    s    d    f    g    h    j    k    l    ;    '        ret
  lsft    z    x    c    v    b    n    m    ,    .    /         ▲   rsft
  fn   @hyper lalt lmet           spc            rmet ralt     ◀   ▼   ▶
)

I think at the very least for today I revert to 1.11

Edit: yes w/ 1.11 my bluetooth keyboard works.

@malpern

malpern commented May 18, 2026

Copy link
Copy Markdown
Contributor Author

This looks like a separate issue from managed-repeat — your Bluetooth keyboard problem is likely related to device grabbing on master, not this PR.

We recently landed macos-continue-if-no-devs-found (#2065) specifically for Bluetooth keyboards. Let's continue the discussion on #1982 where that work is tracked.

once we resolve your bluetooth issues in (#2065), or if you can test with a non-bluetooth keyboard, let us know if you get a clean read on if this PRs managed repeat feature solves your duplicate key presses.

To your question on how to generate load on your mac to see if the repeat issue is fixed, I usually compile a large program from source like KeyPath.

@oschrenk

Copy link
Copy Markdown

Got around to testing this branch

  1. it breaks bluetooth keyboard -> probably needs rebasing
  2. "Solves macOS unintended repeat under load" -> Still happens. My non-scientific test is to run gradle linting on one of my work repos.
Screenshot 2026-05-27 at 11 00 13

tmux. Lower pane running linting while in top pane I try to type git s

@oschrenk

Copy link
Copy Markdown

Anyhting I need to configure? Current config

;; configuration
(defcfg
  ;; if `yes`, process keys that are not defined in defsrc
  ;; useful if you are only mapping a few keys in defsrc
  ;; (for now) i'm setting it to `no`, to learn kanata
  process-unmapped-keys no
)


;; physical hardware as source
;; F-row mapped to media keys directly because macOS's fn-layer (the
;; thing that turns F1 into brightness when fnState=0) only applies to
;; the built-in Apple keyboard. kanata's virtual HID device looks like
;; an external keyboard to macOS, so the fn-layer translation never
;; applies to keys kanata emits. We replicate it manually.
(defsrc
  f1   f2   f3   f4   f5   f6   f7   f8   f9   f10  f11  f12
  grv     1    2    3    4    5    6    7    8    9    0    -    =   bspc
  tab     q    w    e    r    t    y    u    i    o    p    [    ]   \
  caps    a    s    d    f    g    h    j    k    l    ;    '        ret
  lsft    z    x    c    v    b    n    m    ,    .    /         ▲   rsft
  fn   lctl lalt lmet           spc            rmet ralt     ◀   ▼   ▶
)

;; variables
(defvar
  tap-time 200
  hold-time 200
)

;; aliases
(defalias
  ;; esc when press, lctl when held
  cap (tap-hold $tap-time $hold-time esc lctl)

  ;; Hyper: physical lctl produces ctrl+cmd+option+shift while held.
  ;; Used as a unique global modifier (sketchybar, app shortcuts).
  ;; Note: caps still gives plain Ctrl via @cap above.
  hyper (multi lctl lmet lalt lsft)

  ;; Spotlight via macOS default shortcut Cmd+Space.
  ;; kanata has no built-in spotlight key; mctl/lpad/eject are the only
  ;; macOS-native consumer keys exposed.
  spot (macro M-spc)

  ;; Shell commands
  ;; cmd requires special binary and `danger-enable-cmd` defcfg option
  ;; beep (cmd say "beep")
)

;; logical layer as target
;;
;; F5 (dictation) and F6 (focus mode) are passed through as plain f5/f6
;; because kanata doesn't expose the macOS-internal HID consumer codes
;; for these. Karabiner-Elements DriverKit can send them; kanata's
;; macOS consumer-key support is limited to mctl/lpad/eject + the
;; standard brightness/volume/media keys.
;;
;; TODO: open kanata issues to request:
;;   - dictation key (NX_KEYTYPE_DICTATION) support
;;   - focus / Do Not Disturb key support
;;   https://github.com/jtroo/kanata/issues
(deflayer querty
  brdn brup mctl @spot f5   f6   prev pp   next mute vold volu
  grv     1    2    3    4    5    6    7    8    9    0    -    =   bspc
  tab     q    w    e    r    t    y    u    i    o    p    [    ]   \
  @cap    a    s    d    f    g    h    j    k    l    ;    '        ret
  lsft    z    x    c    v    b    n    m    ,    .    /         ▲   rsft
  fn   @hyper lalt lmet           spc            rmet ralt     ◀   ▼   ▶
)

@oschrenk

oschrenk commented May 27, 2026

Copy link
Copy Markdown

Do I need to set

(defcfg
  managed-repeat yes
  managed-repeat-delay 500
  managed-repeat-interval 30
)

?

The description says something about 5ms but the pasted config snippet doesn't make that clear if how that is set

@oschrenk

Copy link
Copy Markdown

Misread the description. Added this config

  ;; managed-repeat: kanata owns key repeat instead of macOS.
  ;; Requires jtroo/kanata PR #2070 (not in upstream 1.11.0).
  ;;
  ;; Why: macOS derives repeat from sustained key presence in the HID
  ;; report. Under CPU load, kanata's tap-hold processing delay can leave
  ;; the previous key held just long enough that macOS fires its own
  ;; autorepeat -- producing `stillllllll` / `aaaaaand` runs. There is no
  ;; macOS API to disable OS-level repeat (allow-hardware-repeat only
  ;; helps on Linux). The only way is to remove the key from the HID
  ;; report before macOS's repeat threshold fires, then synthesize repeat
  ;; ourselves on a controlled timer.
  ;;
  ;; Mechanism: each press enters the HID report for 5ms (hard-coded in
  ;; the PR), then kanata releases it. macOS sees a one-shot tap and
  ;; never repeats. After managed-repeat-delay, kanata sends release+press
  ;; cycles every managed-repeat-interval until the physical key is let
  ;; go. Modifiers are exempt and behave normally.
  managed-repeat yes

  ;; managed-repeat-delay: ms from initial press to first synthetic repeat.
  ;; Analogous to macOS NSGlobalDomain.InitialKeyRepeat (10 ticks = ~150ms,
  ;; current nix-darwin setting in defaults/system/keyboard.nix). Set to
  ;; 150 to match the current native feel.
  managed-repeat-delay 150

  ;; managed-repeat-interval: ms between each synthetic repeat after the
  ;; delay expires. Analogous to macOS NSGlobalDomain.KeyRepeat (3 ticks
  ;; = ~45-50ms, current nix-darwin setting). 50 here keeps the existing
  ;; ~20 char/sec repeat rate.
  managed-repeat-interval 50
)

(and also optional faster key repeat for backspace - I do enjoy to b able to set different key repeats per key).

But I'm sad to say that key-repeats still happen though.

Any other configuration/advice?

@oschrenk

Copy link
Copy Markdown

Should also mention that I am not holding down any key. I'm just normally typing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

macos Issue pertains to macos; jtroo has no macOS devices and does not maintain the support for this OS.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants