v0.26.0 ae3a77a

shortcuts.go RawHistoryFolder

1// Package input provides keyboard event handling and modal input mode management.
2package input
3
4import (
5	"context"
6	"strings"
7
8	"github.com/bnema/dumber/internal/infrastructure/config"
9	"github.com/bnema/dumber/internal/logging"
10	"github.com/jwijenbergh/puregotk/v4/gdk"
11)
12
13// Modifier represents keyboard modifier flags.
14type Modifier uint
15
16const (
17	// ModNone indicates no modifier is pressed.
18	ModNone Modifier = 0
19	// ModShift indicates the Shift key is pressed.
20	ModShift Modifier = Modifier(gdk.ShiftMaskValue)
21	// ModCtrl indicates the Control key is pressed.
22	ModCtrl Modifier = Modifier(gdk.ControlMaskValue)
23	// ModAlt indicates the Alt key is pressed.
24	ModAlt Modifier = Modifier(gdk.AltMaskValue)
25)
26
27// modifierMask filters out non-standard modifiers from GDK state.
28const modifierMask = ModCtrl | ModShift | ModAlt
29
30var keyvalByName = map[string]uint{
31	"escape":     uint(gdk.KEY_Escape),
32	"esc":        uint(gdk.KEY_Escape),
33	"return":     uint(gdk.KEY_Return),
34	"enter":      uint(gdk.KEY_Return),
35	"tab":        uint(gdk.KEY_Tab),
36	"space":      uint(gdk.KEY_space),
37	"backspace":  uint(gdk.KEY_BackSpace),
38	"delete":     uint(gdk.KEY_Delete),
39	"del":        uint(gdk.KEY_Delete),
40	"home":       uint(gdk.KEY_Home),
41	"end":        uint(gdk.KEY_End),
42	"pageup":     uint(gdk.KEY_Page_Up),
43	"page_up":    uint(gdk.KEY_Page_Up),
44	"pagedown":   uint(gdk.KEY_Page_Down),
45	"page_down":  uint(gdk.KEY_Page_Down),
46	"left":       uint(gdk.KEY_Left),
47	"arrowleft":  uint(gdk.KEY_Left),
48	"right":      uint(gdk.KEY_Right),
49	"arrowright": uint(gdk.KEY_Right),
50	"up":         uint(gdk.KEY_Up),
51	"arrowup":    uint(gdk.KEY_Up),
52	"down":       uint(gdk.KEY_Down),
53	"arrowdown":  uint(gdk.KEY_Down),
54	"f1":         uint(gdk.KEY_F1),
55	"f2":         uint(gdk.KEY_F2),
56	"f3":         uint(gdk.KEY_F3),
57	"f4":         uint(gdk.KEY_F4),
58	"f5":         uint(gdk.KEY_F5),
59	"f6":         uint(gdk.KEY_F6),
60	"f7":         uint(gdk.KEY_F7),
61	"f8":         uint(gdk.KEY_F8),
62	"f9":         uint(gdk.KEY_F9),
63	"f10":        uint(gdk.KEY_F10),
64	"f11":        uint(gdk.KEY_F11),
65	"f12":        uint(gdk.KEY_F12),
66	"plus":       uint(gdk.KEY_plus),
67	"+":          uint(gdk.KEY_plus),
68	"minus":      uint(gdk.KEY_minus),
69	"-":          uint(gdk.KEY_minus),
70	"equal":      uint(gdk.KEY_equal),
71	"=":          uint(gdk.KEY_equal),
72	"0":          uint(gdk.KEY_0),
73	"1":          uint(gdk.KEY_1),
74	"2":          uint(gdk.KEY_2),
75	"3":          uint(gdk.KEY_3),
76	"4":          uint(gdk.KEY_4),
77	"5":          uint(gdk.KEY_5),
78	"6":          uint(gdk.KEY_6),
79	"7":          uint(gdk.KEY_7),
80	"8":          uint(gdk.KEY_8),
81	"9":          uint(gdk.KEY_9),
82
83	"bracketleft":  uint(gdk.KEY_bracketleft),
84	"bracketright": uint(gdk.KEY_bracketright),
85	"[":            uint(gdk.KEY_bracketleft),
86	"]":            uint(gdk.KEY_bracketright),
87	"braceleft":    uint(gdk.KEY_braceleft),
88	"braceright":   uint(gdk.KEY_braceright),
89	"{":            uint(gdk.KEY_braceleft),
90	"}":            uint(gdk.KEY_braceright),
91}
92
93// KeyBinding represents a single key combination.
94type KeyBinding struct {
95	Keyval    uint     // GDK key value (e.g., gdk.KEY_t)
96	Modifiers Modifier // Combined modifiers
97}
98
99// Action represents what happens when a shortcut is triggered.
100type Action string
101
102// Predefined actions for the keyboard system.
103const (
104	// Mode management
105	ActionEnterTabMode     Action = "enter_tab_mode"
106	ActionEnterPaneMode    Action = "enter_pane_mode"
107	ActionEnterSessionMode Action = "enter_session_mode"
108	ActionEnterResizeMode  Action = "enter_resize_mode"
109	ActionExitMode         Action = "exit_mode"
110
111	// Tab actions (global and modal)
112	ActionNewTab           Action = "new_tab"
113	ActionCloseTab         Action = "close_tab"
114	ActionNextTab          Action = "next_tab"
115	ActionPreviousTab      Action = "previous_tab"
116	ActionRenameTab        Action = "rename_tab"
117	ActionSwitchLastTab    Action = "switch_last_tab" // Alt+Tab style switching
118	ActionSwitchTabIndex1  Action = "switch_tab_1"
119	ActionSwitchTabIndex2  Action = "switch_tab_2"
120	ActionSwitchTabIndex3  Action = "switch_tab_3"
121	ActionSwitchTabIndex4  Action = "switch_tab_4"
122	ActionSwitchTabIndex5  Action = "switch_tab_5"
123	ActionSwitchTabIndex6  Action = "switch_tab_6"
124	ActionSwitchTabIndex7  Action = "switch_tab_7"
125	ActionSwitchTabIndex8  Action = "switch_tab_8"
126	ActionSwitchTabIndex9  Action = "switch_tab_9"
127	ActionSwitchTabIndex10 Action = "switch_tab_10"
128
129	// Pane actions (modal)
130	ActionSplitRight Action = "split_right"
131	ActionSplitLeft  Action = "split_left"
132	ActionSplitUp    Action = "split_up"
133	ActionSplitDown  Action = "split_down"
134	ActionClosePane  Action = "close_pane"
135	ActionStackPane  Action = "stack_pane"
136
137	ActionMovePaneToTab     Action = "move_pane_to_tab"
138	ActionMovePaneToNextTab Action = "move_pane_to_next_tab"
139
140	ActionConsumeOrExpelLeft  Action = "consume_or_expel_left"
141	ActionConsumeOrExpelRight Action = "consume_or_expel_right"
142	ActionConsumeOrExpelUp    Action = "consume_or_expel_up"
143	ActionConsumeOrExpelDown  Action = "consume_or_expel_down"
144
145	// Pane focus navigation
146	ActionFocusRight Action = "focus_right"
147	ActionFocusLeft  Action = "focus_left"
148	ActionFocusUp    Action = "focus_up"
149	ActionFocusDown  Action = "focus_down"
150
151	// Resize actions (modal)
152	ActionResizeIncreaseLeft  Action = "resize_increase_left"
153	ActionResizeIncreaseRight Action = "resize_increase_right"
154	ActionResizeIncreaseUp    Action = "resize_increase_up"
155	ActionResizeIncreaseDown  Action = "resize_increase_down"
156	ActionResizeDecreaseLeft  Action = "resize_decrease_left"
157	ActionResizeDecreaseRight Action = "resize_decrease_right"
158	ActionResizeDecreaseUp    Action = "resize_decrease_up"
159	ActionResizeDecreaseDown  Action = "resize_decrease_down"
160	ActionResizeIncrease      Action = "resize_increase"
161	ActionResizeDecrease      Action = "resize_decrease"
162
163	// Stack navigation (within stacked panes)
164	ActionStackNavUp   Action = "stack_nav_up"
165	ActionStackNavDown Action = "stack_nav_down"
166
167	// Page navigation
168	ActionGoBack     Action = "go_back"
169	ActionGoForward  Action = "go_forward"
170	ActionReload     Action = "reload"
171	ActionHardReload Action = "hard_reload"
172	ActionStop       Action = "stop"
173	ActionPrintPage  Action = "print_page"
174
175	// Zoom
176	ActionZoomIn    Action = "zoom_in"
177	ActionZoomOut   Action = "zoom_out"
178	ActionZoomReset Action = "zoom_reset"
179
180	// UI
181	ActionOpenOmnibox      Action = "open_omnibox"
182	ActionOpenFind         Action = "open_find"
183	ActionFindNext         Action = "find_next"
184	ActionFindPrev         Action = "find_prev"
185	ActionCloseFind        Action = "close_find"
186	ActionOpenDevTools     Action = "open_devtools"
187	ActionToggleFullscreen Action = "toggle_fullscreen"
188
189	// Clipboard
190	ActionCopyURL Action = "copy_url"
191
192	// Session management
193	ActionOpenSessionManager Action = "open_session_manager"
194
195	// Application
196	ActionQuit Action = "quit"
197)
198
199// ShortcutTable maps KeyBinding to Action.
200type ShortcutTable map[KeyBinding]Action
201
202// ShortcutSet holds all shortcut tables organized by context.
203type ShortcutSet struct {
204	// Global shortcuts are always active regardless of mode.
205	Global ShortcutTable
206	// TabMode shortcuts are only active in tab mode.
207	TabMode ShortcutTable
208	// PaneMode shortcuts are only active in pane mode.
209	PaneMode ShortcutTable
210	// SessionMode shortcuts are only active in session mode.
211	SessionMode ShortcutTable
212	// ResizeMode shortcuts are only active in resize mode.
213	ResizeMode ShortcutTable
214}
215
216// NewShortcutSet creates a ShortcutSet from the configuration.
217func NewShortcutSet(ctx context.Context, cfg *config.Config) *ShortcutSet {
218	log := logging.FromContext(ctx)
219	set := &ShortcutSet{
220		Global:      make(ShortcutTable),
221		TabMode:     make(ShortcutTable),
222		PaneMode:    make(ShortcutTable),
223		SessionMode: make(ShortcutTable),
224		ResizeMode:  make(ShortcutTable),
225	}
226
227	set.buildGlobalShortcuts(ctx, cfg)
228	set.buildTabModeShortcuts(ctx, &cfg.Workspace)
229	set.buildPaneModeShortcuts(ctx, &cfg.Workspace)
230	set.buildResizeModeShortcuts(ctx, &cfg.Workspace)
231	set.buildSessionModeShortcuts(ctx, &cfg.Session)
232
233	log.Debug().
234		Int("global", len(set.Global)).
235		Int("tab", len(set.TabMode)).
236		Int("pane", len(set.PaneMode)).
237		Int("resize", len(set.ResizeMode)).
238		Int("session", len(set.SessionMode)).
239		Msg("shortcuts registered")
240
241	return set
242}
243
244// buildGlobalShortcuts populates global shortcuts from config.
245func (s *ShortcutSet) buildGlobalShortcuts(ctx context.Context, cfg *config.Config) {
246	s.registerActivationShortcuts(ctx, cfg)
247	s.registerConfiguredShortcuts(&cfg.Workspace)
248	s.registerStandardShortcuts()
249	s.registerPaneNavigationShortcuts()
250	s.registerTabSwitchShortcuts()
251}
252
253// buildTabModeShortcuts populates tab mode shortcuts from config.
254func (s *ShortcutSet) buildTabModeShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) {
255	s.buildModeShortcuts(ctx, cfg.TabMode.GetKeyBindings(), s.TabMode, "tab")
256}
257
258// buildPaneModeShortcuts populates pane mode shortcuts from config.
259func (s *ShortcutSet) buildPaneModeShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) {
260	s.buildModeShortcuts(ctx, cfg.PaneMode.GetKeyBindings(), s.PaneMode, "pane")
261}
262
263// buildSessionModeShortcuts populates session mode shortcuts from config.
264func (s *ShortcutSet) buildSessionModeShortcuts(ctx context.Context, cfg *config.SessionConfig) {
265	s.buildModeShortcuts(ctx, cfg.SessionMode.GetKeyBindings(), s.SessionMode, "session")
266}
267
268// buildResizeModeShortcuts populates resize mode shortcuts from config.
269func (s *ShortcutSet) buildResizeModeShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) {
270	s.buildModeShortcuts(ctx, cfg.ResizeMode.GetKeyBindings(), s.ResizeMode, "resize")
271}
272
273func (s *ShortcutSet) registerActivationShortcuts(ctx context.Context, cfg *config.Config) {
274	log := logging.FromContext(ctx)
275	if binding, ok := ParseKeyString(cfg.Workspace.TabMode.ActivationShortcut); ok {
276		s.Global[binding] = ActionEnterTabMode
277		log.Trace().
278			Str("shortcut", cfg.Workspace.TabMode.ActivationShortcut).
279			Uint("keyval", binding.Keyval).
280			Uint("mod", uint(binding.Modifiers)).
281			Msg("tab mode activation registered")
282	} else {
283		log.Warn().Str("shortcut", cfg.Workspace.TabMode.ActivationShortcut).Msg("failed to parse tab mode activation shortcut")
284	}
285	if binding, ok := ParseKeyString(cfg.Workspace.PaneMode.ActivationShortcut); ok {
286		s.Global[binding] = ActionEnterPaneMode
287		log.Trace().
288			Str("shortcut", cfg.Workspace.PaneMode.ActivationShortcut).
289			Uint("keyval", binding.Keyval).
290			Uint("mod", uint(binding.Modifiers)).
291			Msg("pane mode activation registered")
292	} else {
293		log.Warn().Str("shortcut", cfg.Workspace.PaneMode.ActivationShortcut).Msg("failed to parse pane mode activation shortcut")
294	}
295	if binding, ok := ParseKeyString(cfg.Session.SessionMode.ActivationShortcut); ok {
296		s.Global[binding] = ActionEnterSessionMode
297		log.Trace().
298			Str("shortcut", cfg.Session.SessionMode.ActivationShortcut).
299			Uint("keyval", binding.Keyval).
300			Uint("mod", uint(binding.Modifiers)).
301			Msg("session mode activation registered")
302	} else {
303		log.Warn().Str("shortcut", cfg.Session.SessionMode.ActivationShortcut).Msg("failed to parse session mode activation shortcut")
304	}
305	if binding, ok := ParseKeyString(cfg.Workspace.ResizeMode.ActivationShortcut); ok {
306		s.Global[binding] = ActionEnterResizeMode
307		log.Trace().
308			Str("shortcut", cfg.Workspace.ResizeMode.ActivationShortcut).
309			Uint("keyval", binding.Keyval).
310			Uint("mod", uint(binding.Modifiers)).
311			Msg("resize mode activation registered")
312	} else {
313		log.Warn().Str("shortcut", cfg.Workspace.ResizeMode.ActivationShortcut).Msg("failed to parse resize mode activation shortcut")
314	}
315}
316
317func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) {
318	// Note: Ctrl+T is NOT registered globally - it enters tab mode.
319	// In tab mode, use:
320	//   n = new tab
321	//   x = close tab
322	//   l/tab = next tab
323	//   h/shift+tab = previous tab
324	//   r = rename tab
325	// This follows Zellij-style modal keyboard interface.
326	//
327	// However, these standard browser shortcuts ARE global:
328	// Map action names to action constants
329	actionMap := map[string]Action{
330		"close_pane":             ActionClosePane,
331		"next_tab":               ActionNextTab,
332		"previous_tab":           ActionPreviousTab,
333		"consume_or_expel_left":  ActionConsumeOrExpelLeft,
334		"consume_or_expel_right": ActionConsumeOrExpelRight,
335		"consume_or_expel_up":    ActionConsumeOrExpelUp,
336		"consume_or_expel_down":  ActionConsumeOrExpelDown,
337	}
338
339	for actionName, actionBinding := range cfg.Shortcuts.Actions {
340		action, ok := actionMap[actionName]
341		if !ok {
342			continue
343		}
344		for _, keyStr := range actionBinding.Keys {
345			if binding, ok := ParseKeyString(keyStr); ok {
346				s.Global[binding] = action
347			}
348		}
349	}
350}
351
352func (s *ShortcutSet) registerStandardShortcuts() {
353	s.Global[KeyBinding{uint(gdk.KEY_l), ModCtrl}] = ActionOpenOmnibox
354	s.Global[KeyBinding{uint(gdk.KEY_f), ModCtrl}] = ActionOpenFind
355	s.Global[KeyBinding{uint(gdk.KEY_F3), ModNone}] = ActionFindNext
356	s.Global[KeyBinding{uint(gdk.KEY_F3), ModShift}] = ActionFindPrev
357	s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl}] = ActionFindNext
358	s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl | ModShift}] = ActionFindPrev
359	s.Global[KeyBinding{uint(gdk.KEY_r), ModCtrl}] = ActionReload
360	s.Global[KeyBinding{uint('r'), ModCtrl | ModShift}] = ActionHardReload
361	s.Global[KeyBinding{uint(gdk.KEY_F5), ModNone}] = ActionReload
362	s.Global[KeyBinding{uint(gdk.KEY_F5), ModCtrl}] = ActionHardReload
363	s.Global[KeyBinding{uint(gdk.KEY_F12), ModNone}] = ActionOpenDevTools
364	s.Global[KeyBinding{uint(gdk.KEY_Left), ModCtrl}] = ActionGoBack
365	s.Global[KeyBinding{uint(gdk.KEY_Right), ModCtrl}] = ActionGoForward
366	s.Global[KeyBinding{uint(gdk.KEY_plus), ModCtrl}] = ActionZoomIn
367	s.Global[KeyBinding{uint(gdk.KEY_equal), ModCtrl}] = ActionZoomIn // Ctrl+= (no shift needed)
368	s.Global[KeyBinding{uint(gdk.KEY_minus), ModCtrl}] = ActionZoomOut
369	s.Global[KeyBinding{uint(gdk.KEY_0), ModCtrl}] = ActionZoomReset
370	s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit
371	s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen
372	s.Global[KeyBinding{uint('c'), ModCtrl | ModShift}] = ActionCopyURL
373	s.Global[KeyBinding{uint('p'), ModCtrl | ModShift}] = ActionPrintPage
374	// Session management - direct shortcut to open session manager
375	s.Global[KeyBinding{uint(gdk.KEY_s), ModCtrl | ModShift}] = ActionOpenSessionManager
376}
377
378func (s *ShortcutSet) registerPaneNavigationShortcuts() {
379	s.Global[KeyBinding{uint(gdk.KEY_h), ModAlt}] = ActionFocusLeft
380	s.Global[KeyBinding{uint(gdk.KEY_l), ModAlt}] = ActionFocusRight
381	s.Global[KeyBinding{uint(gdk.KEY_k), ModAlt}] = ActionFocusUp
382	s.Global[KeyBinding{uint(gdk.KEY_j), ModAlt}] = ActionFocusDown
383
384	s.Global[KeyBinding{uint(gdk.KEY_Left), ModAlt}] = ActionFocusLeft
385	s.Global[KeyBinding{uint(gdk.KEY_Right), ModAlt}] = ActionFocusRight
386	s.Global[KeyBinding{uint(gdk.KEY_Up), ModAlt}] = ActionFocusUp
387	s.Global[KeyBinding{uint(gdk.KEY_Down), ModAlt}] = ActionFocusDown
388}
389
390func (s *ShortcutSet) registerTabSwitchShortcuts() {
391	// NOTE: Alt+1-9, Alt+0, and Alt+Tab are now handled by GlobalShortcutHandler
392	// using GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL.
393	// This is necessary because WebKitGTK's WebView consumes these key events
394	// before they reach the EventControllerKey in capture phase.
395	//
396	// Only Alt+Shift+Tab remains here as a fallback binding.
397	s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt | ModShift}] = ActionSwitchLastTab
398}
399
400func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[string]string, dest map[KeyBinding]Action, mode string) {
401	log := logging.FromContext(ctx)
402	var registered, parseErrors, unknownActions int
403	for key, configAction := range bindings {
404		if binding, ok := ParseKeyString(key); ok {
405			if action := mapConfigAction(configAction); action != "" {
406				dest[binding] = action
407				registered++
408				log.Trace().
409					Str("key", key).
410					Str("configAction", configAction).
411					Str("action", string(action)).
412					Uint("keyval", binding.Keyval).
413					Msg(mode + " mode shortcut registered")
414			} else {
415				unknownActions++
416				log.Warn().Str("key", key).Str("configAction", configAction).Msg("unknown config action in " + mode + " mode")
417			}
418		} else {
419			parseErrors++
420			log.Warn().Str("key", key).Msg("failed to parse " + mode + " mode key")
421		}
422	}
423	log.Debug().
424		Int("registered", registered).
425		Int("parseErrors", parseErrors).
426		Int("unknownActions", unknownActions).
427		Msg(mode + " mode shortcuts built")
428}
429
430var configActionToAction = map[string]Action{
431	// Tab actions
432	"new-tab":      ActionNewTab,
433	"close-tab":    ActionCloseTab,
434	"next-tab":     ActionNextTab,
435	"previous-tab": ActionPreviousTab,
436	"rename-tab":   ActionRenameTab,
437
438	// Pane actions
439	"split-right":           ActionSplitRight,
440	"split-left":            ActionSplitLeft,
441	"split-up":              ActionSplitUp,
442	"split-down":            ActionSplitDown,
443	"close-pane":            ActionClosePane,
444	"stack-pane":            ActionStackPane,
445	"move-pane-to-tab":      ActionMovePaneToTab,
446	"move-pane-to-next-tab": ActionMovePaneToNextTab,
447
448	"consume-or-expel-left":  ActionConsumeOrExpelLeft,
449	"consume-or-expel-right": ActionConsumeOrExpelRight,
450	"consume-or-expel-up":    ActionConsumeOrExpelUp,
451	"consume-or-expel-down":  ActionConsumeOrExpelDown,
452
453	// Focus navigation
454	"focus-right": ActionFocusRight,
455	"focus-left":  ActionFocusLeft,
456	"focus-up":    ActionFocusUp,
457	"focus-down":  ActionFocusDown,
458
459	// Stack navigation
460	"stack-nav-up":   ActionStackNavUp,
461	"stack-up":       ActionStackNavUp,
462	"stack-nav-down": ActionStackNavDown,
463	"stack-down":     ActionStackNavDown,
464
465	// Resize actions
466	"resize-increase-left":  ActionResizeIncreaseLeft,
467	"resize-increase-right": ActionResizeIncreaseRight,
468	"resize-increase-up":    ActionResizeIncreaseUp,
469	"resize-increase-down":  ActionResizeIncreaseDown,
470	"resize-decrease-left":  ActionResizeDecreaseLeft,
471	"resize-decrease-right": ActionResizeDecreaseRight,
472	"resize-decrease-up":    ActionResizeDecreaseUp,
473	"resize-decrease-down":  ActionResizeDecreaseDown,
474	"resize-increase":       ActionResizeIncrease,
475	"resize-decrease":       ActionResizeDecrease,
476
477	// Session actions
478	"session-manager": ActionOpenSessionManager,
479}
480
481// mapConfigAction maps config action names to Action constants.
482func mapConfigAction(configAction string) Action {
483	if configAction == "cancel" || configAction == "confirm" {
484		return ActionExitMode
485	}
486	return configActionToAction[configAction]
487}
488
489// ParseKeyString converts a config key string like "ctrl+t" to a KeyBinding.
490// Returns false if the string cannot be parsed.
491func ParseKeyString(s string) (KeyBinding, bool) {
492	if s == "" {
493		return KeyBinding{}, false
494	}
495
496	s = strings.TrimSpace(s)
497	if s == "" {
498		return KeyBinding{}, false
499	}
500	if s == "+" {
501		return KeyBinding{Keyval: uint(gdk.KEY_plus), Modifiers: ModNone}, true
502	}
503
504	parts := strings.Split(s, "+")
505
506	var modifiers Modifier
507	var keyPart string
508
509	for _, part := range parts {
510		part = strings.TrimSpace(part)
511		if part == "" {
512			continue
513		}
514
515		lower := strings.ToLower(part)
516		switch lower {
517		case "ctrl", "control":
518			modifiers |= ModCtrl
519		case "shift":
520			modifiers |= ModShift
521		case "alt":
522			modifiers |= ModAlt
523		default:
524			if keyPart != "" {
525				return KeyBinding{}, false
526			}
527			keyPart = part
528		}
529	}
530
531	// Allow parsing "ctrl++" / "alt+shift++" where the key is "+".
532	if keyPart == "" && strings.HasSuffix(s, "++") {
533		keyPart = "+"
534	}
535
536	if keyPart == "" {
537		return KeyBinding{}, false
538	}
539
540	// Treat uppercase single-letter keys as Shift+<letter>.
541	if len(keyPart) == 1 && keyPart[0] >= 'A' && keyPart[0] <= 'Z' {
542		modifiers |= ModShift
543		keyPart = strings.ToLower(keyPart)
544	}
545
546	keyval, ok := stringToKeyval(keyPart)
547	if !ok {
548		return KeyBinding{}, false
549	}
550
551	return KeyBinding{
552		Keyval:    keyval,
553		Modifiers: modifiers,
554	}, true
555}
556
557// stringToKeyval converts a key name to its GDK keyval.
558func stringToKeyval(s string) (uint, bool) {
559	if s == "" {
560		return 0, false
561	}
562
563	if keyval, ok := keyvalByName[strings.ToLower(s)]; ok {
564		return keyval, true
565	}
566
567	// Single letter keys (a-z)
568	if len(s) == 1 && s[0] >= 'a' && s[0] <= 'z' {
569		// ASCII lowercase a=97, which matches gdk.KEY_a
570		return uint(s[0]), true
571	}
572
573	return 0, false
574}
575
576// Lookup finds an action for the given key binding in the appropriate table.
577// It first checks the mode-specific table, then falls back to global.
578func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) {
579	// Normalize the binding modifiers
580	binding.Modifiers &= modifierMask
581
582	// Check mode-specific table first
583	var modeTable ShortcutTable
584	switch mode {
585	case ModeTab:
586		modeTable = s.TabMode
587	case ModePane:
588		modeTable = s.PaneMode
589	case ModeResize:
590		modeTable = s.ResizeMode
591	case ModeSession:
592		modeTable = s.SessionMode
593	}
594
595	if modeTable != nil {
596		if action, ok := modeTable[binding]; ok {
597			return action, true
598		}
599	}
600
601	// Fall back to global
602	if action, ok := s.Global[binding]; ok {
603		return action, true
604	}
605
606	return "", false
607}
608
609// ShouldAutoExitMode returns true if the action should cause modal mode to exit.
610func ShouldAutoExitMode(action Action) bool {
611	switch action {
612	case ActionNewTab, ActionCloseTab, ActionRenameTab,
613		ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown,
614		ActionClosePane, ActionStackPane,
615		ActionMovePaneToTab, ActionMovePaneToNextTab,
616		ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionConsumeOrExpelUp, ActionConsumeOrExpelDown,
617		ActionOpenSessionManager:
618		return true
619	default:
620		return false
621	}
622}

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