66 "context"
77 "strings"
88 "sync"
9+ "sync/atomic"
910
1011 "go.uber.org/zap"
1112
@@ -15,6 +16,19 @@ import (
1516 "github.com/y3owk1n/neru/internal/domain/keyvocab"
1617)
1718
19+ // dispatchChBufferSize bounds how many events the hook thread can queue ahead
20+ // of the dispatcher. The Linux tap uses the same figure.
21+ const dispatchChBufferSize = 256
22+
23+ // dispatchEvent is one item on the dispatcher's queue: a key for the handler,
24+ // or a passthrough notification, which rides the same queue so it lands after
25+ // the keys that preceded it.
26+ type dispatchEvent struct {
27+ key string
28+ passthrough tap.PassthroughCallback
29+ epoch uint64
30+ }
31+
1832// EventTap is a keyboard event interceptor on Windows.
1933type EventTap struct {
2034 logger * zap.Logger
@@ -44,6 +58,26 @@ type EventTap struct {
4458 heldModifiers map [string ]struct {}
4559
4660 hook * winplatform.KeyboardHook
61+
62+ // dispatchCh carries events from the hook thread to dispatchLoop, the one
63+ // goroutine that calls the handler. Windows silently removes a
64+ // WH_KEYBOARD_LL hook whose procedure overruns LowLevelHooksTimeout, and
65+ // the handler holds the mode lock across a click, a mode exit or a hint
66+ // refresh, so the hook procedure only classifies the event and enqueues.
67+ // The channel and loop live for the tap's lifetime, like the Linux tap's;
68+ // stopDispatch ends them from Destroy, and the channel is never closed,
69+ // so a key the hook delivers during teardown is dropped rather than sent
70+ // on a closed channel.
71+ dispatchCh chan dispatchEvent
72+ stopDispatch chan struct {}
73+ stopOnce sync.Once
74+ dispatchWg sync.WaitGroup
75+
76+ // dispatchEpoch is stamped on every event as it is queued and bumped on
77+ // every Disable. dispatchLoop drops an event stamped with an earlier
78+ // epoch, so a key the hook read while the mode was exiting is not
79+ // delivered to whatever mode comes next.
80+ dispatchEpoch atomic.Uint64
4781}
4882
4983// NewEventTap creates a new event tap.
@@ -52,10 +86,16 @@ func NewEventTap(callback tap.Callback, logger *zap.Logger) *EventTap {
5286 logger = zap .NewNop ()
5387 }
5488
55- return & EventTap {
56- logger : logger .Named ("eventtap" ),
57- callback : callback ,
89+ eventTap := & EventTap {
90+ logger : logger .Named ("eventtap" ),
91+ callback : callback ,
92+ dispatchCh : make (chan dispatchEvent , dispatchChBufferSize ),
93+ stopDispatch : make (chan struct {}),
5894 }
95+
96+ eventTap .dispatchWg .Go (eventTap .dispatchLoop )
97+
98+ return eventTap
5999}
60100
61101// Enable enables the event tap.
@@ -104,11 +144,33 @@ func (et *EventTap) Disable() {
104144 if hook != nil {
105145 hook .Stop ()
106146 }
147+
148+ // Anything still queued was read by the hook before it stopped and belongs
149+ // to the mode that just exited. The bump covers an event dispatchLoop has
150+ // already taken off the channel; the drain empties the rest. Disable runs
151+ // on the dispatcher itself when a key exits the mode, which is why neither
152+ // step may wait on it.
153+ et .dispatchEpoch .Add (1 )
154+
155+ for {
156+ select {
157+ case <- et .dispatchCh :
158+ default :
159+ return
160+ }
161+ }
107162}
108163
109- // Destroy destroys the event tap.
164+ // Destroy disables the tap and stops its dispatcher, waiting for a key that
165+ // is being delivered to finish. Safe to call more than once.
110166func (et * EventTap ) Destroy () {
111167 et .Disable ()
168+
169+ et .stopOnce .Do (func () {
170+ close (et .stopDispatch )
171+ })
172+
173+ et .dispatchWg .Wait ()
112174}
113175
114176// SetHotkeys sets the hotkeys. These are the global [hotkeys] chords the
@@ -414,27 +476,66 @@ func (et *EventTap) shouldPassthroughChord(chord string) bool {
414476 return true
415477}
416478
417- // firePassthroughCallback invokes the registered passthrough callback (if any)
418- // on its own goroutine and without holding et.mu. The hook procedure runs on
419- // the hook thread, where the mode handler's lock must not be taken: mode exit
420- // stops the hook while holding it (see KeyboardHook.Stop).
479+ // firePassthroughCallback queues the registered passthrough callback (if any)
480+ // for the dispatcher, so it runs off the hook thread and after the keys that
481+ // preceded it.
421482func (et * EventTap ) firePassthroughCallback () {
422483 et .mu .RLock ()
423484 callback := et .passthroughCallback
424485 et .mu .RUnlock ()
425486
426487 if callback != nil {
427- go callback ( )
488+ et . enqueue ( dispatchEvent { passthrough : callback } )
428489 }
429490}
430491
492+ // dispatchKey queues a key for the dispatcher. It never blocks: the hook
493+ // procedure calls it on the hook thread, which has to return before Windows
494+ // gives up on it.
431495func (et * EventTap ) dispatchKey (key string ) {
496+ if key != "" {
497+ et .enqueue (dispatchEvent {key : key })
498+ }
499+ }
500+
501+ func (et * EventTap ) enqueue (event dispatchEvent ) {
502+ event .epoch = et .dispatchEpoch .Load ()
503+
504+ select {
505+ case <- et .stopDispatch :
506+ case et .dispatchCh <- event :
507+ default :
508+ et .logger .Warn ("Dispatch queue full, dropping event" )
509+ }
510+ }
511+
512+ // dispatchLoop delivers queued events to the handler, in order, on this one
513+ // goroutine.
514+ func (et * EventTap ) dispatchLoop () {
515+ for {
516+ select {
517+ case <- et .stopDispatch :
518+ return
519+ case event := <- et .dispatchCh :
520+ et .deliver (event )
521+ }
522+ }
523+ }
524+
525+ func (et * EventTap ) deliver (event dispatchEvent ) {
526+ if et .dispatchEpoch .Load () != event .epoch {
527+ return
528+ }
529+
432530 et .mu .RLock ()
433531 callback := et .callback
434532 et .mu .RUnlock ()
435533
436- if callback != nil && key != "" {
437- callback (key )
534+ switch {
535+ case event .passthrough != nil :
536+ event .passthrough ()
537+ case callback != nil :
538+ callback (event .key )
438539 }
439540}
440541
0 commit comments