keyboard.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close