1package input
2
3// Manual Testing: Dead Key / Compose Support
4//
5// To verify native dead key support works correctly:
6//
7// 1. Set keyboard layout to "US International with dead keys"
8// 2. Open dumber, navigate to a page with a text input (e.g. Google search)
9// 3. Click into the text input field
10// 4. Type: ' then e -- should produce e with acute accent
11// 5. Type: " then u -- should produce u with diaeresis
12// 6. Type: ` then a -- should produce a with grave accent
13// 7. Type: ~ then n -- should produce n with tilde
14// 8. Verify Ctrl+L still opens omnibox
15// 9. Verify Alt+1-9 still switches tabs
16// 10. Verify modal modes (Ctrl+T for tab mode) still work
17// 11. In omnibox, verify long-press accent picker still works (hold 'e' for 400ms)
18// 12. Verify Escape closes omnibox / exits modal modes
19// 13. Verify session manager overlay captures all keyboard input
20
21import (
22 "context"
23 "sync"
24 "time"
25
26 "github.com/bnema/dumber/internal/domain/entity"
27 "github.com/bnema/dumber/internal/logging"
28 "github.com/bnema/puregotk/v4/gdk"
29 "github.com/bnema/puregotk/v4/glib"
30 "github.com/bnema/puregotk/v4/gtk"
31 "github.com/rs/zerolog"
32)
33
34// ActionHandler is called when a keyboard action is triggered.
35// It receives the context and the action to perform.
36// Return an error if the action fails.
37type ActionHandler func(ctx context.Context, action Action) error
38
39// VimScrollPhase identifies held-scroll lifecycle events. It is internal input
40// state, not a configurable action or shortcut.
41type VimScrollPhase uint8
42
43const (
44 VimScrollContinuous VimScrollPhase = iota
45 VimScrollStop
46)
47
48// VimScrollLifecycleHandler receives autonomous held-scroll ticks and the
49// matching stop event without changing the normal configurable action API.
50type VimScrollLifecycleHandler func(ctx context.Context, action Action, phase VimScrollPhase) error
51
52// PageFocusNavigationHandler handles native-like focus traversal inside the
53// focused page. It returns true when the event was handled by the page.
54type PageFocusNavigationHandler func(ctx context.Context, backward bool) bool
55
56const (
57 vimScrollHoldDelay = 250 * time.Millisecond
58 vimScrollCadence = 16 * time.Millisecond
59)
60
61// AccentHandler handles long-press accent detection.
62// Called for character keys that may have accent variants.
63type AccentHandler interface {
64 // OnKeyPressed is called when a key is pressed.
65 // Returns true if the accent handler is handling this key (picker visible or will show).
66 OnKeyPressed(ctx context.Context, char rune, shiftHeld bool) bool
67 // OnKeyReleased is called when a key is released.
68 OnKeyReleased(ctx context.Context, char rune)
69 // IsPickerVisible returns true if the accent picker is currently visible.
70 IsPickerVisible() bool
71 // Cancel cancels any pending long-press detection.
72 Cancel(ctx context.Context)
73}
74
75// KeyboardHandler processes keyboard events and dispatches actions.
76// It manages modal input modes and routes key events to the appropriate handlers.
77type KeyboardHandler struct {
78 shortcuts *ShortcutSet
79 modal *ModalState
80 workspace *entity.WorkspaceConfig
81 session *entity.SessionConfig
82
83 // Action handler callbacks.
84 onAction ActionHandler
85 onVimScrollLifecycle VimScrollLifecycleHandler
86 onPageFocusNavigation PageFocusNavigationHandler
87 // Optional routing callback that determines how a key should be handled.
88 // Returns RouteHandleShortcuts (default), RoutePassToWidget (let focused
89 // widget handle it), or RouteAccentDetection (long-press accent for GTK entries).
90 routeKey func(KeyContext) KeyRoute
91 // Optional accent handler for dead keys support
92 accentHandler AccentHandler
93 // Optional escape hook for app-level overlays
94 onEscape func(ctx context.Context) bool
95 // Optional hook that blocks Vim mode activation and lets the original key
96 // event pass through to the focused widget/page instead.
97 vimModeActivationPassthrough func() bool
98
99 // GTK controller (nil until attached)
100 controller *gtk.EventControllerKey
101 window *gtk.ApplicationWindow
102
103 // Callback retention: must stay reachable by Go GC.
104 keyPressedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool
105 keyReleasedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType)
106 keyPressedHandlerID uint
107 keyReleasedHandlerID uint
108
109 activePressedActions map[Action]uint
110 ctx context.Context
111 mu sync.RWMutex
112
113 vimScrollRepeatAction Action
114 vimScrollRepeatKeyval uint
115 vimScrollRepeatTimer uint
116 vimScrollRepeatGeneration uint64
117 vimScrollRepeatPressedAt time.Time
118 vimScrollRepeatCb glib.SourceFunc
119 vimScrollRepeatAdd func(intervalMS uint, cb *glib.SourceFunc) uint
120 vimScrollRepeatRemove func(id uint) bool
121 vimScrollNow func() time.Time
122
123 seq vimModeSequenceState
124
125 // pageKeyCapture forwards Vim Mode keys to an in-page interaction (link
126 // hints or visual selection) while it is active.
127 pageKeyCapture PageKeyForwarder
128}
129
130// NewKeyboardHandler creates a new keyboard handler.
131func NewKeyboardHandler(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) *KeyboardHandler {
132 log := logging.FromContext(ctx)
133
134 log.Debug().Msg("creating keyboard handler")
135
136 shortcuts := NewShortcutSet(ctx, workspace, session)
137 seqTimeout := time.Duration(0)
138 if workspace != nil {
139 seqTimeout = time.Duration(workspace.VimMode.SequenceTimeoutMilliseconds) * time.Millisecond
140 }
141 h := &KeyboardHandler{
142 shortcuts: shortcuts,
143 modal: NewModalState(ctx),
144 workspace: workspace,
145 session: session,
146 activePressedActions: make(map[Action]uint),
147 ctx: ctx,
148 vimScrollRepeatAdd: func(intervalMS uint, cb *glib.SourceFunc) uint {
149 return glib.TimeoutAdd(intervalMS, cb, 0)
150 },
151 vimScrollRepeatRemove: glib.SourceRemove,
152 vimScrollNow: time.Now,
153 seq: newVimModeSequenceState(shortcuts.VimModeSequences(), seqTimeout),
154 }
155 h.SetOnModeChange(nil)
156
157 return h
158}
159
160// SetOnAction sets the callback for when actions are triggered.
161func (h *KeyboardHandler) SetOnAction(fn ActionHandler) {
162 h.mu.Lock()
163 defer h.mu.Unlock()
164 h.onAction = fn
165}
166
167// SetOnVimScrollLifecycle sets the callback for autonomous held-scroll ticks
168// and stop notification. Tap actions continue through SetOnAction.
169func (h *KeyboardHandler) SetOnVimScrollLifecycle(fn VimScrollLifecycleHandler) {
170 h.mu.Lock()
171 defer h.mu.Unlock()
172 h.onVimScrollLifecycle = fn
173}
174
175// SetOnPageFocusNavigation sets the optional callback for keeping page focus
176// traversal inside the active WebView.
177func (h *KeyboardHandler) SetOnPageFocusNavigation(fn PageFocusNavigationHandler) {
178 h.mu.Lock()
179 defer h.mu.Unlock()
180 h.onPageFocusNavigation = fn
181}
182
183// SetVimModeActivationPassthrough sets the callback that can block Vim mode
184// activation and let the original key event propagate instead.
185func (h *KeyboardHandler) SetVimModeActivationPassthrough(fn func() bool) {
186 h.mu.Lock()
187 defer h.mu.Unlock()
188 h.vimModeActivationPassthrough = fn
189}
190
191// ReloadShortcuts rebuilds the shortcut set from new config values.
192// This enables hot-reloading of keybindings without restarting.
193func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
194 h.stopVimScrollRepeat()
195 h.mu.Lock()
196
197 log := logging.FromContext(ctx)
198 log.Debug().Msg("reloading shortcuts from config")
199
200 h.shortcuts = NewShortcutSet(ctx, workspace, session)
201 h.workspace = workspace
202 h.session = session
203 seqTimeout := time.Duration(0)
204 if workspace != nil {
205 seqTimeout = time.Duration(workspace.VimMode.SequenceTimeoutMilliseconds) * time.Millisecond
206 }
207 h.mu.Unlock()
208
209 h.InvalidateSequenceMatcher()
210 h.SetSequenceTimeout(seqTimeout)
211}
212
213// SetOnModeChange sets the callback for mode changes (for UI updates).
214func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) {
215 h.modal.SetOnModeChange(func(from, to Mode) {
216 // Switch controller phase: capture during modal, bubble for normal.
217 // This ensures plain modal keys (s, h, etc.) reach the handler
218 // before WebView, while normal typing still goes through WebKit IM.
219 if to == ModeNormal {
220 h.setControllerPhase(gtk.PhaseBubbleValue)
221 } else if from == ModeNormal {
222 h.setControllerPhase(gtk.PhaseCaptureValue)
223 }
224 if to != ModeVim {
225 h.ClearPageKeyCapture()
226 h.stopVimScrollRepeat()
227 // Silent reset under ModalState lock: no pending callback (avoids Mode() re-entry).
228 h.resetPendingSequence(false)
229 }
230 // Forward to app-level callback
231 if fn != nil {
232 fn(from, to)
233 }
234 })
235}
236
237// setControllerPhase changes the propagation phase of the key controller.
238// Capture phase during modal modes so plain keys reach the handler before WebView.
239// Bubble phase in normal mode for WebKit IM/dead-key support.
240//
241// This is called synchronously from onModeChange so the phase switch takes
242// effect before the next key event. The caller must ensure GTK thread safety;
243// modal timeouts dispatch to the GTK main thread via glib.IdleAdd before
244// calling ExitMode, so the onModeChange callback always runs on the GTK thread.
245func (h *KeyboardHandler) setControllerPhase(phase gtk.PropagationPhase) {
246 if h.controller == nil {
247 return
248 }
249 h.controller.SetPropagationPhase(phase)
250}
251
252// SetRouteKey sets the callback that determines how each key event should be routed.
253// The callback receives key context and returns a KeyRoute indicating whether the
254// key should be handled by the shortcut system, passed to the focused widget
255// (for IM/compose support), or routed through accent detection.
256//
257// If not set, all keys are routed through the shortcut system (RouteHandleShortcuts).
258func (h *KeyboardHandler) SetRouteKey(fn func(KeyContext) KeyRoute) {
259 h.mu.Lock()
260 defer h.mu.Unlock()
261 h.routeKey = fn
262}
263
264// SetAccentHandler sets the handler for long-press accent detection.
265func (h *KeyboardHandler) SetAccentHandler(handler AccentHandler) {
266 h.mu.Lock()
267 defer h.mu.Unlock()
268 h.accentHandler = handler
269}
270
271// SetOnEscape sets an optional callback invoked for plain Escape in normal mode.
272// Return true to consume the key and stop further handling.
273func (h *KeyboardHandler) SetOnEscape(fn func(ctx context.Context) bool) {
274 h.mu.Lock()
275 defer h.mu.Unlock()
276 h.onEscape = fn
277}
278
279// Mode returns the current input mode.
280func (h *KeyboardHandler) Mode() Mode {
281 return h.modal.Mode()
282}
283
284// AttachTo attaches the keyboard handler to a GTK window.
285// The handler runs in the bubble phase so that WebKit's IM pipeline (including
286// dead-key / compose sequences) processes key events first. App-level shortcuts
287// are still intercepted because WebKit only consumes events it recognizes as
288// text input; unhandled events bubble up to this controller.
289func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
290 log := logging.FromContext(h.ctx)
291
292 if window == nil {
293 log.Error().Msg("cannot attach keyboard handler to nil window")
294 return
295 }
296
297 h.controller = gtk.NewEventControllerKey()
298 if h.controller == nil {
299 log.Error().Msg("failed to create event controller key")
300 return
301 }
302
303 // Bubble phase: let WebKit IM handle key events first (dead keys, compose
304 // sequences, input methods). Events not consumed by WebKit bubble up here
305 // so app shortcuts still work.
306 h.controller.SetPropagationPhase(gtk.PhaseBubbleValue)
307
308 // Wire GTK main thread schedulers for modal and sequence ambiguity timeouts.
309 // Timer goroutines must dispatch back to the GTK thread because callbacks
310 // may touch GTK widgets / EventController phase.
311 h.installGTKMainThreadSchedulers()
312
313 // Connect key pressed handler (retain callback to prevent GC).
314 // The callback receives: keyval (translated key), keycode (hardware key position), state (modifiers)
315 h.keyPressedCb = func(_ gtk.EventControllerKey, keyval uint, keycode uint, state gdk.ModifierType) bool {
316 return h.handleKeyPress(keyval, keycode, state)
317 }
318 h.keyPressedHandlerID = h.controller.ConnectKeyPressed(&h.keyPressedCb)
319
320 // Connect key released handler for accent detection.
321 h.keyReleasedCb = func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) {
322 h.handleKeyRelease(keyval)
323 }
324 h.keyReleasedHandlerID = h.controller.ConnectKeyReleased(&h.keyReleasedCb)
325
326 // Add controller to window
327 h.window = window
328 window.AddController(&h.controller.EventController)
329
330 log.Debug().Msg("keyboard handler attached to window")
331}
332
333// installGTKMainThreadSchedulers routes modal and sequence timer callbacks
334// through glib.IdleAdd so they run on the GTK main thread.
335func (h *KeyboardHandler) installGTKMainThreadSchedulers() {
336 if h == nil {
337 return
338 }
339 schedule := func(fn func()) {
340 cb := glib.SourceFunc(func(_ uintptr) bool {
341 fn()
342 return false
343 })
344 glib.IdleAdd(&cb, 0)
345 }
346 h.modal.SetMainThreadScheduler(schedule)
347 h.SetSequenceMainThreadScheduler(schedule)
348 logging.FromContext(h.ctx).Debug().Msg("gtk main-thread schedulers installed for modal and vim-mode sequences")
349}
350
351// Detach removes the keyboard handler from a live GTK window and releases its
352// retained GTK signal callbacks.
353func (h *KeyboardHandler) Detach() {
354 h.detach(true)
355}
356
357// DetachForDestroy releases retained GTK signal callbacks during window
358// teardown without mutating the window's controller list while GTK is
359// destroying it.
360func (h *KeyboardHandler) DetachForDestroy() {
361 h.detach(false)
362}
363
364func (h *KeyboardHandler) detach(removeController bool) {
365 h.stopVimScrollRepeat()
366 // Sequence teardown uses seq.mu only; keep it outside h.mu to preserve
367 // the existing h.mu → (never seq) / seq.mu standalone lock order.
368 h.teardownSequenceState()
369 h.mu.Lock()
370 if h.controller != nil && h.keyPressedHandlerID != 0 {
371 h.controller.DisconnectSignal(h.keyPressedHandlerID)
372 }
373 if h.controller != nil && h.keyReleasedHandlerID != 0 {
374 h.controller.DisconnectSignal(h.keyReleasedHandlerID)
375 }
376 if removeController && h.window != nil && h.controller != nil {
377 h.window.RemoveController(&h.controller.EventController)
378 }
379 h.controller = nil
380 h.window = nil
381 h.keyPressedCb = nil
382 h.keyReleasedCb = nil
383 h.keyPressedHandlerID = 0
384 h.keyReleasedHandlerID = 0
385 h.activePressedActions = nil
386 h.mu.Unlock()
387}
388
389// handleKeyPress processes a key press event.
390// Returns true if the event was handled and should not propagate further.
391func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {
392 log := logging.FromContext(h.ctx)
393
394 h.mu.RLock()
395 routeKey := h.routeKey
396 accentHandler := h.accentHandler
397 onEscape := h.onEscape
398 h.mu.RUnlock()
399
400 // Accent picker takes absolute priority when visible -- it has its own key controller
401 if accentHandler != nil && accentHandler.IsPickerVisible() {
402 return false
403 }
404
405 modifiers := Modifier(state) & modifierMask
406 mode := h.modal.Mode()
407
408 // Escape in normal mode: check app-level escape hook first
409 if mode == ModeNormal && keyval == uint(gdk.KEY_Escape) && modifiers == 0 {
410 if onEscape != nil && onEscape(h.ctx) {
411 return true
412 }
413 }
414
415 // An in-page Vim interaction owns every key, including Escape and Enter.
416 if h.forwardPageKey(mode, keyval, state) {
417 return true
418 }
419
420 if h.handleGuaranteedModalExit(mode, keyval, modifiers) {
421 return true
422 }
423
424 // Page focus traversal must stay out of shortcut lookup. The callback keeps
425 // traversal inside CEF when the active page control needs it; otherwise the
426 // event passes through to the native widget.
427 if shouldPassthroughNativePageFocusNavigation(mode, keyval, modifiers) {
428 return h.handlePageFocusNavigation(keyval, modifiers)
429 }
430
431 // Determine routing for this key event
432 route := RouteHandleShortcuts // default: process through shortcut system
433 if routeKey != nil && mode == ModeNormal {
434 route = routeKey(KeyContext{
435 Keyval: keyval,
436 Keycode: keycode,
437 Modifiers: modifiers,
438 })
439 }
440
441 switch route {
442 case RoutePassToWidget:
443 // Let the focused widget handle this key directly (WebView IM, etc.)
444 // No accent detection here -- WebView has its own JS-based accent detection.
445 log.Trace().Uint("keyval", keyval).Msg("routing key to focused widget")
446 return false
447
448 case RouteAccentDetection:
449 // Try long-press accent detection for GTK Entry widgets (omnibox, find bar).
450 // If the accent handler doesn't consume the key, pass it to the widget.
451 if h.tryAccentDetection(accentHandler, keyval, modifiers) {
452 return true
453 }
454 log.Trace().Uint("keyval", keyval).Msg("accent detection declined, routing to widget")
455 return false
456
457 case RouteHandleShortcuts:
458 // Fall through to shortcut processing below
459 }
460
461 // Vim-mode multi-key sequences run before legacy single-chord lookup.
462 if h.feedVimModeSequence(keyval, state) {
463 return true
464 }
465
466 // --- Shortcut processing path ---
467
468 // Normalize uppercase letters for consistent binding lookup
469 keyval = normalizeKeyval(keyval)
470
471 binding := KeyBinding{Keyval: keyval, Modifiers: modifiers}
472 action, found := h.lookupAction(log, binding, mode, modifiers, keycode)
473 return h.handleShortcutLookupResult(log, action, found, mode, keyval, modifiers)
474}
475
476// handleGuaranteedModalExit preserves Escape and Enter as modal fallbacks even
477// when the current configuration omits explicit cancel or confirm bindings.
478func (h *KeyboardHandler) handleGuaranteedModalExit(mode Mode, keyval uint, modifiers Modifier) bool {
479 if mode == ModeNormal || modifiers != 0 {
480 return false
481 }
482
483 switch keyval {
484 case uint(gdk.KEY_Escape):
485 if mode == ModeVim {
486 h.stopVimScrollRepeat()
487 }
488 h.modal.ExitMode(h.ctx)
489 return true
490 case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
491 if mode == ModeVim {
492 h.confirmVimMode()
493 } else {
494 h.modal.ExitMode(h.ctx)
495 }
496 return true
497 default:
498 return false
499 }
500}
501
502func (h *KeyboardHandler) handleShortcutLookupResult(
503 log *zerolog.Logger,
504 action Action,
505 found bool,
506 mode Mode,
507 keyval uint,
508 modifiers Modifier,
509) bool {
510 if !found {
511 if shouldPassthroughNativeVimModeNavigation(mode, keyval, modifiers) {
512 log.Trace().Uint("keyval", keyval).Msg("routing native page navigation key to focused widget in vim mode")
513 return false
514 }
515 if mode == ModePrefix {
516 return h.handleUnboundPrefixKey(keyval, modifiers)
517 }
518 return mode != ModeNormal // Consume unrecognized keys in modal mode
519 }
520 if h.shouldPassthroughVimModeActivation(action, mode) {
521 return false
522 }
523 if mode == ModeVim && isVimScrollAction(action) {
524 return h.handleVimScrollAction(log, action, mode, keyval)
525 }
526 if h.suppressHeldAction(action, keyval) {
527 log.Trace().
528 Str("action", string(action)).
529 Msg("held keyboard action repeat suppressed")
530 return true
531 }
532 return h.dispatchAction(action, mode)
533}
534
535func (h *KeyboardHandler) handleVimScrollAction(log *zerolog.Logger, action Action, mode Mode, keyval uint) bool {
536 h.mu.RLock()
537 sameHeldKey := h.vimScrollRepeatAction == action &&
538 h.vimScrollRepeatKeyval == keyval && h.vimScrollRepeatTimer != 0
539 h.mu.RUnlock()
540 if sameHeldKey {
541 log.Trace().Str("action", string(action)).Msg("vim scroll OS repeat suppressed")
542 return true
543 }
544
545 h.stopVimScrollRepeat()
546 if !h.dispatchAction(action, mode) {
547 return false
548 }
549 h.startVimScrollRepeat(action, keyval)
550 return true
551}
552
553func (h *KeyboardHandler) startVimScrollRepeat(action Action, keyval uint) {
554 h.mu.Lock()
555 if h.vimScrollRepeatTimer != 0 || h.vimScrollRepeatAdd == nil {
556 h.mu.Unlock()
557 return
558 }
559 h.vimScrollRepeatGeneration++
560 generation := h.vimScrollRepeatGeneration
561 h.vimScrollRepeatAction = action
562 h.vimScrollRepeatKeyval = keyval
563 h.vimScrollRepeatPressedAt = h.vimScrollNow()
564 cb := glib.SourceFunc(func(_ uintptr) bool {
565 return h.vimScrollRepeatTick(action, keyval, generation)
566 })
567 h.vimScrollRepeatCb = cb
568 add := h.vimScrollRepeatAdd
569 id := add(uint(vimScrollCadence/time.Millisecond), &h.vimScrollRepeatCb)
570 if id == 0 {
571 h.clearVimScrollRepeatLocked()
572 h.mu.Unlock()
573 return
574 }
575 h.vimScrollRepeatTimer = id
576 h.mu.Unlock()
577}
578
579func (h *KeyboardHandler) vimScrollRepeatTick(action Action, keyval uint, generation uint64) bool {
580 h.mu.RLock()
581 active := h.vimScrollRepeatAction == action && h.vimScrollRepeatKeyval == keyval &&
582 h.vimScrollRepeatTimer != 0 && h.vimScrollRepeatGeneration == generation
583 pressedAt := h.vimScrollRepeatPressedAt
584 now := h.vimScrollNow
585 h.mu.RUnlock()
586 if !active || h.modal.Mode() != ModeVim {
587 return false
588 }
589 if now().Sub(pressedAt) < vimScrollHoldDelay {
590 return true
591 }
592 // Continuous dispatch bypasses dispatchAction, so refresh Vim Mode timeout here.
593 h.modal.ResetTimeout(h.ctx)
594 h.dispatchVimScrollLifecycle(action, VimScrollContinuous)
595 return true
596}
597
598func (h *KeyboardHandler) stopVimScrollRepeat() {
599 h.mu.Lock()
600 action := h.vimScrollRepeatAction
601 timerID := h.vimScrollRepeatTimer
602 remove := h.vimScrollRepeatRemove
603 h.clearVimScrollRepeatLocked()
604 h.mu.Unlock()
605 if timerID != 0 && remove != nil {
606 remove(timerID)
607 }
608 if action != "" {
609 h.dispatchVimScrollLifecycle(action, VimScrollStop)
610 }
611}
612
613func (h *KeyboardHandler) clearVimScrollRepeatLocked() {
614 h.vimScrollRepeatGeneration++
615 h.vimScrollRepeatAction = ""
616 h.vimScrollRepeatKeyval = 0
617 h.vimScrollRepeatTimer = 0
618 h.vimScrollRepeatPressedAt = time.Time{}
619 h.vimScrollRepeatCb = nil
620}
621
622func (h *KeyboardHandler) dispatchVimScrollLifecycle(action Action, phase VimScrollPhase) {
623 h.mu.RLock()
624 handler := h.onVimScrollLifecycle
625 h.mu.RUnlock()
626 if handler == nil {
627 if phase == VimScrollContinuous {
628 h.dispatchAction(action, ModeVim)
629 }
630 return
631 }
632 if err := handler(h.ctx, action, phase); err != nil {
633 logging.FromContext(h.ctx).Error().Err(err).Str("action", string(action)).Msg("vim scroll lifecycle handler error")
634 }
635}
636
637// tryAccentDetection starts long-press detection for accent-eligible keys.
638// It is used only for the RouteAccentDetection route (GTK Entry widgets such as
639// the omnibox and find bar). RoutePassToWidget does not call tryAccentDetection;
640// WebView long-press accent behavior is handled entirely by the injected JS bridge
641// (accentDetectionScript) and the Go-side accent key detection script, not here.
642// Returns true if the key should be suppressed (i.e. RouteAccentDetection consumed it).
643func (h *KeyboardHandler) tryAccentDetection(accentHandler AccentHandler, keyval uint, modifiers Modifier) bool {
644 if accentHandler == nil {
645 return false
646 }
647 // Only consider keys without Ctrl/Alt modifiers (Shift is OK for uppercase)
648 if modifiers != 0 && modifiers != ModShift {
649 return false
650 }
651 if char := KeyvalToRune(keyval); char != 0 {
652 return accentHandler.OnKeyPressed(h.ctx, char, modifiers == ModShift)
653 }
654 return false
655}
656
657// normalizeKeyval converts uppercase A-Z to lowercase for consistent binding lookup.
658func normalizeKeyval(keyval uint) uint {
659 if keyval >= uint('A') && keyval <= uint('Z') {
660 return keyval + (uint('a') - uint('A'))
661 }
662 return keyval
663}
664
665func (h *KeyboardHandler) handlePageFocusNavigation(keyval uint, modifiers Modifier) bool {
666 h.mu.RLock()
667 handler := h.onPageFocusNavigation
668 h.mu.RUnlock()
669 if handler == nil {
670 return false
671 }
672 backward := keyval == uint(gdk.KEY_ISO_Left_Tab) || modifiers == ModShift
673 return handler(h.ctx, backward)
674}
675
676func shouldPassthroughNativePageFocusNavigation(mode Mode, keyval uint, modifiers Modifier) bool {
677 if mode != ModeNormal && mode != ModeVim {
678 return false
679 }
680 switch keyval {
681 case uint(gdk.KEY_Tab), uint(gdk.KEY_ISO_Left_Tab):
682 return modifiers == ModNone || modifiers == ModShift
683 default:
684 return false
685 }
686}
687
688func shouldPassthroughNativeVimModeNavigation(mode Mode, keyval uint, modifiers Modifier) bool {
689 if mode != ModeVim {
690 return false
691 }
692 switch keyval {
693 case uint(gdk.KEY_Left), uint(gdk.KEY_Right), uint(gdk.KEY_Up), uint(gdk.KEY_Down):
694 return modifiers == ModNone
695 case uint(gdk.KEY_Tab), uint(gdk.KEY_ISO_Left_Tab):
696 return modifiers == ModNone || modifiers == ModShift
697 default:
698 return false
699 }
700}
701
702func (h *KeyboardHandler) confirmVimMode() {
703 h.stopVimScrollRepeat()
704 h.seq.mu.Lock()
705 onAction := h.seq.onAction
706 h.seq.mu.Unlock()
707 if onAction != nil {
708 onAction("confirm", 0)
709 }
710 h.modal.ExitMode(h.ctx)
711}
712
713// handleUnboundPrefixKey handles a key with no prefix action. Modifier and
714// dead-key presses (the Shift before '%') keep the prefix waiting. Any other
715// key cancels the prefix, tmux-style; modified keys then continue to their
716// normal handlers (Alt+1 tab switch, page shortcuts), plain keys are consumed.
717func (h *KeyboardHandler) handleUnboundPrefixKey(keyval uint, modifiers Modifier) bool {
718 if isNonCharacterKeysym(keyval) {
719 return true
720 }
721 h.modal.ExitMode(h.ctx)
722 return !IsShortcutModified(modifiers)
723}
724
725// isNonCharacterKeysym reports modifier, ISO lock/latch, and dead keysyms:
726// keys pressed on the way to a character rather than characters themselves.
727func isNonCharacterKeysym(keyval uint) bool {
728 isModifier := keyval >= uint(gdk.KEY_Shift_L) && keyval <= uint(gdk.KEY_Hyper_R)
729 isISOOrDead := keyval >= uint(gdk.KEY_ISO_Lock) && keyval <= uint(gdk.KEY_dead_greek)
730 return isModifier || isISOOrDead
731}
732
733// dispatchAction dispatches the action and handles mode-related logic.
734func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool {
735 if h.handleModeAction(action) {
736 return true
737 }
738
739 h.mu.RLock()
740 handler := h.onAction
741 h.mu.RUnlock()
742
743 if handler != nil {
744 if err := handler(h.ctx, action); err != nil {
745 log := logging.FromContext(h.ctx)
746 log.Error().Err(err).Str("action", string(action)).Msg("action handler error")
747 }
748 }
749
750 if mode == ModeResize && isResizeAction(action) {
751 h.modal.ResetTimeout(h.ctx)
752 }
753 if mode == ModeVim && isVimScrollAction(action) {
754 h.modal.ResetTimeout(h.ctx)
755 }
756
757 // The prefix is one-shot: any action returns to normal mode. Mode-enter
758 // actions already left ModePrefix in handleModeAction.
759 if (mode == ModePrefix && h.modal.Mode() == ModePrefix) ||
760 (mode != ModeNormal && ShouldAutoExitMode(action)) {
761 h.modal.ExitMode(h.ctx)
762 }
763
764 return true // Consumed the key
765}
766
767func (h *KeyboardHandler) suppressHeldAction(action Action, keyval uint) bool {
768 if h == nil || !isRepeatedKeyboardActionSuppressed(action) {
769 return false
770 }
771
772 h.mu.Lock()
773 defer h.mu.Unlock()
774 if h.activePressedActions == nil {
775 return true
776 }
777 if pressedKeyval, ok := h.activePressedActions[action]; ok && pressedKeyval == keyval {
778 return true
779 }
780 h.activePressedActions[action] = keyval
781 return false
782}
783
784func isRepeatedKeyboardActionSuppressed(action Action) bool {
785 if isRepeatedGlobalShortcutSuppressed(action) {
786 return true
787 }
788 switch action {
789 case ActionEnterTabMode,
790 ActionEnterPaneMode,
791 ActionEnterSessionMode,
792 ActionEnterResizeMode,
793 ActionEnterVimMode,
794 ActionEnterPrefixMode,
795 ActionNewTab,
796 ActionRenameTab,
797 ActionSplitRight,
798 ActionSplitLeft,
799 ActionSplitUp,
800 ActionSplitDown,
801 ActionStackPane,
802 ActionMovePaneToTab,
803 ActionMovePaneToNextTab:
804 return true
805 default:
806 return false
807 }
808}
809
810func (h *KeyboardHandler) lookupAction(
811 log *zerolog.Logger,
812 binding KeyBinding,
813 mode Mode,
814 modifiers Modifier,
815 keycode uint,
816) (Action, bool) {
817 action, found := h.shortcuts.Lookup(binding, mode)
818
819 // Fallback: check hardware keycode for Alt+number shortcuts on non-QWERTY layouts.
820 if !found && mode == ModeNormal && modifiers == ModAlt {
821 if keycodeAction, ok := KeycodeToTabAction[keycode]; ok {
822 action = keycodeAction
823 found = true
824 if log != nil {
825 log.Debug().
826 Uint("keycode", keycode).
827 Str("action", string(action)).
828 Msg("tab switch via hardware keycode fallback (non-QWERTY layout)")
829 }
830 }
831 }
832
833 if !found && mode == ModeNormal && (modifiers == ModAlt || modifiers == (ModAlt|ModShift)) {
834 if bracketActions, ok := KeycodeToBracketAction[keycode]; ok {
835 if modifiers == (ModAlt | ModShift) {
836 action = bracketActions.WithShift
837 } else {
838 action = bracketActions.NoShift
839 }
840 found = action != ""
841 if found && log != nil {
842 log.Debug().
843 Uint("keycode", keycode).
844 Str("action", string(action)).
845 Msg("consume/expel via hardware keycode fallback (non-QWERTY layout)")
846 }
847 }
848 }
849
850 return action, found
851}
852
853func isResizeAction(action Action) bool {
854 switch action {
855 case ActionResizeIncreaseLeft,
856 ActionResizeIncreaseRight,
857 ActionResizeIncreaseUp,
858 ActionResizeIncreaseDown,
859 ActionResizeDecreaseLeft,
860 ActionResizeDecreaseRight,
861 ActionResizeDecreaseUp,
862 ActionResizeDecreaseDown,
863 ActionResizeIncrease,
864 ActionResizeDecrease:
865 return true
866 default:
867 return false
868 }
869}
870
871func isVimScrollAction(action Action) bool {
872 switch action {
873 case ActionVimScrollLeft,
874 ActionVimScrollDown,
875 ActionVimScrollUp,
876 ActionVimScrollRight,
877 ActionVimScrollDownFast,
878 ActionVimScrollUpFast:
879 return true
880 default:
881 return false
882 }
883}
884
885func (h *KeyboardHandler) handleVimModeAction(action Action, workspace *entity.WorkspaceConfig) bool {
886 switch action {
887 case ActionEnterVimMode:
888 if h.modal.Mode() == ModeVim {
889 h.stopVimScrollRepeat()
890 h.modal.ExitMode(h.ctx)
891 return true
892 }
893 var timeoutMS int
894 if workspace != nil {
895 timeoutMS = workspace.VimMode.TimeoutMilliseconds
896 }
897 h.modal.EnterVimMode(h.ctx, time.Duration(timeoutMS)*time.Millisecond)
898 return true
899 case ActionVimConfirm:
900 if h.modal.Mode() == ModeVim {
901 h.confirmVimMode()
902 }
903 return true
904 default:
905 return false
906 }
907}
908
909func (h *KeyboardHandler) handleModeAction(action Action) bool {
910 h.mu.RLock()
911 workspace := h.workspace
912 session := h.session
913 h.mu.RUnlock()
914
915 if h.handleVimModeAction(action, workspace) {
916 return true
917 }
918
919 switch action {
920 case ActionEnterPrefixMode:
921 if h.modal.Mode() == ModePrefix {
922 h.modal.ExitMode(h.ctx)
923 return true
924 }
925 var ms int
926 if workspace != nil {
927 ms = workspace.PrefixMode.TimeoutMilliseconds
928 }
929 h.modal.EnterPrefixMode(h.ctx, time.Duration(ms)*time.Millisecond)
930 return true
931 case ActionEnterTabMode:
932 var ms int
933 if workspace != nil {
934 ms = workspace.TabMode.TimeoutMilliseconds
935 }
936 h.modal.EnterTabMode(h.ctx, time.Duration(ms)*time.Millisecond)
937 return true
938 case ActionEnterPaneMode:
939 var ms int
940 if workspace != nil {
941 ms = workspace.PaneMode.TimeoutMilliseconds
942 }
943 h.modal.EnterPaneMode(h.ctx, time.Duration(ms)*time.Millisecond)
944 return true
945 case ActionEnterSessionMode:
946 var ms int
947 if session != nil {
948 ms = session.SessionMode.TimeoutMilliseconds
949 }
950 h.modal.EnterSessionMode(h.ctx, time.Duration(ms)*time.Millisecond)
951 return true
952 case ActionEnterResizeMode:
953 if h.modal.Mode() == ModeResize {
954 h.modal.ExitMode(h.ctx)
955 return true
956 }
957 var ms int
958 if workspace != nil {
959 ms = workspace.ResizeMode.TimeoutMilliseconds
960 }
961 h.modal.EnterResizeMode(h.ctx, time.Duration(ms)*time.Millisecond)
962 return true
963 case ActionExitMode:
964 if h.modal.Mode() == ModeVim {
965 h.stopVimScrollRepeat()
966 }
967 h.modal.ExitMode(h.ctx)
968 return true
969 default:
970 return false
971 }
972}
973
974// EnterTabMode programmatically enters tab mode.
975// Useful for testing or programmatic mode changes.
976func (h *KeyboardHandler) EnterTabMode() {
977 h.mu.RLock()
978 workspace := h.workspace
979 h.mu.RUnlock()
980 var ms int
981 if workspace != nil {
982 ms = workspace.TabMode.TimeoutMilliseconds
983 }
984 h.modal.EnterTabMode(h.ctx, time.Duration(ms)*time.Millisecond)
985}
986
987// EnterPaneMode programmatically enters pane mode.
988// Useful for testing or programmatic mode changes.
989func (h *KeyboardHandler) EnterPaneMode() {
990 h.mu.RLock()
991 workspace := h.workspace
992 h.mu.RUnlock()
993 var ms int
994 if workspace != nil {
995 ms = workspace.PaneMode.TimeoutMilliseconds
996 }
997 h.modal.EnterPaneMode(h.ctx, time.Duration(ms)*time.Millisecond)
998}
999
1000// EnterSessionMode programmatically enters session mode.
1001// Useful for testing or programmatic mode changes.
1002func (h *KeyboardHandler) EnterSessionMode() {
1003 h.mu.RLock()
1004 session := h.session
1005 h.mu.RUnlock()
1006 var ms int
1007 if session != nil {
1008 ms = session.SessionMode.TimeoutMilliseconds
1009 }
1010 h.modal.EnterSessionMode(h.ctx, time.Duration(ms)*time.Millisecond)
1011}
1012
1013// EnterVimMode programmatically enters Vim-style scrolling mode.
1014// Useful for testing or programmatic mode changes.
1015func (h *KeyboardHandler) EnterVimMode() {
1016 h.mu.RLock()
1017 workspace := h.workspace
1018 h.mu.RUnlock()
1019 var ms int
1020 if workspace != nil {
1021 ms = workspace.VimMode.TimeoutMilliseconds
1022 }
1023 h.modal.EnterVimMode(h.ctx, time.Duration(ms)*time.Millisecond)
1024}
1025
1026// ExitMode programmatically exits modal mode.
1027// Useful for testing or programmatic mode changes.
1028func (h *KeyboardHandler) ExitMode() {
1029 h.modal.ExitMode(h.ctx)
1030}
1031
1032func (h *KeyboardHandler) shouldPassthroughVimModeActivation(action Action, mode Mode) bool {
1033 if action != ActionEnterVimMode || mode != ModeNormal {
1034 return false
1035 }
1036 h.mu.RLock()
1037 passthrough := h.vimModeActivationPassthrough
1038 h.mu.RUnlock()
1039 return passthrough != nil && passthrough()
1040}
1041
1042// DispatchAction processes an action externally triggered (e.g., by a global
1043// shortcut). Mode-enter actions update modal state; other actions are forwarded
1044// to the registered action handler. It returns whether the action was consumed.
1045func (h *KeyboardHandler) DispatchAction(action Action) bool {
1046 mode := h.modal.Mode()
1047 if h.shouldPassthroughVimModeActivation(action, mode) {
1048 return false
1049 }
1050 h.dispatchAction(action, mode)
1051 return true
1052}
1053
1054// handleKeyRelease processes a key release event for accent detection.
1055func (h *KeyboardHandler) handleKeyRelease(keyval uint) {
1056 keyval = normalizeKeyval(keyval)
1057 h.mu.Lock()
1058 accentHandler := h.accentHandler
1059 if len(h.activePressedActions) > 0 {
1060 for action, pressedKeyval := range h.activePressedActions {
1061 if pressedKeyval == keyval {
1062 delete(h.activePressedActions, action)
1063 }
1064 }
1065 }
1066 stopVimScroll := h.vimScrollRepeatKeyval == keyval
1067 h.mu.Unlock()
1068 if stopVimScroll {
1069 h.stopVimScrollRepeat()
1070 }
1071
1072 if accentHandler == nil {
1073 return
1074 }
1075
1076 // Convert keyval to rune for the accent handler
1077 if char := KeyvalToRune(keyval); char != 0 {
1078 accentHandler.OnKeyReleased(h.ctx, char)
1079 }
1080}
1081
1082// KeyvalToRune converts a GTK keyval to a lowercase rune.
1083// Returns 0 if the keyval is not a printable character with potential accents.
1084func KeyvalToRune(keyval uint) rune {
1085 // Handle lowercase a-z
1086 if keyval >= uint('a') && keyval <= uint('z') {
1087 return rune(keyval)
1088 }
1089 // Handle uppercase A-Z (convert to lowercase for accent lookup)
1090 if keyval >= uint('A') && keyval <= uint('Z') {
1091 return rune(keyval + ('a' - 'A'))
1092 }
1093 return 0
1094}
1095
1096// KeyRoute determines how a key event should be routed.
1097type KeyRoute int
1098
1099const (
1100 // RouteHandleShortcuts means the key should be processed by the shortcut system.
1101 // This is the default route -- app-level shortcuts, modal mode keys, etc.
1102 RouteHandleShortcuts KeyRoute = iota
1103
1104 // RoutePassToWidget means the key should propagate to the focused widget.
1105 // Used when overlays (session manager, tab picker) are visible, or when a
1106 // WebView should receive text input for native IM/compose/dead-key processing.
1107 // Long-press accent detection still runs first for accessibility: users with
1108 // motor disabilities may not be able to execute rapid compose sequences.
1109 RoutePassToWidget
1110
1111 // RouteAccentDetection means the key should go through long-press accent
1112 // detection, then pass to the focused widget. Used for GTK Entry widgets
1113 // (omnibox, find bar) and as a universal accessibility fallback.
1114 RouteAccentDetection
1115)
1116
1117// KeyContext provides key event information for routing decisions.
1118type KeyContext struct {
1119 Keyval uint // Translated key value
1120 Keycode uint // Hardware keycode
1121 Modifiers Modifier // Active modifiers (masked)
1122}
1123
1124// IsShortcutModified returns true if the modifiers indicate a shortcut combination
1125// (Ctrl or Alt pressed), as opposed to plain text input (no modifier or Shift-only).
1126// Dead keys and compose sequences only use no-modifier or Shift, so this correctly
1127// identifies keys that should be intercepted vs passed through to the IM pipeline.
1128func IsShortcutModified(modifiers Modifier) bool {
1129 return modifiers&(ModCtrl|ModAlt) != 0
1130}
1131
1132// IsTextInputKey returns true if the keyval represents a text input key
1133// (printable character or dead key) as opposed to a function/navigation key.
1134// This is used to determine whether a key should be passed through to the
1135// WebKit IM pipeline for compose/dead-key processing.
1136func IsTextInputKey(keyval uint) bool {
1137 // Dead keys used by compose sequences (US International, etc.)
1138 // GDK dead key range: KEY_dead_grave (0xfe50) to KEY_dead_greek (0xfe8c)
1139 if keyval >= 0xfe50 && keyval <= 0xfe8c {
1140 return true
1141 }
1142
1143 // Printable ASCII range (space through tilde)
1144 if keyval >= 0x020 && keyval <= 0x07e {
1145 return true
1146 }
1147 // Latin-1 supplement (non-breaking space through ÿ)
1148 if keyval >= 0x0a0 && keyval <= 0x0ff {
1149 return true
1150 }
1151 // Extended Latin and other Unicode characters mapped in GDK keyval space
1152 if keyval >= 0x100 && keyval <= 0x20ff {
1153 return true
1154 }
1155
1156 return false
1157}