v0.30.0 eb3068f

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// AccentHandler handles long-press accent detection.
40// Called for character keys that may have accent variants.
41type AccentHandler interface {
42	// OnKeyPressed is called when a key is pressed.
43	// Returns true if the accent handler is handling this key (picker visible or will show).
44	OnKeyPressed(ctx context.Context, char rune, shiftHeld bool) bool
45	// OnKeyReleased is called when a key is released.
46	OnKeyReleased(ctx context.Context, char rune)
47	// IsPickerVisible returns true if the accent picker is currently visible.
48	IsPickerVisible() bool
49	// Cancel cancels any pending long-press detection.
50	Cancel(ctx context.Context)
51}
52
53// KeyboardHandler processes keyboard events and dispatches actions.
54// It manages modal input modes and routes key events to the appropriate handlers.
55type KeyboardHandler struct {
56	shortcuts *ShortcutSet
57	modal     *ModalState
58	workspace *entity.WorkspaceConfig
59	session   *entity.SessionConfig
60
61	// Action handler callback
62	onAction ActionHandler
63	// Optional routing callback that determines how a key should be handled.
64	// Returns RouteHandleShortcuts (default), RoutePassToWidget (let focused
65	// widget handle it), or RouteAccentDetection (long-press accent for GTK entries).
66	routeKey func(KeyContext) KeyRoute
67	// Optional accent handler for dead keys support
68	accentHandler AccentHandler
69	// Optional escape hook for app-level overlays
70	onEscape func(ctx context.Context) bool
71
72	// GTK controller (nil until attached)
73	controller *gtk.EventControllerKey
74	window     *gtk.ApplicationWindow
75
76	// Callback retention: must stay reachable by Go GC.
77	keyPressedCb         func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool
78	keyReleasedCb        func(gtk.EventControllerKey, uint, uint, gdk.ModifierType)
79	keyPressedHandlerID  uint
80	keyReleasedHandlerID uint
81
82	activePressedActions map[Action]uint
83	ctx                  context.Context
84	mu                   sync.RWMutex
85}
86
87// NewKeyboardHandler creates a new keyboard handler.
88func NewKeyboardHandler(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) *KeyboardHandler {
89	log := logging.FromContext(ctx)
90
91	log.Debug().Msg("creating keyboard handler")
92
93	h := &KeyboardHandler{
94		shortcuts:            NewShortcutSet(ctx, workspace, session),
95		modal:                NewModalState(ctx),
96		workspace:            workspace,
97		session:              session,
98		activePressedActions: make(map[Action]uint),
99		ctx:                  ctx,
100	}
101
102	return h
103}
104
105// SetOnAction sets the callback for when actions are triggered.
106func (h *KeyboardHandler) SetOnAction(fn ActionHandler) {
107	h.mu.Lock()
108	defer h.mu.Unlock()
109	h.onAction = fn
110}
111
112// ReloadShortcuts rebuilds the shortcut set from new config values.
113// This enables hot-reloading of keybindings without restarting.
114func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
115	h.mu.Lock()
116	defer h.mu.Unlock()
117
118	log := logging.FromContext(ctx)
119	log.Debug().Msg("reloading shortcuts from config")
120
121	h.shortcuts = NewShortcutSet(ctx, workspace, session)
122	h.workspace = workspace
123	h.session = session
124}
125
126// SetOnModeChange sets the callback for mode changes (for UI updates).
127func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) {
128	h.modal.SetOnModeChange(func(from, to Mode) {
129		// Switch controller phase: capture during modal, bubble for normal.
130		// This ensures plain modal keys (s, h, etc.) reach the handler
131		// before WebView, while normal typing still goes through WebKit IM.
132		if to == ModeNormal {
133			h.setControllerPhase(gtk.PhaseBubbleValue)
134		} else if from == ModeNormal {
135			h.setControllerPhase(gtk.PhaseCaptureValue)
136		}
137		// Forward to app-level callback
138		if fn != nil {
139			fn(from, to)
140		}
141	})
142}
143
144// setControllerPhase changes the propagation phase of the key controller.
145// Capture phase during modal modes so plain keys reach the handler before WebView.
146// Bubble phase in normal mode for WebKit IM/dead-key support.
147//
148// This is called synchronously from onModeChange so the phase switch takes
149// effect before the next key event. The caller must ensure GTK thread safety;
150// modal timeouts dispatch to the GTK main thread via glib.IdleAdd before
151// calling ExitMode, so the onModeChange callback always runs on the GTK thread.
152func (h *KeyboardHandler) setControllerPhase(phase gtk.PropagationPhase) {
153	if h.controller == nil {
154		return
155	}
156	h.controller.SetPropagationPhase(phase)
157}
158
159// SetRouteKey sets the callback that determines how each key event should be routed.
160// The callback receives key context and returns a KeyRoute indicating whether the
161// key should be handled by the shortcut system, passed to the focused widget
162// (for IM/compose support), or routed through accent detection.
163//
164// If not set, all keys are routed through the shortcut system (RouteHandleShortcuts).
165func (h *KeyboardHandler) SetRouteKey(fn func(KeyContext) KeyRoute) {
166	h.mu.Lock()
167	defer h.mu.Unlock()
168	h.routeKey = fn
169}
170
171// SetAccentHandler sets the handler for long-press accent detection.
172func (h *KeyboardHandler) SetAccentHandler(handler AccentHandler) {
173	h.mu.Lock()
174	defer h.mu.Unlock()
175	h.accentHandler = handler
176}
177
178// SetOnEscape sets an optional callback invoked for plain Escape in normal mode.
179// Return true to consume the key and stop further handling.
180func (h *KeyboardHandler) SetOnEscape(fn func(ctx context.Context) bool) {
181	h.mu.Lock()
182	defer h.mu.Unlock()
183	h.onEscape = fn
184}
185
186// Mode returns the current input mode.
187func (h *KeyboardHandler) Mode() Mode {
188	return h.modal.Mode()
189}
190
191// AttachTo attaches the keyboard handler to a GTK window.
192// The handler runs in the bubble phase so that WebKit's IM pipeline (including
193// dead-key / compose sequences) processes key events first. App-level shortcuts
194// are still intercepted because WebKit only consumes events it recognizes as
195// text input; unhandled events bubble up to this controller.
196func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
197	log := logging.FromContext(h.ctx)
198
199	if window == nil {
200		log.Error().Msg("cannot attach keyboard handler to nil window")
201		return
202	}
203
204	h.controller = gtk.NewEventControllerKey()
205	if h.controller == nil {
206		log.Error().Msg("failed to create event controller key")
207		return
208	}
209
210	// Bubble phase: let WebKit IM handle key events first (dead keys, compose
211	// sequences, input methods). Events not consumed by WebKit bubble up here
212	// so app shortcuts still work.
213	h.controller.SetPropagationPhase(gtk.PhaseBubbleValue)
214
215	// Wire GTK main thread scheduler for modal timeouts. Timer goroutines
216	// must dispatch ExitMode to the GTK thread because onModeChange may
217	// call setControllerPhase (a GTK operation).
218	h.modal.SetMainThreadScheduler(func(fn func()) {
219		cb := glib.SourceFunc(func(_ uintptr) bool {
220			fn()
221			return false
222		})
223		glib.IdleAdd(&cb, 0)
224	})
225
226	// Connect key pressed handler (retain callback to prevent GC).
227	// The callback receives: keyval (translated key), keycode (hardware key position), state (modifiers)
228	h.keyPressedCb = func(_ gtk.EventControllerKey, keyval uint, keycode uint, state gdk.ModifierType) bool {
229		return h.handleKeyPress(keyval, keycode, state)
230	}
231	h.keyPressedHandlerID = h.controller.ConnectKeyPressed(&h.keyPressedCb)
232
233	// Connect key released handler for accent detection.
234	h.keyReleasedCb = func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) {
235		h.handleKeyRelease(keyval)
236	}
237	h.keyReleasedHandlerID = h.controller.ConnectKeyReleased(&h.keyReleasedCb)
238
239	// Add controller to window
240	h.window = window
241	window.AddController(&h.controller.EventController)
242
243	log.Debug().Msg("keyboard handler attached to window")
244}
245
246// Detach removes the keyboard handler from a live GTK window and releases its
247// retained GTK signal callbacks.
248func (h *KeyboardHandler) Detach() {
249	h.detach(true)
250}
251
252// DetachForDestroy releases retained GTK signal callbacks during window
253// teardown without mutating the window's controller list while GTK is
254// destroying it.
255func (h *KeyboardHandler) DetachForDestroy() {
256	h.detach(false)
257}
258
259func (h *KeyboardHandler) detach(removeController bool) {
260	h.mu.Lock()
261	defer h.mu.Unlock()
262	if h.controller != nil && h.keyPressedHandlerID != 0 {
263		h.controller.DisconnectSignal(h.keyPressedHandlerID)
264	}
265	if h.controller != nil && h.keyReleasedHandlerID != 0 {
266		h.controller.DisconnectSignal(h.keyReleasedHandlerID)
267	}
268	if removeController && h.window != nil && h.controller != nil {
269		h.window.RemoveController(&h.controller.EventController)
270	}
271	h.controller = nil
272	h.window = nil
273	h.keyPressedCb = nil
274	h.keyReleasedCb = nil
275	h.keyPressedHandlerID = 0
276	h.keyReleasedHandlerID = 0
277	h.activePressedActions = nil
278}
279
280// handleKeyPress processes a key press event.
281// Returns true if the event was handled and should not propagate further.
282func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {
283	log := logging.FromContext(h.ctx)
284
285	h.mu.RLock()
286	routeKey := h.routeKey
287	accentHandler := h.accentHandler
288	onEscape := h.onEscape
289	h.mu.RUnlock()
290
291	// Accent picker takes absolute priority when visible -- it has its own key controller
292	if accentHandler != nil && accentHandler.IsPickerVisible() {
293		return false
294	}
295
296	modifiers := Modifier(state) & modifierMask
297
298	// Escape in normal mode: check app-level escape hook first
299	if h.modal.Mode() == ModeNormal && keyval == uint(gdk.KEY_Escape) && modifiers == 0 {
300		if onEscape != nil && onEscape(h.ctx) {
301			return true
302		}
303	}
304
305	// Determine routing for this key event
306	route := RouteHandleShortcuts // default: process through shortcut system
307	if routeKey != nil && h.modal.Mode() == ModeNormal {
308		route = routeKey(KeyContext{
309			Keyval:    keyval,
310			Keycode:   keycode,
311			Modifiers: modifiers,
312		})
313	}
314
315	switch route {
316	case RoutePassToWidget:
317		// Let the focused widget handle this key directly (WebView IM, etc.)
318		// No accent detection here -- WebView has its own JS-based accent detection.
319		log.Trace().Uint("keyval", keyval).Msg("routing key to focused widget")
320		return false
321
322	case RouteAccentDetection:
323		// Try long-press accent detection for GTK Entry widgets (omnibox, find bar).
324		// If the accent handler doesn't consume the key, pass it to the widget.
325		if h.tryAccentDetection(accentHandler, keyval, modifiers) {
326			return true
327		}
328		log.Trace().Uint("keyval", keyval).Msg("accent detection declined, routing to widget")
329		return false
330
331	case RouteHandleShortcuts:
332		// Fall through to shortcut processing below
333	}
334
335	// --- Shortcut processing path ---
336
337	// Normalize uppercase letters for consistent binding lookup
338	keyval = normalizeKeyval(keyval)
339
340	binding := KeyBinding{Keyval: keyval, Modifiers: modifiers}
341	mode := h.modal.Mode()
342	action, found := h.lookupAction(log, binding, mode, modifiers, keycode)
343
344	if !found {
345		return mode != ModeNormal // Consume unrecognized keys in modal mode
346	}
347	if h.suppressHeldAction(action, keyval) {
348		log.Trace().
349			Str("action", string(action)).
350			Msg("held keyboard action repeat suppressed")
351		return true
352	}
353
354	return h.dispatchAction(action, mode)
355}
356
357// tryAccentDetection starts long-press detection for accent-eligible keys.
358// It is used only for the RouteAccentDetection route (GTK Entry widgets such as
359// the omnibox and find bar). RoutePassToWidget does not call tryAccentDetection;
360// WebView long-press accent behavior is handled entirely by the injected JS bridge
361// (accentDetectionScript) and the Go-side accent key detection script, not here.
362// Returns true if the key should be suppressed (i.e. RouteAccentDetection consumed it).
363func (h *KeyboardHandler) tryAccentDetection(accentHandler AccentHandler, keyval uint, modifiers Modifier) bool {
364	if accentHandler == nil {
365		return false
366	}
367	// Only consider keys without Ctrl/Alt modifiers (Shift is OK for uppercase)
368	if modifiers != 0 && modifiers != ModShift {
369		return false
370	}
371	if char := KeyvalToRune(keyval); char != 0 {
372		return accentHandler.OnKeyPressed(h.ctx, char, modifiers == ModShift)
373	}
374	return false
375}
376
377// normalizeKeyval converts uppercase A-Z to lowercase for consistent binding lookup.
378func normalizeKeyval(keyval uint) uint {
379	if keyval >= uint('A') && keyval <= uint('Z') {
380		return keyval + (uint('a') - uint('A'))
381	}
382	return keyval
383}
384
385// dispatchAction dispatches the action and handles mode-related logic.
386func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool {
387	if h.handleModeAction(action) {
388		return true
389	}
390
391	h.mu.RLock()
392	handler := h.onAction
393	h.mu.RUnlock()
394
395	if handler != nil {
396		if err := handler(h.ctx, action); err != nil {
397			log := logging.FromContext(h.ctx)
398			log.Error().Err(err).Str("action", string(action)).Msg("action handler error")
399		}
400	}
401
402	if mode == ModeResize && isResizeAction(action) {
403		h.modal.ResetTimeout(h.ctx)
404	}
405
406	if mode != ModeNormal && ShouldAutoExitMode(action) {
407		h.modal.ExitMode(h.ctx)
408	}
409
410	return true // Consumed the key
411}
412
413func (h *KeyboardHandler) suppressHeldAction(action Action, keyval uint) bool {
414	if h == nil || !isRepeatedKeyboardActionSuppressed(action) {
415		return false
416	}
417
418	h.mu.Lock()
419	defer h.mu.Unlock()
420	if h.activePressedActions == nil {
421		return true
422	}
423	if pressedKeyval, ok := h.activePressedActions[action]; ok && pressedKeyval == keyval {
424		return true
425	}
426	h.activePressedActions[action] = keyval
427	return false
428}
429
430func isRepeatedKeyboardActionSuppressed(action Action) bool {
431	if isRepeatedGlobalShortcutSuppressed(action) {
432		return true
433	}
434	switch action {
435	case ActionEnterTabMode,
436		ActionEnterPaneMode,
437		ActionEnterSessionMode,
438		ActionEnterResizeMode,
439		ActionNewTab,
440		ActionRenameTab,
441		ActionSplitRight,
442		ActionSplitLeft,
443		ActionSplitUp,
444		ActionSplitDown,
445		ActionStackPane,
446		ActionMovePaneToTab,
447		ActionMovePaneToNextTab:
448		return true
449	default:
450		return false
451	}
452}
453
454func (h *KeyboardHandler) lookupAction(
455	log *zerolog.Logger,
456	binding KeyBinding,
457	mode Mode,
458	modifiers Modifier,
459	keycode uint,
460) (Action, bool) {
461	action, found := h.shortcuts.Lookup(binding, mode)
462
463	// Fallback: check hardware keycode for Alt+number shortcuts on non-QWERTY layouts.
464	if !found && mode == ModeNormal && modifiers == ModAlt {
465		if keycodeAction, ok := KeycodeToTabAction[keycode]; ok {
466			action = keycodeAction
467			found = true
468			if log != nil {
469				log.Debug().
470					Uint("keycode", keycode).
471					Str("action", string(action)).
472					Msg("tab switch via hardware keycode fallback (non-QWERTY layout)")
473			}
474		}
475	}
476
477	if !found && mode == ModeNormal && (modifiers == ModAlt || modifiers == (ModAlt|ModShift)) {
478		if bracketActions, ok := KeycodeToBracketAction[keycode]; ok {
479			if modifiers == (ModAlt | ModShift) {
480				action = bracketActions.WithShift
481			} else {
482				action = bracketActions.NoShift
483			}
484			found = action != ""
485			if found && log != nil {
486				log.Debug().
487					Uint("keycode", keycode).
488					Str("action", string(action)).
489					Msg("consume/expel via hardware keycode fallback (non-QWERTY layout)")
490			}
491		}
492	}
493
494	return action, found
495}
496
497func isResizeAction(action Action) bool {
498	switch action {
499	case ActionResizeIncreaseLeft,
500		ActionResizeIncreaseRight,
501		ActionResizeIncreaseUp,
502		ActionResizeIncreaseDown,
503		ActionResizeDecreaseLeft,
504		ActionResizeDecreaseRight,
505		ActionResizeDecreaseUp,
506		ActionResizeDecreaseDown,
507		ActionResizeIncrease,
508		ActionResizeDecrease:
509		return true
510	default:
511		return false
512	}
513}
514
515func (h *KeyboardHandler) handleModeAction(action Action) bool {
516	h.mu.RLock()
517	workspace := h.workspace
518	session := h.session
519	h.mu.RUnlock()
520
521	switch action {
522	case ActionEnterTabMode:
523		var ms int
524		if workspace != nil {
525			ms = workspace.TabMode.TimeoutMilliseconds
526		}
527		h.modal.EnterTabMode(h.ctx, time.Duration(ms)*time.Millisecond)
528		return true
529	case ActionEnterPaneMode:
530		var ms int
531		if workspace != nil {
532			ms = workspace.PaneMode.TimeoutMilliseconds
533		}
534		h.modal.EnterPaneMode(h.ctx, time.Duration(ms)*time.Millisecond)
535		return true
536	case ActionEnterSessionMode:
537		var ms int
538		if session != nil {
539			ms = session.SessionMode.TimeoutMilliseconds
540		}
541		h.modal.EnterSessionMode(h.ctx, time.Duration(ms)*time.Millisecond)
542		return true
543	case ActionEnterResizeMode:
544		if h.modal.Mode() == ModeResize {
545			h.modal.ExitMode(h.ctx)
546			return true
547		}
548		var ms int
549		if workspace != nil {
550			ms = workspace.ResizeMode.TimeoutMilliseconds
551		}
552		h.modal.EnterResizeMode(h.ctx, time.Duration(ms)*time.Millisecond)
553		return true
554	case ActionExitMode:
555		h.modal.ExitMode(h.ctx)
556		return true
557	default:
558		return false
559	}
560}
561
562// EnterTabMode programmatically enters tab mode.
563// Useful for testing or programmatic mode changes.
564func (h *KeyboardHandler) EnterTabMode() {
565	h.mu.RLock()
566	workspace := h.workspace
567	h.mu.RUnlock()
568	var ms int
569	if workspace != nil {
570		ms = workspace.TabMode.TimeoutMilliseconds
571	}
572	h.modal.EnterTabMode(h.ctx, time.Duration(ms)*time.Millisecond)
573}
574
575// EnterPaneMode programmatically enters pane mode.
576// Useful for testing or programmatic mode changes.
577func (h *KeyboardHandler) EnterPaneMode() {
578	h.mu.RLock()
579	workspace := h.workspace
580	h.mu.RUnlock()
581	var ms int
582	if workspace != nil {
583		ms = workspace.PaneMode.TimeoutMilliseconds
584	}
585	h.modal.EnterPaneMode(h.ctx, time.Duration(ms)*time.Millisecond)
586}
587
588// EnterSessionMode programmatically enters session mode.
589// Useful for testing or programmatic mode changes.
590func (h *KeyboardHandler) EnterSessionMode() {
591	h.mu.RLock()
592	session := h.session
593	h.mu.RUnlock()
594	var ms int
595	if session != nil {
596		ms = session.SessionMode.TimeoutMilliseconds
597	}
598	h.modal.EnterSessionMode(h.ctx, time.Duration(ms)*time.Millisecond)
599}
600
601// ExitMode programmatically exits modal mode.
602// Useful for testing or programmatic mode changes.
603func (h *KeyboardHandler) ExitMode() {
604	h.modal.ExitMode(h.ctx)
605}
606
607// DispatchAction processes an action externally triggered (e.g., by a global
608// shortcut). Mode-enter actions update modal state; other actions are forwarded
609// to the registered action handler.
610func (h *KeyboardHandler) DispatchAction(action Action) {
611	mode := h.modal.Mode()
612	h.dispatchAction(action, mode)
613}
614
615// handleKeyRelease processes a key release event for accent detection.
616func (h *KeyboardHandler) handleKeyRelease(keyval uint) {
617	h.mu.Lock()
618	accentHandler := h.accentHandler
619	if len(h.activePressedActions) > 0 {
620		keyval = normalizeKeyval(keyval)
621		for action, pressedKeyval := range h.activePressedActions {
622			if pressedKeyval == keyval {
623				delete(h.activePressedActions, action)
624			}
625		}
626	}
627	h.mu.Unlock()
628
629	if accentHandler == nil {
630		return
631	}
632
633	// Convert keyval to rune for the accent handler
634	if char := KeyvalToRune(keyval); char != 0 {
635		accentHandler.OnKeyReleased(h.ctx, char)
636	}
637}
638
639// KeyvalToRune converts a GTK keyval to a lowercase rune.
640// Returns 0 if the keyval is not a printable character with potential accents.
641func KeyvalToRune(keyval uint) rune {
642	// Handle lowercase a-z
643	if keyval >= uint('a') && keyval <= uint('z') {
644		return rune(keyval)
645	}
646	// Handle uppercase A-Z (convert to lowercase for accent lookup)
647	if keyval >= uint('A') && keyval <= uint('Z') {
648		return rune(keyval + ('a' - 'A'))
649	}
650	return 0
651}
652
653// KeyRoute determines how a key event should be routed.
654type KeyRoute int
655
656const (
657	// RouteHandleShortcuts means the key should be processed by the shortcut system.
658	// This is the default route -- app-level shortcuts, modal mode keys, etc.
659	RouteHandleShortcuts KeyRoute = iota
660
661	// RoutePassToWidget means the key should propagate to the focused widget.
662	// Used when overlays (session manager, tab picker) are visible, or when a
663	// WebView should receive text input for native IM/compose/dead-key processing.
664	// Long-press accent detection still runs first for accessibility: users with
665	// motor disabilities may not be able to execute rapid compose sequences.
666	RoutePassToWidget
667
668	// RouteAccentDetection means the key should go through long-press accent
669	// detection, then pass to the focused widget. Used for GTK Entry widgets
670	// (omnibox, find bar) and as a universal accessibility fallback.
671	RouteAccentDetection
672)
673
674// KeyContext provides key event information for routing decisions.
675type KeyContext struct {
676	Keyval    uint     // Translated key value
677	Keycode   uint     // Hardware keycode
678	Modifiers Modifier // Active modifiers (masked)
679}
680
681// IsShortcutModified returns true if the modifiers indicate a shortcut combination
682// (Ctrl or Alt pressed), as opposed to plain text input (no modifier or Shift-only).
683// Dead keys and compose sequences only use no-modifier or Shift, so this correctly
684// identifies keys that should be intercepted vs passed through to the IM pipeline.
685func IsShortcutModified(modifiers Modifier) bool {
686	return modifiers&(ModCtrl|ModAlt) != 0
687}
688
689// IsTextInputKey returns true if the keyval represents a text input key
690// (printable character or dead key) as opposed to a function/navigation key.
691// This is used to determine whether a key should be passed through to the
692// WebKit IM pipeline for compose/dead-key processing.
693func IsTextInputKey(keyval uint) bool {
694	// Dead keys used by compose sequences (US International, etc.)
695	// GDK dead key range: KEY_dead_grave (0xfe50) to KEY_dead_greek (0xfe8c)
696	if keyval >= 0xfe50 && keyval <= 0xfe8c {
697		return true
698	}
699
700	// Printable ASCII range (space through tilde)
701	if keyval >= 0x020 && keyval <= 0x07e {
702		return true
703	}
704	// Latin-1 supplement (non-breaking space through ÿ)
705	if keyval >= 0x0a0 && keyval <= 0x0ff {
706		return true
707	}
708	// Extended Latin and other Unicode characters mapped in GDK keyval space
709	if keyval >= 0x100 && keyval <= 0x20ff {
710		return true
711	}
712
713	return false
714}

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