v0.27.2 5960dd8

shortcuts.go RawHistoryFolder

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

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