Skip to content

Commit 19b40a3

Browse files
authored
feat(hotkeys): repeat a held global hotkey on Linux and Windows (#1621)
1 parent 16a3c90 commit 19b40a3

21 files changed

Lines changed: 698 additions & 81 deletions

docs/CROSS_PLATFORM.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -526,7 +526,7 @@ compositor infer the drag.
526526
| **In-mode capture** | `CGEventTapCreate` | `XGrabKeyboard` | evdev proxy (lifetime `EVIOCGRAB` + uinput re-emit), wl-keyboard fallback | `WH_KEYBOARD_LL` |
527527
| **Global hotkeys** | Per-key CGEventTap | `XGrabKey` | Chord matcher on the evdev proxy | `RegisterHotKey` |
528528
| **CGO needed** | Yes | Yes | Yes | No |
529-
| **Press/release** | ✅ separate callbacks | ✅ KeyPress/KeyRelease | ⚠️ press-only in some configs |`WM_HOTKEY` flags |
529+
| **Press/release** | ✅ separate callbacks | ✅ KeyPress/KeyRelease | ✅ evdev press/release |press, then `GetAsyncKeyState` poll |
530530
| **Modifier passthrough** || ❌ grab is all-or-nothing | ✅ evdev only | ✅ hook forwards per event |
531531
| **`PostModifierEvent`** ||| ✅ (`zwp_virtual_keyboard_v1`) |`SendInput` |
532532
| **Sticky modifiers** |||||
@@ -537,6 +537,23 @@ There is no separate Wayland hotkey file. The Wayland path lives in the common
537537
`hotkeys/linux/manager.go`, which delegates to the evdev listener in the
538538
eventtap package.
539539

540+
**A held global hotkey.** Every manager implements `HotkeyReleaseRegistrar`,
541+
and reports a hold as one press and one release however long it lasts, which is
542+
what `[held_repeat]` repeats between. macOS folds autorepeat in the per-hotkey
543+
tap. The evdev proxy fires the release when the chord's key comes up, whether or
544+
not the modifier is still down by then. X11 asks the server for detectable
545+
autorepeat on both of its connections (`XkbSetDetectableAutoRepeat`), so a held
546+
key is repeated `KeyPress` events and one `KeyRelease` rather than the
547+
release/press pairs that would end the hold at the server's repeat rate; the
548+
in-mode tap needs the same, or the mode's own held repeat stops after its first
549+
tick. `RegisterHotKey` reports the press only, so the Windows registry registers
550+
with `MOD_NOREPEAT` and reads the key through `GetAsyncKeyState` every 10 ms
551+
from the `WM_HOTKEY` until it reads up. The poll runs only while a hotkey is
552+
held; the other source of releases, the `WH_KEYBOARD_LL` hook, sits on every
553+
keystroke for as long as it is installed, and installing it for the daemon's
554+
lifetime would put a hook procedure on the idle path to pay for a release only a
555+
held hotkey needs.
556+
540557
**A global chord while a mode is active.** A `[hotkeys]` binding keeps working
541558
from inside a mode on macOS, Windows and Linux Wayland, and each gets there its
542559
own way, because whichever mechanism can see the chord has to be the only one

internal/adapter/eventtap/linux/evdev_proxy_cgo.go

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,18 @@ type evdevProxy struct {
6666
pointerNode *proxyNode
6767
heldByAnother func(*proxyNode) bool
6868

69-
bindings atomic.Pointer[map[string]func()]
69+
bindings atomic.Pointer[map[string]hotkeyBinding]
70+
71+
// heldHotkeys is the release callback owed for each key whose press
72+
// matched a chord and is still down, by key code. Owned by the run
73+
// goroutine. It is what lets a binding repeat while its key is held: the
74+
// press fires once, at match time, and the release fires when this key
75+
// comes up, whichever consumer the events reached in between.
76+
heldHotkeys map[uint16]func()
77+
78+
// dispatch runs the matched bindings' callbacks, in order, off this
79+
// goroutine.
80+
dispatch *HotkeyDispatcher
7081

7182
control chan proxyCommand
7283
done chan struct{}
@@ -80,6 +91,13 @@ type proxyCommand struct {
8091
ack chan struct{}
8192
}
8293

94+
// hotkeyBinding is what the idle matcher fires for a chord: press when the
95+
// chord's key goes down, release (which may be nil) when that key comes up.
96+
type hotkeyBinding struct {
97+
press func()
98+
release func()
99+
}
100+
83101
var (
84102
sharedProxyMu sync.Mutex
85103
sharedProxy *evdevProxy
@@ -161,11 +179,13 @@ func newEvdevProxy(logger *zap.Logger) (*evdevProxy, error) {
161179
control: make(chan proxyCommand),
162180
done: make(chan struct{}),
163181
heldByAnother: (*proxyNode).heldByAnother,
182+
heldHotkeys: make(map[uint16]func()),
183+
dispatch: NewHotkeyDispatcher(),
164184
}
165185

166186
proxy.forwarding.Store(uinputFd >= 0)
167187

168-
empty := map[string]func(){}
188+
empty := map[string]hotkeyBinding{}
169189
proxy.bindings.Store(&empty)
170190

171191
go proxy.run()
@@ -200,8 +220,8 @@ func (p *evdevProxy) alive() bool {
200220

201221
// setBindings replaces the chords the idle matcher fires on. The map is read
202222
// on the run goroutine without a lock, so it is swapped whole and never edited.
203-
func (p *evdevProxy) setBindings(bindings map[string]func()) {
204-
copied := make(map[string]func(), len(bindings))
223+
func (p *evdevProxy) setBindings(bindings map[string]hotkeyBinding) {
224+
copied := make(map[string]hotkeyBinding, len(bindings))
205225
maps.Copy(copied, bindings)
206226

207227
p.bindings.Store(&copied)
@@ -412,6 +432,7 @@ func (p *evdevProxy) handleKey(event waylandEvdevEvent) {
412432
case evdevValueRelease:
413433
p.capture.feedKey(code, false)
414434
p.trackGlobal(code, false)
435+
p.releaseHotkey(code)
415436

416437
forwarded := p.rule.release(code)
417438
if forwarded {
@@ -439,7 +460,8 @@ func (p *evdevProxy) trackGlobal(code uint16, isDown bool) {
439460
}
440461

441462
// matchHotkey fires the binding for the chord a press completes, if there is
442-
// one, and reports whether it did.
463+
// one, and reports whether it did. A press fires once per hold: the kernel
464+
// reports a held key as repeats, not presses, so none of those reach here.
443465
func (p *evdevProxy) matchHotkey(code uint16) bool {
444466
bindings := *p.bindings.Load()
445467
if len(bindings) == 0 {
@@ -456,18 +478,36 @@ func (p *evdevProxy) matchHotkey(code uint16) bool {
456478
return false
457479
}
458480

459-
callback := bindings[signature]
460-
if callback == nil {
481+
binding, bound := bindings[signature]
482+
if !bound || binding.press == nil {
461483
return false
462484
}
463485

464486
p.logger.Debug("Global hotkey matched", zap.String("chord", signature))
465487

466-
go callback()
488+
if binding.release != nil {
489+
p.heldHotkeys[code] = binding.release
490+
}
491+
492+
p.dispatch.Dispatch(binding.press)
467493

468494
return true
469495
}
470496

497+
// releaseHotkey fires the release owed for a key whose press matched a chord.
498+
// It is keyed by the key alone, not the chord: the binder needs the release
499+
// whether or not the modifier is still down when the key comes up.
500+
func (p *evdevProxy) releaseHotkey(code uint16) {
501+
release, held := p.heldHotkeys[code]
502+
if !held {
503+
return
504+
}
505+
506+
delete(p.heldHotkeys, code)
507+
508+
p.dispatch.Dispatch(release)
509+
}
510+
471511
// emit re-emits one event on the proxy keyboard.
472512
func (p *evdevProxy) emit(event waylandEvdevEvent) {
473513
var raw C.struct_input_event

internal/adapter/eventtap/linux/evdev_proxy_test.go

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,19 @@ func newTestProxy() *evdevProxy {
3636
done: make(chan struct{}),
3737
}
3838

39-
empty := map[string]func(){}
39+
empty := map[string]hotkeyBinding{}
4040
proxy.bindings.Store(&empty)
41+
proxy.heldHotkeys = make(map[uint16]func())
42+
proxy.dispatch = NewHotkeyDispatcher()
4143

4244
return proxy
4345
}
4446

4547
func bindTestChord(proxy *evdevProxy) chan struct{} {
4648
fired := make(chan struct{}, 4)
4749

48-
proxy.setBindings(map[string]func(){
49-
canonicalChordSignature(testChord): func() { fired <- struct{}{} },
50+
proxy.setBindings(map[string]hotkeyBinding{
51+
canonicalChordSignature(testChord): {press: func() { fired <- struct{}{} }},
5052
})
5153

5254
return fired
@@ -189,6 +191,54 @@ func TestForwardRule_IgnoresCodesOutsideTheKeyRange(t *testing.T) {
189191
}
190192
}
191193

194+
// A held chord is one press and one release to the binder, which is what lets
195+
// it repeat the binding for as long as the key is down: the kernel's repeats
196+
// fire nothing, and the release comes when the key comes up, whether or not
197+
// the modifier is still held by then.
198+
func TestEvdevProxy_AHeldChordFiresPressOnceAndReleaseWhenItsKeyComesUp(t *testing.T) {
199+
t.Parallel()
200+
201+
proxy := newTestProxy()
202+
pressed := make(chan struct{}, 4)
203+
released := make(chan struct{}, 4)
204+
205+
proxy.setBindings(map[string]hotkeyBinding{
206+
canonicalChordSignature(testChord): {
207+
press: func() { pressed <- struct{}{} },
208+
release: func() { released <- struct{}{} },
209+
},
210+
})
211+
212+
proxy.handle(keyEvent(evdevKeyLeftMeta, evdevValuePress))
213+
proxy.handle(keyEvent(evdevKeySemicolon, evdevValuePress))
214+
215+
if !waitFired(t, pressed) {
216+
t.Fatal("the chord did not match")
217+
}
218+
219+
proxy.handle(keyEvent(evdevKeySemicolon, evdevValueRepeat))
220+
proxy.handle(keyEvent(evdevKeySemicolon, evdevValueRepeat))
221+
222+
select {
223+
case <-pressed:
224+
t.Fatal(
225+
"a repeat fired the press again; the binder would restart its repeat from the delay",
226+
)
227+
case <-released:
228+
t.Fatal("the release fired while the key was still down")
229+
case <-time.After(50 * time.Millisecond):
230+
}
231+
232+
// The modifier comes up first, so the key's release is no longer the
233+
// chord. The release is owed to the key, not the chord.
234+
proxy.handle(keyEvent(evdevKeyLeftMeta, evdevValueRelease))
235+
proxy.handle(keyEvent(evdevKeySemicolon, evdevValueRelease))
236+
237+
if !waitFired(t, released) {
238+
t.Fatal("the key came up and no release fired; the binder would repeat forever")
239+
}
240+
}
241+
192242
// A matched chord is withheld from the focused app, the way the macOS tap
193243
// consumes it, while the modifier that was already down when it matched stays
194244
// the compositor's and is released to it.
@@ -612,7 +662,7 @@ func TestGlobalHotkeyListener_StopDetachesTheBindings(t *testing.T) {
612662

613663
proxy := newTestProxy()
614664
listener := NewGlobalHotkeyListener(nil)
615-
listener.SetBinding(testChord, func() {})
665+
listener.SetBinding(testChord, func() {}, nil)
616666

617667
listener.mu.Lock()
618668
listener.proxy = proxy

internal/adapter/eventtap/linux/global_hotkey_cgo.go

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ type GlobalHotkeyListener struct {
2020
logger *zap.Logger
2121

2222
mu sync.Mutex
23-
bindings map[string]func()
23+
bindings map[string]hotkeyBinding
2424
proxy *evdevProxy
2525
running bool
2626
}
@@ -34,22 +34,23 @@ func NewGlobalHotkeyListener(logger *zap.Logger) *GlobalHotkeyListener {
3434

3535
return &GlobalHotkeyListener{
3636
logger: logger.Named("hotkeys.evdev"),
37-
bindings: make(map[string]func()),
37+
bindings: make(map[string]hotkeyBinding),
3838
}
3939
}
4040

41-
// SetBinding registers a callback for a chord string (e.g. "Ctrl+Shift+G").
42-
// Safe to call before or after Start.
43-
func (l *GlobalHotkeyListener) SetBinding(chord string, callback func()) {
41+
// SetBinding registers the callbacks for a chord string (e.g. "Ctrl+Shift+G"):
42+
// press when the chord's key goes down, release (nil for none) when it comes
43+
// up. Safe to call before or after Start.
44+
func (l *GlobalHotkeyListener) SetBinding(chord string, press, release func()) {
4445
signature := canonicalChordSignature(chord)
45-
if signature == "" || callback == nil {
46+
if signature == "" || press == nil {
4647
return
4748
}
4849

4950
l.mu.Lock()
5051
defer l.mu.Unlock()
5152

52-
l.bindings[signature] = callback
53+
l.bindings[signature] = hotkeyBinding{press: press, release: release}
5354
l.publishLocked()
5455
}
5556

@@ -58,7 +59,7 @@ func (l *GlobalHotkeyListener) ClearBindings() {
5859
l.mu.Lock()
5960
defer l.mu.Unlock()
6061

61-
l.bindings = make(map[string]func())
62+
l.bindings = make(map[string]hotkeyBinding)
6263
l.publishLocked()
6364
}
6465

internal/adapter/eventtap/linux/global_hotkey_nocgo.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ func NewGlobalHotkeyListener(_ *zap.Logger) *GlobalHotkeyListener {
2121
}
2222

2323
// SetBinding is a no-op without cgo.
24-
func (l *GlobalHotkeyListener) SetBinding(_ string, _ func()) {}
24+
func (l *GlobalHotkeyListener) SetBinding(_ string, _, _ func()) {}
2525

2626
// ClearBindings is a no-op without cgo.
2727
func (l *GlobalHotkeyListener) ClearBindings() {}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
//go:build linux
2+
3+
package linux
4+
5+
import "sync"
6+
7+
// HotkeyDispatcher runs hotkey callbacks off the goroutine that reads key
8+
// events, one at a time, in the order they were queued.
9+
//
10+
// Off that goroutine because a callback takes the mode handler's lock, and the
11+
// handler waits on the reader while holding it (the proxy's session ack, the
12+
// X11 loop's exit), so running it inline could deadlock. In order because a
13+
// goroutine per callback has none: a release and the press that follows it
14+
// could run swapped, and the release would then cancel the repeat the new
15+
// press had just started, leaving a held key that stops repeating.
16+
//
17+
// The queue is unbounded rather than dropping under pressure, because a
18+
// release is the one thing that ends a held key's repeat: dropped, the repeat
19+
// would outlive the key. What queues is two callbacks per hotkey press, so
20+
// even a handler stuck for the length of a hold grows it by a few entries.
21+
type HotkeyDispatcher struct {
22+
mu sync.Mutex
23+
ready *sync.Cond
24+
queue []func()
25+
stopped bool
26+
}
27+
28+
// NewHotkeyDispatcher starts a dispatcher.
29+
func NewHotkeyDispatcher() *HotkeyDispatcher {
30+
dispatcher := &HotkeyDispatcher{}
31+
dispatcher.ready = sync.NewCond(&dispatcher.mu)
32+
33+
go dispatcher.run()
34+
35+
return dispatcher
36+
}
37+
38+
// Dispatch queues callback. It never blocks the reader.
39+
func (d *HotkeyDispatcher) Dispatch(callback func()) {
40+
d.mu.Lock()
41+
defer d.mu.Unlock()
42+
43+
if d.stopped {
44+
return
45+
}
46+
47+
d.queue = append(d.queue, callback)
48+
d.ready.Signal()
49+
}
50+
51+
// Stop ends the dispatcher once it has run what is queued. It does not wait,
52+
// because a queued callback may be waiting on a lock the owner holds.
53+
func (d *HotkeyDispatcher) Stop() {
54+
d.mu.Lock()
55+
defer d.mu.Unlock()
56+
57+
d.stopped = true
58+
d.ready.Signal()
59+
}
60+
61+
func (d *HotkeyDispatcher) run() {
62+
for {
63+
d.mu.Lock()
64+
65+
for len(d.queue) == 0 && !d.stopped {
66+
d.ready.Wait()
67+
}
68+
69+
if len(d.queue) == 0 {
70+
d.mu.Unlock()
71+
72+
return
73+
}
74+
75+
callback := d.queue[0]
76+
d.queue[0] = nil
77+
d.queue = d.queue[1:]
78+
d.mu.Unlock()
79+
80+
callback()
81+
}
82+
}

0 commit comments

Comments
 (0)