package input // Manual Testing: Dead Key / Compose Support // // To verify native dead key support works correctly: // // 1. Set keyboard layout to "US International with dead keys" // 2. Open dumber, navigate to a page with a text input (e.g. Google search) // 3. Click into the text input field // 4. Type: ' then e -- should produce e with acute accent // 5. Type: " then u -- should produce u with diaeresis // 6. Type: ` then a -- should produce a with grave accent // 7. Type: ~ then n -- should produce n with tilde // 8. Verify Ctrl+L still opens omnibox // 9. Verify Alt+1-9 still switches tabs // 10. Verify modal modes (Ctrl+T for tab mode) still work // 11. In omnibox, verify long-press accent picker still works (hold 'e' for 400ms) // 12. Verify Escape closes omnibox / exits modal modes // 13. Verify session manager overlay captures all keyboard input import ( "context" "sync" "time" "github.com/bnema/dumber/internal/domain/entity" "github.com/bnema/dumber/internal/logging" "github.com/bnema/puregotk/v4/gdk" "github.com/bnema/puregotk/v4/glib" "github.com/bnema/puregotk/v4/gtk" "github.com/rs/zerolog" ) // ActionHandler is called when a keyboard action is triggered. // It receives the context and the action to perform. // Return an error if the action fails. type ActionHandler func(ctx context.Context, action Action) error // VimScrollPhase identifies held-scroll lifecycle events. It is internal input // state, not a configurable action or shortcut. type VimScrollPhase uint8 const ( VimScrollContinuous VimScrollPhase = iota VimScrollStop ) // VimScrollLifecycleHandler receives autonomous held-scroll ticks and the // matching stop event without changing the normal configurable action API. type VimScrollLifecycleHandler func(ctx context.Context, action Action, phase VimScrollPhase) error // PageFocusNavigationHandler handles native-like focus traversal inside the // focused page. It returns true when the event was handled by the page. type PageFocusNavigationHandler func(ctx context.Context, backward bool) bool const ( vimScrollHoldDelay = 250 * time.Millisecond vimScrollCadence = 16 * time.Millisecond ) // AccentHandler handles long-press accent detection. // Called for character keys that may have accent variants. type AccentHandler interface { // OnKeyPressed is called when a key is pressed. // Returns true if the accent handler is handling this key (picker visible or will show). OnKeyPressed(ctx context.Context, char rune, shiftHeld bool) bool // OnKeyReleased is called when a key is released. OnKeyReleased(ctx context.Context, char rune) // IsPickerVisible returns true if the accent picker is currently visible. IsPickerVisible() bool // Cancel cancels any pending long-press detection. Cancel(ctx context.Context) } // KeyboardHandler processes keyboard events and dispatches actions. // It manages modal input modes and routes key events to the appropriate handlers. type KeyboardHandler struct { shortcuts *ShortcutSet modal *ModalState workspace *entity.WorkspaceConfig session *entity.SessionConfig // Action handler callbacks. onAction ActionHandler onVimScrollLifecycle VimScrollLifecycleHandler onPageFocusNavigation PageFocusNavigationHandler // Optional routing callback that determines how a key should be handled. // Returns RouteHandleShortcuts (default), RoutePassToWidget (let focused // widget handle it), or RouteAccentDetection (long-press accent for GTK entries). routeKey func(KeyContext) KeyRoute // Optional accent handler for dead keys support accentHandler AccentHandler // Optional escape hook for app-level overlays onEscape func(ctx context.Context) bool // Optional hook that blocks Vim mode activation and lets the original key // event pass through to the focused widget/page instead. vimModeActivationPassthrough func() bool // GTK controller (nil until attached) controller *gtk.EventControllerKey window *gtk.ApplicationWindow // Callback retention: must stay reachable by Go GC. keyPressedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool keyReleasedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) keyPressedHandlerID uint keyReleasedHandlerID uint activePressedActions map[Action]uint ctx context.Context mu sync.RWMutex vimScrollRepeatAction Action vimScrollRepeatKeyval uint vimScrollRepeatTimer uint vimScrollRepeatGeneration uint64 vimScrollRepeatPressedAt time.Time vimScrollRepeatCb glib.SourceFunc vimScrollRepeatAdd func(intervalMS uint, cb *glib.SourceFunc) uint vimScrollRepeatRemove func(id uint) bool vimScrollNow func() time.Time seq vimModeSequenceState // pageKeyCapture forwards Vim Mode keys to an in-page interaction (link // hints or visual selection) while it is active. pageKeyCapture PageKeyForwarder } // NewKeyboardHandler creates a new keyboard handler. func NewKeyboardHandler(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) *KeyboardHandler { log := logging.FromContext(ctx) log.Debug().Msg("creating keyboard handler") shortcuts := NewShortcutSet(ctx, workspace, session) seqTimeout := time.Duration(0) if workspace != nil { seqTimeout = time.Duration(workspace.VimMode.SequenceTimeoutMilliseconds) * time.Millisecond } h := &KeyboardHandler{ shortcuts: shortcuts, modal: NewModalState(ctx), workspace: workspace, session: session, activePressedActions: make(map[Action]uint), ctx: ctx, vimScrollRepeatAdd: func(intervalMS uint, cb *glib.SourceFunc) uint { return glib.TimeoutAdd(intervalMS, cb, 0) }, vimScrollRepeatRemove: glib.SourceRemove, vimScrollNow: time.Now, seq: newVimModeSequenceState(shortcuts.VimModeSequences(), seqTimeout), } h.SetOnModeChange(nil) return h } // SetOnAction sets the callback for when actions are triggered. func (h *KeyboardHandler) SetOnAction(fn ActionHandler) { h.mu.Lock() defer h.mu.Unlock() h.onAction = fn } // SetOnVimScrollLifecycle sets the callback for autonomous held-scroll ticks // and stop notification. Tap actions continue through SetOnAction. func (h *KeyboardHandler) SetOnVimScrollLifecycle(fn VimScrollLifecycleHandler) { h.mu.Lock() defer h.mu.Unlock() h.onVimScrollLifecycle = fn } // SetOnPageFocusNavigation sets the optional callback for keeping page focus // traversal inside the active WebView. func (h *KeyboardHandler) SetOnPageFocusNavigation(fn PageFocusNavigationHandler) { h.mu.Lock() defer h.mu.Unlock() h.onPageFocusNavigation = fn } // SetVimModeActivationPassthrough sets the callback that can block Vim mode // activation and let the original key event propagate instead. func (h *KeyboardHandler) SetVimModeActivationPassthrough(fn func() bool) { h.mu.Lock() defer h.mu.Unlock() h.vimModeActivationPassthrough = fn } // ReloadShortcuts rebuilds the shortcut set from new config values. // This enables hot-reloading of keybindings without restarting. func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) { h.stopVimScrollRepeat() h.mu.Lock() log := logging.FromContext(ctx) log.Debug().Msg("reloading shortcuts from config") h.shortcuts = NewShortcutSet(ctx, workspace, session) h.workspace = workspace h.session = session seqTimeout := time.Duration(0) if workspace != nil { seqTimeout = time.Duration(workspace.VimMode.SequenceTimeoutMilliseconds) * time.Millisecond } h.mu.Unlock() h.InvalidateSequenceMatcher() h.SetSequenceTimeout(seqTimeout) } // SetOnModeChange sets the callback for mode changes (for UI updates). func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) { h.modal.SetOnModeChange(func(from, to Mode) { // Switch controller phase: capture during modal, bubble for normal. // This ensures plain modal keys (s, h, etc.) reach the handler // before WebView, while normal typing still goes through WebKit IM. if to == ModeNormal { h.setControllerPhase(gtk.PhaseBubbleValue) } else if from == ModeNormal { h.setControllerPhase(gtk.PhaseCaptureValue) } if to != ModeVim { h.ClearPageKeyCapture() h.stopVimScrollRepeat() // Silent reset under ModalState lock: no pending callback (avoids Mode() re-entry). h.resetPendingSequence(false) } // Forward to app-level callback if fn != nil { fn(from, to) } }) } // setControllerPhase changes the propagation phase of the key controller. // Capture phase during modal modes so plain keys reach the handler before WebView. // Bubble phase in normal mode for WebKit IM/dead-key support. // // This is called synchronously from onModeChange so the phase switch takes // effect before the next key event. The caller must ensure GTK thread safety; // modal timeouts dispatch to the GTK main thread via glib.IdleAdd before // calling ExitMode, so the onModeChange callback always runs on the GTK thread. func (h *KeyboardHandler) setControllerPhase(phase gtk.PropagationPhase) { if h.controller == nil { return } h.controller.SetPropagationPhase(phase) } // SetRouteKey sets the callback that determines how each key event should be routed. // The callback receives key context and returns a KeyRoute indicating whether the // key should be handled by the shortcut system, passed to the focused widget // (for IM/compose support), or routed through accent detection. // // If not set, all keys are routed through the shortcut system (RouteHandleShortcuts). func (h *KeyboardHandler) SetRouteKey(fn func(KeyContext) KeyRoute) { h.mu.Lock() defer h.mu.Unlock() h.routeKey = fn } // SetAccentHandler sets the handler for long-press accent detection. func (h *KeyboardHandler) SetAccentHandler(handler AccentHandler) { h.mu.Lock() defer h.mu.Unlock() h.accentHandler = handler } // SetOnEscape sets an optional callback invoked for plain Escape in normal mode. // Return true to consume the key and stop further handling. func (h *KeyboardHandler) SetOnEscape(fn func(ctx context.Context) bool) { h.mu.Lock() defer h.mu.Unlock() h.onEscape = fn } // Mode returns the current input mode. func (h *KeyboardHandler) Mode() Mode { return h.modal.Mode() } // AttachTo attaches the keyboard handler to a GTK window. // The handler runs in the bubble phase so that WebKit's IM pipeline (including // dead-key / compose sequences) processes key events first. App-level shortcuts // are still intercepted because WebKit only consumes events it recognizes as // text input; unhandled events bubble up to this controller. func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) { log := logging.FromContext(h.ctx) if window == nil { log.Error().Msg("cannot attach keyboard handler to nil window") return } h.controller = gtk.NewEventControllerKey() if h.controller == nil { log.Error().Msg("failed to create event controller key") return } // Bubble phase: let WebKit IM handle key events first (dead keys, compose // sequences, input methods). Events not consumed by WebKit bubble up here // so app shortcuts still work. h.controller.SetPropagationPhase(gtk.PhaseBubbleValue) // Wire GTK main thread schedulers for modal and sequence ambiguity timeouts. // Timer goroutines must dispatch back to the GTK thread because callbacks // may touch GTK widgets / EventController phase. h.installGTKMainThreadSchedulers() // Connect key pressed handler (retain callback to prevent GC). // The callback receives: keyval (translated key), keycode (hardware key position), state (modifiers) h.keyPressedCb = func(_ gtk.EventControllerKey, keyval uint, keycode uint, state gdk.ModifierType) bool { return h.handleKeyPress(keyval, keycode, state) } h.keyPressedHandlerID = h.controller.ConnectKeyPressed(&h.keyPressedCb) // Connect key released handler for accent detection. h.keyReleasedCb = func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) { h.handleKeyRelease(keyval) } h.keyReleasedHandlerID = h.controller.ConnectKeyReleased(&h.keyReleasedCb) // Add controller to window h.window = window window.AddController(&h.controller.EventController) log.Debug().Msg("keyboard handler attached to window") } // installGTKMainThreadSchedulers routes modal and sequence timer callbacks // through glib.IdleAdd so they run on the GTK main thread. func (h *KeyboardHandler) installGTKMainThreadSchedulers() { if h == nil { return } schedule := func(fn func()) { cb := glib.SourceFunc(func(_ uintptr) bool { fn() return false }) glib.IdleAdd(&cb, 0) } h.modal.SetMainThreadScheduler(schedule) h.SetSequenceMainThreadScheduler(schedule) logging.FromContext(h.ctx).Debug().Msg("gtk main-thread schedulers installed for modal and vim-mode sequences") } // Detach removes the keyboard handler from a live GTK window and releases its // retained GTK signal callbacks. func (h *KeyboardHandler) Detach() { h.detach(true) } // DetachForDestroy releases retained GTK signal callbacks during window // teardown without mutating the window's controller list while GTK is // destroying it. func (h *KeyboardHandler) DetachForDestroy() { h.detach(false) } func (h *KeyboardHandler) detach(removeController bool) { h.stopVimScrollRepeat() // Sequence teardown uses seq.mu only; keep it outside h.mu to preserve // the existing h.mu → (never seq) / seq.mu standalone lock order. h.teardownSequenceState() h.mu.Lock() if h.controller != nil && h.keyPressedHandlerID != 0 { h.controller.DisconnectSignal(h.keyPressedHandlerID) } if h.controller != nil && h.keyReleasedHandlerID != 0 { h.controller.DisconnectSignal(h.keyReleasedHandlerID) } if removeController && h.window != nil && h.controller != nil { h.window.RemoveController(&h.controller.EventController) } h.controller = nil h.window = nil h.keyPressedCb = nil h.keyReleasedCb = nil h.keyPressedHandlerID = 0 h.keyReleasedHandlerID = 0 h.activePressedActions = nil h.mu.Unlock() } // handleKeyPress processes a key press event. // Returns true if the event was handled and should not propagate further. func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool { log := logging.FromContext(h.ctx) h.mu.RLock() routeKey := h.routeKey accentHandler := h.accentHandler onEscape := h.onEscape h.mu.RUnlock() // Accent picker takes absolute priority when visible -- it has its own key controller if accentHandler != nil && accentHandler.IsPickerVisible() { return false } modifiers := Modifier(state) & modifierMask mode := h.modal.Mode() // Escape in normal mode: check app-level escape hook first if mode == ModeNormal && keyval == uint(gdk.KEY_Escape) && modifiers == 0 { if onEscape != nil && onEscape(h.ctx) { return true } } // An in-page Vim interaction owns every key, including Escape and Enter. if h.forwardPageKey(mode, keyval, state) { return true } if h.handleGuaranteedModalExit(mode, keyval, modifiers) { return true } // Page focus traversal must stay out of shortcut lookup. The callback keeps // traversal inside CEF when the active page control needs it; otherwise the // event passes through to the native widget. if shouldPassthroughNativePageFocusNavigation(mode, keyval, modifiers) { return h.handlePageFocusNavigation(keyval, modifiers) } // Determine routing for this key event route := RouteHandleShortcuts // default: process through shortcut system if routeKey != nil && mode == ModeNormal { route = routeKey(KeyContext{ Keyval: keyval, Keycode: keycode, Modifiers: modifiers, }) } switch route { case RoutePassToWidget: // Let the focused widget handle this key directly (WebView IM, etc.) // No accent detection here -- WebView has its own JS-based accent detection. log.Trace().Uint("keyval", keyval).Msg("routing key to focused widget") return false case RouteAccentDetection: // Try long-press accent detection for GTK Entry widgets (omnibox, find bar). // If the accent handler doesn't consume the key, pass it to the widget. if h.tryAccentDetection(accentHandler, keyval, modifiers) { return true } log.Trace().Uint("keyval", keyval).Msg("accent detection declined, routing to widget") return false case RouteHandleShortcuts: // Fall through to shortcut processing below } // Vim-mode multi-key sequences run before legacy single-chord lookup. if h.feedVimModeSequence(keyval, state) { return true } // --- Shortcut processing path --- // Normalize uppercase letters for consistent binding lookup keyval = normalizeKeyval(keyval) binding := KeyBinding{Keyval: keyval, Modifiers: modifiers} action, found := h.lookupAction(log, binding, mode, modifiers, keycode) return h.handleShortcutLookupResult(log, action, found, mode, keyval, modifiers) } // handleGuaranteedModalExit preserves Escape and Enter as modal fallbacks even // when the current configuration omits explicit cancel or confirm bindings. func (h *KeyboardHandler) handleGuaranteedModalExit(mode Mode, keyval uint, modifiers Modifier) bool { if mode == ModeNormal || modifiers != 0 { return false } switch keyval { case uint(gdk.KEY_Escape): if mode == ModeVim { h.stopVimScrollRepeat() } h.modal.ExitMode(h.ctx) return true case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter): if mode == ModeVim { h.confirmVimMode() } else { h.modal.ExitMode(h.ctx) } return true default: return false } } func (h *KeyboardHandler) handleShortcutLookupResult( log *zerolog.Logger, action Action, found bool, mode Mode, keyval uint, modifiers Modifier, ) bool { if !found { if shouldPassthroughNativeVimModeNavigation(mode, keyval, modifiers) { log.Trace().Uint("keyval", keyval).Msg("routing native page navigation key to focused widget in vim mode") return false } if mode == ModePrefix { return h.handleUnboundPrefixKey(keyval, modifiers) } return mode != ModeNormal // Consume unrecognized keys in modal mode } if h.shouldPassthroughVimModeActivation(action, mode) { return false } if mode == ModeVim && isVimScrollAction(action) { return h.handleVimScrollAction(log, action, mode, keyval) } if h.suppressHeldAction(action, keyval) { log.Trace(). Str("action", string(action)). Msg("held keyboard action repeat suppressed") return true } return h.dispatchAction(action, mode) } func (h *KeyboardHandler) handleVimScrollAction(log *zerolog.Logger, action Action, mode Mode, keyval uint) bool { h.mu.RLock() sameHeldKey := h.vimScrollRepeatAction == action && h.vimScrollRepeatKeyval == keyval && h.vimScrollRepeatTimer != 0 h.mu.RUnlock() if sameHeldKey { log.Trace().Str("action", string(action)).Msg("vim scroll OS repeat suppressed") return true } h.stopVimScrollRepeat() if !h.dispatchAction(action, mode) { return false } h.startVimScrollRepeat(action, keyval) return true } func (h *KeyboardHandler) startVimScrollRepeat(action Action, keyval uint) { h.mu.Lock() if h.vimScrollRepeatTimer != 0 || h.vimScrollRepeatAdd == nil { h.mu.Unlock() return } h.vimScrollRepeatGeneration++ generation := h.vimScrollRepeatGeneration h.vimScrollRepeatAction = action h.vimScrollRepeatKeyval = keyval h.vimScrollRepeatPressedAt = h.vimScrollNow() cb := glib.SourceFunc(func(_ uintptr) bool { return h.vimScrollRepeatTick(action, keyval, generation) }) h.vimScrollRepeatCb = cb add := h.vimScrollRepeatAdd id := add(uint(vimScrollCadence/time.Millisecond), &h.vimScrollRepeatCb) if id == 0 { h.clearVimScrollRepeatLocked() h.mu.Unlock() return } h.vimScrollRepeatTimer = id h.mu.Unlock() } func (h *KeyboardHandler) vimScrollRepeatTick(action Action, keyval uint, generation uint64) bool { h.mu.RLock() active := h.vimScrollRepeatAction == action && h.vimScrollRepeatKeyval == keyval && h.vimScrollRepeatTimer != 0 && h.vimScrollRepeatGeneration == generation pressedAt := h.vimScrollRepeatPressedAt now := h.vimScrollNow h.mu.RUnlock() if !active || h.modal.Mode() != ModeVim { return false } if now().Sub(pressedAt) < vimScrollHoldDelay { return true } // Continuous dispatch bypasses dispatchAction, so refresh Vim Mode timeout here. h.modal.ResetTimeout(h.ctx) h.dispatchVimScrollLifecycle(action, VimScrollContinuous) return true } func (h *KeyboardHandler) stopVimScrollRepeat() { h.mu.Lock() action := h.vimScrollRepeatAction timerID := h.vimScrollRepeatTimer remove := h.vimScrollRepeatRemove h.clearVimScrollRepeatLocked() h.mu.Unlock() if timerID != 0 && remove != nil { remove(timerID) } if action != "" { h.dispatchVimScrollLifecycle(action, VimScrollStop) } } func (h *KeyboardHandler) clearVimScrollRepeatLocked() { h.vimScrollRepeatGeneration++ h.vimScrollRepeatAction = "" h.vimScrollRepeatKeyval = 0 h.vimScrollRepeatTimer = 0 h.vimScrollRepeatPressedAt = time.Time{} h.vimScrollRepeatCb = nil } func (h *KeyboardHandler) dispatchVimScrollLifecycle(action Action, phase VimScrollPhase) { h.mu.RLock() handler := h.onVimScrollLifecycle h.mu.RUnlock() if handler == nil { if phase == VimScrollContinuous { h.dispatchAction(action, ModeVim) } return } if err := handler(h.ctx, action, phase); err != nil { logging.FromContext(h.ctx).Error().Err(err).Str("action", string(action)).Msg("vim scroll lifecycle handler error") } } // tryAccentDetection starts long-press detection for accent-eligible keys. // It is used only for the RouteAccentDetection route (GTK Entry widgets such as // the omnibox and find bar). RoutePassToWidget does not call tryAccentDetection; // WebView long-press accent behavior is handled entirely by the injected JS bridge // (accentDetectionScript) and the Go-side accent key detection script, not here. // Returns true if the key should be suppressed (i.e. RouteAccentDetection consumed it). func (h *KeyboardHandler) tryAccentDetection(accentHandler AccentHandler, keyval uint, modifiers Modifier) bool { if accentHandler == nil { return false } // Only consider keys without Ctrl/Alt modifiers (Shift is OK for uppercase) if modifiers != 0 && modifiers != ModShift { return false } if char := KeyvalToRune(keyval); char != 0 { return accentHandler.OnKeyPressed(h.ctx, char, modifiers == ModShift) } return false } // normalizeKeyval converts uppercase A-Z to lowercase for consistent binding lookup. func normalizeKeyval(keyval uint) uint { if keyval >= uint('A') && keyval <= uint('Z') { return keyval + (uint('a') - uint('A')) } return keyval } func (h *KeyboardHandler) handlePageFocusNavigation(keyval uint, modifiers Modifier) bool { h.mu.RLock() handler := h.onPageFocusNavigation h.mu.RUnlock() if handler == nil { return false } backward := keyval == uint(gdk.KEY_ISO_Left_Tab) || modifiers == ModShift return handler(h.ctx, backward) } func shouldPassthroughNativePageFocusNavigation(mode Mode, keyval uint, modifiers Modifier) bool { if mode != ModeNormal && mode != ModeVim { return false } switch keyval { case uint(gdk.KEY_Tab), uint(gdk.KEY_ISO_Left_Tab): return modifiers == ModNone || modifiers == ModShift default: return false } } func shouldPassthroughNativeVimModeNavigation(mode Mode, keyval uint, modifiers Modifier) bool { if mode != ModeVim { return false } switch keyval { case uint(gdk.KEY_Left), uint(gdk.KEY_Right), uint(gdk.KEY_Up), uint(gdk.KEY_Down): return modifiers == ModNone case uint(gdk.KEY_Tab), uint(gdk.KEY_ISO_Left_Tab): return modifiers == ModNone || modifiers == ModShift default: return false } } func (h *KeyboardHandler) confirmVimMode() { h.stopVimScrollRepeat() h.seq.mu.Lock() onAction := h.seq.onAction h.seq.mu.Unlock() if onAction != nil { onAction("confirm", 0) } h.modal.ExitMode(h.ctx) } // handleUnboundPrefixKey handles a key with no prefix action. Modifier and // dead-key presses (the Shift before '%') keep the prefix waiting. Any other // key cancels the prefix, tmux-style; modified keys then continue to their // normal handlers (Alt+1 tab switch, page shortcuts), plain keys are consumed. func (h *KeyboardHandler) handleUnboundPrefixKey(keyval uint, modifiers Modifier) bool { if isNonCharacterKeysym(keyval) { return true } h.modal.ExitMode(h.ctx) return !IsShortcutModified(modifiers) } // isNonCharacterKeysym reports modifier, ISO lock/latch, and dead keysyms: // keys pressed on the way to a character rather than characters themselves. func isNonCharacterKeysym(keyval uint) bool { isModifier := keyval >= uint(gdk.KEY_Shift_L) && keyval <= uint(gdk.KEY_Hyper_R) isISOOrDead := keyval >= uint(gdk.KEY_ISO_Lock) && keyval <= uint(gdk.KEY_dead_greek) return isModifier || isISOOrDead } // dispatchAction dispatches the action and handles mode-related logic. func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool { if h.handleModeAction(action) { return true } h.mu.RLock() handler := h.onAction h.mu.RUnlock() if handler != nil { if err := handler(h.ctx, action); err != nil { log := logging.FromContext(h.ctx) log.Error().Err(err).Str("action", string(action)).Msg("action handler error") } } if mode == ModeResize && isResizeAction(action) { h.modal.ResetTimeout(h.ctx) } if mode == ModeVim && isVimScrollAction(action) { h.modal.ResetTimeout(h.ctx) } // The prefix is one-shot: any action returns to normal mode. Mode-enter // actions already left ModePrefix in handleModeAction. if (mode == ModePrefix && h.modal.Mode() == ModePrefix) || (mode != ModeNormal && ShouldAutoExitMode(action)) { h.modal.ExitMode(h.ctx) } return true // Consumed the key } func (h *KeyboardHandler) suppressHeldAction(action Action, keyval uint) bool { if h == nil || !isRepeatedKeyboardActionSuppressed(action) { return false } h.mu.Lock() defer h.mu.Unlock() if h.activePressedActions == nil { return true } if pressedKeyval, ok := h.activePressedActions[action]; ok && pressedKeyval == keyval { return true } h.activePressedActions[action] = keyval return false } func isRepeatedKeyboardActionSuppressed(action Action) bool { if isRepeatedGlobalShortcutSuppressed(action) { return true } switch action { case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionEnterVimMode, ActionEnterPrefixMode, ActionNewTab, ActionRenameTab, ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown, ActionStackPane, ActionMovePaneToTab, ActionMovePaneToNextTab: return true default: return false } } func (h *KeyboardHandler) lookupAction( log *zerolog.Logger, binding KeyBinding, mode Mode, modifiers Modifier, keycode uint, ) (Action, bool) { action, found := h.shortcuts.Lookup(binding, mode) // Fallback: check hardware keycode for Alt+number shortcuts on non-QWERTY layouts. if !found && mode == ModeNormal && modifiers == ModAlt { if keycodeAction, ok := KeycodeToTabAction[keycode]; ok { action = keycodeAction found = true if log != nil { log.Debug(). Uint("keycode", keycode). Str("action", string(action)). Msg("tab switch via hardware keycode fallback (non-QWERTY layout)") } } } if !found && mode == ModeNormal && (modifiers == ModAlt || modifiers == (ModAlt|ModShift)) { if bracketActions, ok := KeycodeToBracketAction[keycode]; ok { if modifiers == (ModAlt | ModShift) { action = bracketActions.WithShift } else { action = bracketActions.NoShift } found = action != "" if found && log != nil { log.Debug(). Uint("keycode", keycode). Str("action", string(action)). Msg("consume/expel via hardware keycode fallback (non-QWERTY layout)") } } } return action, found } func isResizeAction(action Action) bool { switch action { case ActionResizeIncreaseLeft, ActionResizeIncreaseRight, ActionResizeIncreaseUp, ActionResizeIncreaseDown, ActionResizeDecreaseLeft, ActionResizeDecreaseRight, ActionResizeDecreaseUp, ActionResizeDecreaseDown, ActionResizeIncrease, ActionResizeDecrease: return true default: return false } } func isVimScrollAction(action Action) bool { switch action { case ActionVimScrollLeft, ActionVimScrollDown, ActionVimScrollUp, ActionVimScrollRight, ActionVimScrollDownFast, ActionVimScrollUpFast: return true default: return false } } func (h *KeyboardHandler) handleVimModeAction(action Action, workspace *entity.WorkspaceConfig) bool { switch action { case ActionEnterVimMode: if h.modal.Mode() == ModeVim { h.stopVimScrollRepeat() h.modal.ExitMode(h.ctx) return true } var timeoutMS int if workspace != nil { timeoutMS = workspace.VimMode.TimeoutMilliseconds } h.modal.EnterVimMode(h.ctx, time.Duration(timeoutMS)*time.Millisecond) return true case ActionVimConfirm: if h.modal.Mode() == ModeVim { h.confirmVimMode() } return true default: return false } } func (h *KeyboardHandler) handleModeAction(action Action) bool { h.mu.RLock() workspace := h.workspace session := h.session h.mu.RUnlock() if h.handleVimModeAction(action, workspace) { return true } switch action { case ActionEnterPrefixMode: if h.modal.Mode() == ModePrefix { h.modal.ExitMode(h.ctx) return true } var ms int if workspace != nil { ms = workspace.PrefixMode.TimeoutMilliseconds } h.modal.EnterPrefixMode(h.ctx, time.Duration(ms)*time.Millisecond) return true case ActionEnterTabMode: var ms int if workspace != nil { ms = workspace.TabMode.TimeoutMilliseconds } h.modal.EnterTabMode(h.ctx, time.Duration(ms)*time.Millisecond) return true case ActionEnterPaneMode: var ms int if workspace != nil { ms = workspace.PaneMode.TimeoutMilliseconds } h.modal.EnterPaneMode(h.ctx, time.Duration(ms)*time.Millisecond) return true case ActionEnterSessionMode: var ms int if session != nil { ms = session.SessionMode.TimeoutMilliseconds } h.modal.EnterSessionMode(h.ctx, time.Duration(ms)*time.Millisecond) return true case ActionEnterResizeMode: if h.modal.Mode() == ModeResize { h.modal.ExitMode(h.ctx) return true } var ms int if workspace != nil { ms = workspace.ResizeMode.TimeoutMilliseconds } h.modal.EnterResizeMode(h.ctx, time.Duration(ms)*time.Millisecond) return true case ActionExitMode: if h.modal.Mode() == ModeVim { h.stopVimScrollRepeat() } h.modal.ExitMode(h.ctx) return true default: return false } } // EnterTabMode programmatically enters tab mode. // Useful for testing or programmatic mode changes. func (h *KeyboardHandler) EnterTabMode() { h.mu.RLock() workspace := h.workspace h.mu.RUnlock() var ms int if workspace != nil { ms = workspace.TabMode.TimeoutMilliseconds } h.modal.EnterTabMode(h.ctx, time.Duration(ms)*time.Millisecond) } // EnterPaneMode programmatically enters pane mode. // Useful for testing or programmatic mode changes. func (h *KeyboardHandler) EnterPaneMode() { h.mu.RLock() workspace := h.workspace h.mu.RUnlock() var ms int if workspace != nil { ms = workspace.PaneMode.TimeoutMilliseconds } h.modal.EnterPaneMode(h.ctx, time.Duration(ms)*time.Millisecond) } // EnterSessionMode programmatically enters session mode. // Useful for testing or programmatic mode changes. func (h *KeyboardHandler) EnterSessionMode() { h.mu.RLock() session := h.session h.mu.RUnlock() var ms int if session != nil { ms = session.SessionMode.TimeoutMilliseconds } h.modal.EnterSessionMode(h.ctx, time.Duration(ms)*time.Millisecond) } // EnterVimMode programmatically enters Vim-style scrolling mode. // Useful for testing or programmatic mode changes. func (h *KeyboardHandler) EnterVimMode() { h.mu.RLock() workspace := h.workspace h.mu.RUnlock() var ms int if workspace != nil { ms = workspace.VimMode.TimeoutMilliseconds } h.modal.EnterVimMode(h.ctx, time.Duration(ms)*time.Millisecond) } // ExitMode programmatically exits modal mode. // Useful for testing or programmatic mode changes. func (h *KeyboardHandler) ExitMode() { h.modal.ExitMode(h.ctx) } func (h *KeyboardHandler) shouldPassthroughVimModeActivation(action Action, mode Mode) bool { if action != ActionEnterVimMode || mode != ModeNormal { return false } h.mu.RLock() passthrough := h.vimModeActivationPassthrough h.mu.RUnlock() return passthrough != nil && passthrough() } // DispatchAction processes an action externally triggered (e.g., by a global // shortcut). Mode-enter actions update modal state; other actions are forwarded // to the registered action handler. It returns whether the action was consumed. func (h *KeyboardHandler) DispatchAction(action Action) bool { mode := h.modal.Mode() if h.shouldPassthroughVimModeActivation(action, mode) { return false } h.dispatchAction(action, mode) return true } // handleKeyRelease processes a key release event for accent detection. func (h *KeyboardHandler) handleKeyRelease(keyval uint) { keyval = normalizeKeyval(keyval) h.mu.Lock() accentHandler := h.accentHandler if len(h.activePressedActions) > 0 { for action, pressedKeyval := range h.activePressedActions { if pressedKeyval == keyval { delete(h.activePressedActions, action) } } } stopVimScroll := h.vimScrollRepeatKeyval == keyval h.mu.Unlock() if stopVimScroll { h.stopVimScrollRepeat() } if accentHandler == nil { return } // Convert keyval to rune for the accent handler if char := KeyvalToRune(keyval); char != 0 { accentHandler.OnKeyReleased(h.ctx, char) } } // KeyvalToRune converts a GTK keyval to a lowercase rune. // Returns 0 if the keyval is not a printable character with potential accents. func KeyvalToRune(keyval uint) rune { // Handle lowercase a-z if keyval >= uint('a') && keyval <= uint('z') { return rune(keyval) } // Handle uppercase A-Z (convert to lowercase for accent lookup) if keyval >= uint('A') && keyval <= uint('Z') { return rune(keyval + ('a' - 'A')) } return 0 } // KeyRoute determines how a key event should be routed. type KeyRoute int const ( // RouteHandleShortcuts means the key should be processed by the shortcut system. // This is the default route -- app-level shortcuts, modal mode keys, etc. RouteHandleShortcuts KeyRoute = iota // RoutePassToWidget means the key should propagate to the focused widget. // Used when overlays (session manager, tab picker) are visible, or when a // WebView should receive text input for native IM/compose/dead-key processing. // Long-press accent detection still runs first for accessibility: users with // motor disabilities may not be able to execute rapid compose sequences. RoutePassToWidget // RouteAccentDetection means the key should go through long-press accent // detection, then pass to the focused widget. Used for GTK Entry widgets // (omnibox, find bar) and as a universal accessibility fallback. RouteAccentDetection ) // KeyContext provides key event information for routing decisions. type KeyContext struct { Keyval uint // Translated key value Keycode uint // Hardware keycode Modifiers Modifier // Active modifiers (masked) } // IsShortcutModified returns true if the modifiers indicate a shortcut combination // (Ctrl or Alt pressed), as opposed to plain text input (no modifier or Shift-only). // Dead keys and compose sequences only use no-modifier or Shift, so this correctly // identifies keys that should be intercepted vs passed through to the IM pipeline. func IsShortcutModified(modifiers Modifier) bool { return modifiers&(ModCtrl|ModAlt) != 0 } // IsTextInputKey returns true if the keyval represents a text input key // (printable character or dead key) as opposed to a function/navigation key. // This is used to determine whether a key should be passed through to the // WebKit IM pipeline for compose/dead-key processing. func IsTextInputKey(keyval uint) bool { // Dead keys used by compose sequences (US International, etc.) // GDK dead key range: KEY_dead_grave (0xfe50) to KEY_dead_greek (0xfe8c) if keyval >= 0xfe50 && keyval <= 0xfe8c { return true } // Printable ASCII range (space through tilde) if keyval >= 0x020 && keyval <= 0x07e { return true } // Latin-1 supplement (non-breaking space through ÿ) if keyval >= 0x0a0 && keyval <= 0x0ff { return true } // Extended Latin and other Unicode characters mapped in GDK keyval space if keyval >= 0x100 && keyval <= 0x20ff { return true } return false }