v0.31.0 bf0756b

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

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