main 5041353

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

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