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 if h.handleGuaranteedModalExit(mode, keyval, modifiers) {
411 return true
412 }
413
414 // Page focus traversal must stay out of shortcut lookup. The callback keeps
415 // traversal inside CEF when the active page control needs it; otherwise the
416 // event passes through to the native widget.
417 if shouldPassthroughNativePageFocusNavigation(mode, keyval, modifiers) {
418 return h.handlePageFocusNavigation(keyval, modifiers)
419 }
420
421 // Determine routing for this key event
422 route := RouteHandleShortcuts // default: process through shortcut system
423 if routeKey != nil && mode == ModeNormal {
424 route = routeKey(KeyContext{
425 Keyval: keyval,
426 Keycode: keycode,
427 Modifiers: modifiers,
428 })
429 }
430
431 switch route {
432 case RoutePassToWidget:
433 // Let the focused widget handle this key directly (WebView IM, etc.)
434 // No accent detection here -- WebView has its own JS-based accent detection.
435 log.Trace().Uint("keyval", keyval).Msg("routing key to focused widget")
436 return false
437
438 case RouteAccentDetection:
439 // Try long-press accent detection for GTK Entry widgets (omnibox, find bar).
440 // If the accent handler doesn't consume the key, pass it to the widget.
441 if h.tryAccentDetection(accentHandler, keyval, modifiers) {
442 return true
443 }
444 log.Trace().Uint("keyval", keyval).Msg("accent detection declined, routing to widget")
445 return false
446
447 case RouteHandleShortcuts:
448 // Fall through to shortcut processing below
449 }
450
451 // Vim-mode multi-key sequences run before legacy single-chord lookup.
452 if h.feedVimModeSequence(keyval, state) {
453 return true
454 }
455
456 // --- Shortcut processing path ---
457
458 // Normalize uppercase letters for consistent binding lookup
459 keyval = normalizeKeyval(keyval)
460
461 binding := KeyBinding{Keyval: keyval, Modifiers: modifiers}
462 action, found := h.lookupAction(log, binding, mode, modifiers, keycode)
463 return h.handleShortcutLookupResult(log, action, found, mode, keyval, modifiers)
464}
465
466// handleGuaranteedModalExit preserves Escape and Enter as modal fallbacks even
467// when the current configuration omits explicit cancel or confirm bindings.
468func (h *KeyboardHandler) handleGuaranteedModalExit(mode Mode, keyval uint, modifiers Modifier) bool {
469 if mode == ModeNormal || modifiers != 0 {
470 return false
471 }
472
473 switch keyval {
474 case uint(gdk.KEY_Escape):
475 if mode == ModeVim {
476 h.stopVimScrollRepeat()
477 }
478 h.modal.ExitMode(h.ctx)
479 return true
480 case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
481 if mode == ModeVim {
482 h.confirmVimMode()
483 } else {
484 h.modal.ExitMode(h.ctx)
485 }
486 return true
487 default:
488 return false
489 }
490}
491
492func (h *KeyboardHandler) handleShortcutLookupResult(
493 log *zerolog.Logger,
494 action Action,
495 found bool,
496 mode Mode,
497 keyval uint,
498 modifiers Modifier,
499) bool {
500 if !found {
501 if shouldPassthroughNativeVimModeNavigation(mode, keyval, modifiers) {
502 log.Trace().Uint("keyval", keyval).Msg("routing native page navigation key to focused widget in vim mode")
503 return false
504 }
505 if mode == ModePrefix {
506 return h.handleUnboundPrefixKey(keyval, modifiers)
507 }
508 return mode != ModeNormal // Consume unrecognized keys in modal mode
509 }
510 if h.shouldPassthroughVimModeActivation(action, mode) {
511 return false
512 }
513 if mode == ModeVim && isVimScrollAction(action) {
514 return h.handleVimScrollAction(log, action, mode, keyval)
515 }
516 if h.suppressHeldAction(action, keyval) {
517 log.Trace().
518 Str("action", string(action)).
519 Msg("held keyboard action repeat suppressed")
520 return true
521 }
522 return h.dispatchAction(action, mode)
523}
524
525func (h *KeyboardHandler) handleVimScrollAction(log *zerolog.Logger, action Action, mode Mode, keyval uint) bool {
526 h.mu.RLock()
527 sameHeldKey := h.vimScrollRepeatAction == action &&
528 h.vimScrollRepeatKeyval == keyval && h.vimScrollRepeatTimer != 0
529 h.mu.RUnlock()
530 if sameHeldKey {
531 log.Trace().Str("action", string(action)).Msg("vim scroll OS repeat suppressed")
532 return true
533 }
534
535 h.stopVimScrollRepeat()
536 if !h.dispatchAction(action, mode) {
537 return false
538 }
539 h.startVimScrollRepeat(action, keyval)
540 return true
541}
542
543func (h *KeyboardHandler) startVimScrollRepeat(action Action, keyval uint) {
544 h.mu.Lock()
545 if h.vimScrollRepeatTimer != 0 || h.vimScrollRepeatAdd == nil {
546 h.mu.Unlock()
547 return
548 }
549 h.vimScrollRepeatGeneration++
550 generation := h.vimScrollRepeatGeneration
551 h.vimScrollRepeatAction = action
552 h.vimScrollRepeatKeyval = keyval
553 h.vimScrollRepeatPressedAt = h.vimScrollNow()
554 cb := glib.SourceFunc(func(_ uintptr) bool {
555 return h.vimScrollRepeatTick(action, keyval, generation)
556 })
557 h.vimScrollRepeatCb = cb
558 add := h.vimScrollRepeatAdd
559 id := add(uint(vimScrollCadence/time.Millisecond), &h.vimScrollRepeatCb)
560 if id == 0 {
561 h.clearVimScrollRepeatLocked()
562 h.mu.Unlock()
563 return
564 }
565 h.vimScrollRepeatTimer = id
566 h.mu.Unlock()
567}
568
569func (h *KeyboardHandler) vimScrollRepeatTick(action Action, keyval uint, generation uint64) bool {
570 h.mu.RLock()
571 active := h.vimScrollRepeatAction == action && h.vimScrollRepeatKeyval == keyval &&
572 h.vimScrollRepeatTimer != 0 && h.vimScrollRepeatGeneration == generation
573 pressedAt := h.vimScrollRepeatPressedAt
574 now := h.vimScrollNow
575 h.mu.RUnlock()
576 if !active || h.modal.Mode() != ModeVim {
577 return false
578 }
579 if now().Sub(pressedAt) < vimScrollHoldDelay {
580 return true
581 }
582 // Continuous dispatch bypasses dispatchAction, so refresh Vim Mode timeout here.
583 h.modal.ResetTimeout(h.ctx)
584 h.dispatchVimScrollLifecycle(action, VimScrollContinuous)
585 return true
586}
587
588func (h *KeyboardHandler) stopVimScrollRepeat() {
589 h.mu.Lock()
590 action := h.vimScrollRepeatAction
591 timerID := h.vimScrollRepeatTimer
592 remove := h.vimScrollRepeatRemove
593 h.clearVimScrollRepeatLocked()
594 h.mu.Unlock()
595 if timerID != 0 && remove != nil {
596 remove(timerID)
597 }
598 if action != "" {
599 h.dispatchVimScrollLifecycle(action, VimScrollStop)
600 }
601}
602
603func (h *KeyboardHandler) clearVimScrollRepeatLocked() {
604 h.vimScrollRepeatGeneration++
605 h.vimScrollRepeatAction = ""
606 h.vimScrollRepeatKeyval = 0
607 h.vimScrollRepeatTimer = 0
608 h.vimScrollRepeatPressedAt = time.Time{}
609 h.vimScrollRepeatCb = nil
610}
611
612func (h *KeyboardHandler) dispatchVimScrollLifecycle(action Action, phase VimScrollPhase) {
613 h.mu.RLock()
614 handler := h.onVimScrollLifecycle
615 h.mu.RUnlock()
616 if handler == nil {
617 if phase == VimScrollContinuous {
618 h.dispatchAction(action, ModeVim)
619 }
620 return
621 }
622 if err := handler(h.ctx, action, phase); err != nil {
623 logging.FromContext(h.ctx).Error().Err(err).Str("action", string(action)).Msg("vim scroll lifecycle handler error")
624 }
625}
626
627// tryAccentDetection starts long-press detection for accent-eligible keys.
628// It is used only for the RouteAccentDetection route (GTK Entry widgets such as
629// the omnibox and find bar). RoutePassToWidget does not call tryAccentDetection;
630// WebView long-press accent behavior is handled entirely by the injected JS bridge
631// (accentDetectionScript) and the Go-side accent key detection script, not here.
632// Returns true if the key should be suppressed (i.e. RouteAccentDetection consumed it).
633func (h *KeyboardHandler) tryAccentDetection(accentHandler AccentHandler, keyval uint, modifiers Modifier) bool {
634 if accentHandler == nil {
635 return false
636 }
637 // Only consider keys without Ctrl/Alt modifiers (Shift is OK for uppercase)
638 if modifiers != 0 && modifiers != ModShift {
639 return false
640 }
641 if char := KeyvalToRune(keyval); char != 0 {
642 return accentHandler.OnKeyPressed(h.ctx, char, modifiers == ModShift)
643 }
644 return false
645}
646
647// normalizeKeyval converts uppercase A-Z to lowercase for consistent binding lookup.
648func normalizeKeyval(keyval uint) uint {
649 if keyval >= uint('A') && keyval <= uint('Z') {
650 return keyval + (uint('a') - uint('A'))
651 }
652 return keyval
653}
654
655func (h *KeyboardHandler) handlePageFocusNavigation(keyval uint, modifiers Modifier) bool {
656 h.mu.RLock()
657 handler := h.onPageFocusNavigation
658 h.mu.RUnlock()
659 if handler == nil {
660 return false
661 }
662 backward := keyval == uint(gdk.KEY_ISO_Left_Tab) || modifiers == ModShift
663 return handler(h.ctx, backward)
664}
665
666func shouldPassthroughNativePageFocusNavigation(mode Mode, keyval uint, modifiers Modifier) bool {
667 if mode != ModeNormal && mode != ModeVim {
668 return false
669 }
670 switch keyval {
671 case uint(gdk.KEY_Tab), uint(gdk.KEY_ISO_Left_Tab):
672 return modifiers == ModNone || modifiers == ModShift
673 default:
674 return false
675 }
676}
677
678func shouldPassthroughNativeVimModeNavigation(mode Mode, keyval uint, modifiers Modifier) bool {
679 if mode != ModeVim {
680 return false
681 }
682 switch keyval {
683 case uint(gdk.KEY_Left), uint(gdk.KEY_Right), uint(gdk.KEY_Up), uint(gdk.KEY_Down):
684 return modifiers == ModNone
685 case uint(gdk.KEY_Tab), uint(gdk.KEY_ISO_Left_Tab):
686 return modifiers == ModNone || modifiers == ModShift
687 default:
688 return false
689 }
690}
691
692func (h *KeyboardHandler) confirmVimMode() {
693 h.stopVimScrollRepeat()
694 h.seq.mu.Lock()
695 onAction := h.seq.onAction
696 h.seq.mu.Unlock()
697 if onAction != nil {
698 onAction("confirm", 0)
699 }
700 h.modal.ExitMode(h.ctx)
701}
702
703// handleUnboundPrefixKey handles a key with no prefix action. Modifier and
704// dead-key presses (the Shift before '%') keep the prefix waiting. Any other
705// key cancels the prefix, tmux-style; modified keys then continue to their
706// normal handlers (Alt+1 tab switch, page shortcuts), plain keys are consumed.
707func (h *KeyboardHandler) handleUnboundPrefixKey(keyval uint, modifiers Modifier) bool {
708 if isNonCharacterKeysym(keyval) {
709 return true
710 }
711 h.modal.ExitMode(h.ctx)
712 return !IsShortcutModified(modifiers)
713}
714
715// isNonCharacterKeysym reports modifier, ISO lock/latch, and dead keysyms:
716// keys pressed on the way to a character rather than characters themselves.
717func isNonCharacterKeysym(keyval uint) bool {
718 isModifier := keyval >= uint(gdk.KEY_Shift_L) && keyval <= uint(gdk.KEY_Hyper_R)
719 isISOOrDead := keyval >= uint(gdk.KEY_ISO_Lock) && keyval <= uint(gdk.KEY_dead_greek)
720 return isModifier || isISOOrDead
721}
722
723// dispatchAction dispatches the action and handles mode-related logic.
724func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool {
725 if h.handleModeAction(action) {
726 return true
727 }
728
729 h.mu.RLock()
730 handler := h.onAction
731 h.mu.RUnlock()
732
733 if handler != nil {
734 if err := handler(h.ctx, action); err != nil {
735 log := logging.FromContext(h.ctx)
736 log.Error().Err(err).Str("action", string(action)).Msg("action handler error")
737 }
738 }
739
740 if mode == ModeResize && isResizeAction(action) {
741 h.modal.ResetTimeout(h.ctx)
742 }
743 if mode == ModeVim && isVimScrollAction(action) {
744 h.modal.ResetTimeout(h.ctx)
745 }
746
747 // The prefix is one-shot: any action returns to normal mode. Mode-enter
748 // actions already left ModePrefix in handleModeAction.
749 if (mode == ModePrefix && h.modal.Mode() == ModePrefix) ||
750 (mode != ModeNormal && ShouldAutoExitMode(action)) {
751 h.modal.ExitMode(h.ctx)
752 }
753
754 return true // Consumed the key
755}
756
757func (h *KeyboardHandler) suppressHeldAction(action Action, keyval uint) bool {
758 if h == nil || !isRepeatedKeyboardActionSuppressed(action) {
759 return false
760 }
761
762 h.mu.Lock()
763 defer h.mu.Unlock()
764 if h.activePressedActions == nil {
765 return true
766 }
767 if pressedKeyval, ok := h.activePressedActions[action]; ok && pressedKeyval == keyval {
768 return true
769 }
770 h.activePressedActions[action] = keyval
771 return false
772}
773
774func isRepeatedKeyboardActionSuppressed(action Action) bool {
775 if isRepeatedGlobalShortcutSuppressed(action) {
776 return true
777 }
778 switch action {
779 case ActionEnterTabMode,
780 ActionEnterPaneMode,
781 ActionEnterSessionMode,
782 ActionEnterResizeMode,
783 ActionEnterVimMode,
784 ActionEnterPrefixMode,
785 ActionNewTab,
786 ActionRenameTab,
787 ActionSplitRight,
788 ActionSplitLeft,
789 ActionSplitUp,
790 ActionSplitDown,
791 ActionStackPane,
792 ActionMovePaneToTab,
793 ActionMovePaneToNextTab:
794 return true
795 default:
796 return false
797 }
798}
799
800func (h *KeyboardHandler) lookupAction(
801 log *zerolog.Logger,
802 binding KeyBinding,
803 mode Mode,
804 modifiers Modifier,
805 keycode uint,
806) (Action, bool) {
807 action, found := h.shortcuts.Lookup(binding, mode)
808
809 // Fallback: check hardware keycode for Alt+number shortcuts on non-QWERTY layouts.
810 if !found && mode == ModeNormal && modifiers == ModAlt {
811 if keycodeAction, ok := KeycodeToTabAction[keycode]; ok {
812 action = keycodeAction
813 found = true
814 if log != nil {
815 log.Debug().
816 Uint("keycode", keycode).
817 Str("action", string(action)).
818 Msg("tab switch via hardware keycode fallback (non-QWERTY layout)")
819 }
820 }
821 }
822
823 if !found && mode == ModeNormal && (modifiers == ModAlt || modifiers == (ModAlt|ModShift)) {
824 if bracketActions, ok := KeycodeToBracketAction[keycode]; ok {
825 if modifiers == (ModAlt | ModShift) {
826 action = bracketActions.WithShift
827 } else {
828 action = bracketActions.NoShift
829 }
830 found = action != ""
831 if found && log != nil {
832 log.Debug().
833 Uint("keycode", keycode).
834 Str("action", string(action)).
835 Msg("consume/expel via hardware keycode fallback (non-QWERTY layout)")
836 }
837 }
838 }
839
840 return action, found
841}
842
843func isResizeAction(action Action) bool {
844 switch action {
845 case ActionResizeIncreaseLeft,
846 ActionResizeIncreaseRight,
847 ActionResizeIncreaseUp,
848 ActionResizeIncreaseDown,
849 ActionResizeDecreaseLeft,
850 ActionResizeDecreaseRight,
851 ActionResizeDecreaseUp,
852 ActionResizeDecreaseDown,
853 ActionResizeIncrease,
854 ActionResizeDecrease:
855 return true
856 default:
857 return false
858 }
859}
860
861func isVimScrollAction(action Action) bool {
862 switch action {
863 case ActionVimScrollLeft,
864 ActionVimScrollDown,
865 ActionVimScrollUp,
866 ActionVimScrollRight,
867 ActionVimScrollDownFast,
868 ActionVimScrollUpFast:
869 return true
870 default:
871 return false
872 }
873}
874
875func (h *KeyboardHandler) handleVimModeAction(action Action, workspace *entity.WorkspaceConfig) bool {
876 switch action {
877 case ActionEnterVimMode:
878 if h.modal.Mode() == ModeVim {
879 h.stopVimScrollRepeat()
880 h.modal.ExitMode(h.ctx)
881 return true
882 }
883 var timeoutMS int
884 if workspace != nil {
885 timeoutMS = workspace.VimMode.TimeoutMilliseconds
886 }
887 h.modal.EnterVimMode(h.ctx, time.Duration(timeoutMS)*time.Millisecond)
888 return true
889 case ActionVimConfirm:
890 if h.modal.Mode() == ModeVim {
891 h.confirmVimMode()
892 }
893 return true
894 default:
895 return false
896 }
897}
898
899func (h *KeyboardHandler) handleModeAction(action Action) bool {
900 h.mu.RLock()
901 workspace := h.workspace
902 session := h.session
903 h.mu.RUnlock()
904
905 if h.handleVimModeAction(action, workspace) {
906 return true
907 }
908
909 switch action {
910 case ActionEnterPrefixMode:
911 if h.modal.Mode() == ModePrefix {
912 h.modal.ExitMode(h.ctx)
913 return true
914 }
915 var ms int
916 if workspace != nil {
917 ms = workspace.PrefixMode.TimeoutMilliseconds
918 }
919 h.modal.EnterPrefixMode(h.ctx, time.Duration(ms)*time.Millisecond)
920 return true
921 case ActionEnterTabMode:
922 var ms int
923 if workspace != nil {
924 ms = workspace.TabMode.TimeoutMilliseconds
925 }
926 h.modal.EnterTabMode(h.ctx, time.Duration(ms)*time.Millisecond)
927 return true
928 case ActionEnterPaneMode:
929 var ms int
930 if workspace != nil {
931 ms = workspace.PaneMode.TimeoutMilliseconds
932 }
933 h.modal.EnterPaneMode(h.ctx, time.Duration(ms)*time.Millisecond)
934 return true
935 case ActionEnterSessionMode:
936 var ms int
937 if session != nil {
938 ms = session.SessionMode.TimeoutMilliseconds
939 }
940 h.modal.EnterSessionMode(h.ctx, time.Duration(ms)*time.Millisecond)
941 return true
942 case ActionEnterResizeMode:
943 if h.modal.Mode() == ModeResize {
944 h.modal.ExitMode(h.ctx)
945 return true
946 }
947 var ms int
948 if workspace != nil {
949 ms = workspace.ResizeMode.TimeoutMilliseconds
950 }
951 h.modal.EnterResizeMode(h.ctx, time.Duration(ms)*time.Millisecond)
952 return true
953 case ActionExitMode:
954 if h.modal.Mode() == ModeVim {
955 h.stopVimScrollRepeat()
956 }
957 h.modal.ExitMode(h.ctx)
958 return true
959 default:
960 return false
961 }
962}
963
964// EnterTabMode programmatically enters tab mode.
965// Useful for testing or programmatic mode changes.
966func (h *KeyboardHandler) EnterTabMode() {
967 h.mu.RLock()
968 workspace := h.workspace
969 h.mu.RUnlock()
970 var ms int
971 if workspace != nil {
972 ms = workspace.TabMode.TimeoutMilliseconds
973 }
974 h.modal.EnterTabMode(h.ctx, time.Duration(ms)*time.Millisecond)
975}
976
977// EnterPaneMode programmatically enters pane mode.
978// Useful for testing or programmatic mode changes.
979func (h *KeyboardHandler) EnterPaneMode() {
980 h.mu.RLock()
981 workspace := h.workspace
982 h.mu.RUnlock()
983 var ms int
984 if workspace != nil {
985 ms = workspace.PaneMode.TimeoutMilliseconds
986 }
987 h.modal.EnterPaneMode(h.ctx, time.Duration(ms)*time.Millisecond)
988}
989
990// EnterSessionMode programmatically enters session mode.
991// Useful for testing or programmatic mode changes.
992func (h *KeyboardHandler) EnterSessionMode() {
993 h.mu.RLock()
994 session := h.session
995 h.mu.RUnlock()
996 var ms int
997 if session != nil {
998 ms = session.SessionMode.TimeoutMilliseconds
999 }
1000 h.modal.EnterSessionMode(h.ctx, time.Duration(ms)*time.Millisecond)
1001}
1002
1003// EnterVimMode programmatically enters Vim-style scrolling mode.
1004// Useful for testing or programmatic mode changes.
1005func (h *KeyboardHandler) EnterVimMode() {
1006 h.mu.RLock()
1007 workspace := h.workspace
1008 h.mu.RUnlock()
1009 var ms int
1010 if workspace != nil {
1011 ms = workspace.VimMode.TimeoutMilliseconds
1012 }
1013 h.modal.EnterVimMode(h.ctx, time.Duration(ms)*time.Millisecond)
1014}
1015
1016// ExitMode programmatically exits modal mode.
1017// Useful for testing or programmatic mode changes.
1018func (h *KeyboardHandler) ExitMode() {
1019 h.modal.ExitMode(h.ctx)
1020}
1021
1022func (h *KeyboardHandler) shouldPassthroughVimModeActivation(action Action, mode Mode) bool {
1023 if action != ActionEnterVimMode || mode != ModeNormal {
1024 return false
1025 }
1026 h.mu.RLock()
1027 passthrough := h.vimModeActivationPassthrough
1028 h.mu.RUnlock()
1029 return passthrough != nil && passthrough()
1030}
1031
1032// DispatchAction processes an action externally triggered (e.g., by a global
1033// shortcut). Mode-enter actions update modal state; other actions are forwarded
1034// to the registered action handler. It returns whether the action was consumed.
1035func (h *KeyboardHandler) DispatchAction(action Action) bool {
1036 mode := h.modal.Mode()
1037 if h.shouldPassthroughVimModeActivation(action, mode) {
1038 return false
1039 }
1040 h.dispatchAction(action, mode)
1041 return true
1042}
1043
1044// handleKeyRelease processes a key release event for accent detection.
1045func (h *KeyboardHandler) handleKeyRelease(keyval uint) {
1046 keyval = normalizeKeyval(keyval)
1047 h.mu.Lock()
1048 accentHandler := h.accentHandler
1049 if len(h.activePressedActions) > 0 {
1050 for action, pressedKeyval := range h.activePressedActions {
1051 if pressedKeyval == keyval {
1052 delete(h.activePressedActions, action)
1053 }
1054 }
1055 }
1056 stopVimScroll := h.vimScrollRepeatKeyval == keyval
1057 h.mu.Unlock()
1058 if stopVimScroll {
1059 h.stopVimScrollRepeat()
1060 }
1061
1062 if accentHandler == nil {
1063 return
1064 }
1065
1066 // Convert keyval to rune for the accent handler
1067 if char := KeyvalToRune(keyval); char != 0 {
1068 accentHandler.OnKeyReleased(h.ctx, char)
1069 }
1070}
1071
1072// KeyvalToRune converts a GTK keyval to a lowercase rune.
1073// Returns 0 if the keyval is not a printable character with potential accents.
1074func KeyvalToRune(keyval uint) rune {
1075 // Handle lowercase a-z
1076 if keyval >= uint('a') && keyval <= uint('z') {
1077 return rune(keyval)
1078 }
1079 // Handle uppercase A-Z (convert to lowercase for accent lookup)
1080 if keyval >= uint('A') && keyval <= uint('Z') {
1081 return rune(keyval + ('a' - 'A'))
1082 }
1083 return 0
1084}
1085
1086// KeyRoute determines how a key event should be routed.
1087type KeyRoute int
1088
1089const (
1090 // RouteHandleShortcuts means the key should be processed by the shortcut system.
1091 // This is the default route -- app-level shortcuts, modal mode keys, etc.
1092 RouteHandleShortcuts KeyRoute = iota
1093
1094 // RoutePassToWidget means the key should propagate to the focused widget.
1095 // Used when overlays (session manager, tab picker) are visible, or when a
1096 // WebView should receive text input for native IM/compose/dead-key processing.
1097 // Long-press accent detection still runs first for accessibility: users with
1098 // motor disabilities may not be able to execute rapid compose sequences.
1099 RoutePassToWidget
1100
1101 // RouteAccentDetection means the key should go through long-press accent
1102 // detection, then pass to the focused widget. Used for GTK Entry widgets
1103 // (omnibox, find bar) and as a universal accessibility fallback.
1104 RouteAccentDetection
1105)
1106
1107// KeyContext provides key event information for routing decisions.
1108type KeyContext struct {
1109 Keyval uint // Translated key value
1110 Keycode uint // Hardware keycode
1111 Modifiers Modifier // Active modifiers (masked)
1112}
1113
1114// IsShortcutModified returns true if the modifiers indicate a shortcut combination
1115// (Ctrl or Alt pressed), as opposed to plain text input (no modifier or Shift-only).
1116// Dead keys and compose sequences only use no-modifier or Shift, so this correctly
1117// identifies keys that should be intercepted vs passed through to the IM pipeline.
1118func IsShortcutModified(modifiers Modifier) bool {
1119 return modifiers&(ModCtrl|ModAlt) != 0
1120}
1121
1122// IsTextInputKey returns true if the keyval represents a text input key
1123// (printable character or dead key) as opposed to a function/navigation key.
1124// This is used to determine whether a key should be passed through to the
1125// WebKit IM pipeline for compose/dead-key processing.
1126func IsTextInputKey(keyval uint) bool {
1127 // Dead keys used by compose sequences (US International, etc.)
1128 // GDK dead key range: KEY_dead_grave (0xfe50) to KEY_dead_greek (0xfe8c)
1129 if keyval >= 0xfe50 && keyval <= 0xfe8c {
1130 return true
1131 }
1132
1133 // Printable ASCII range (space through tilde)
1134 if keyval >= 0x020 && keyval <= 0x07e {
1135 return true
1136 }
1137 // Latin-1 supplement (non-breaking space through ÿ)
1138 if keyval >= 0x0a0 && keyval <= 0x0ff {
1139 return true
1140 }
1141 // Extended Latin and other Unicode characters mapped in GDK keyval space
1142 if keyval >= 0x100 && keyval <= 0x20ff {
1143 return true
1144 }
1145
1146 return false
1147}