v0.29.0 21b3150

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

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