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