v0.30.0 eb3068f

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/domain/entity"
10	"github.com/bnema/dumber/internal/logging"
11	"github.com/bnema/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	ActionEjectPaneToWindow Action = "eject_pane_to_window"
141
142	ActionConsumeOrExpelLeft  Action = "consume_or_expel_left"
143	ActionConsumeOrExpelRight Action = "consume_or_expel_right"
144	ActionConsumeOrExpelUp    Action = "consume_or_expel_up"
145	ActionConsumeOrExpelDown  Action = "consume_or_expel_down"
146
147	// Pane focus navigation
148	ActionFocusRight Action = "focus_right"
149	ActionFocusLeft  Action = "focus_left"
150	ActionFocusUp    Action = "focus_up"
151	ActionFocusDown  Action = "focus_down"
152
153	// Resize actions (modal)
154	ActionResizeIncreaseLeft  Action = "resize_increase_left"
155	ActionResizeIncreaseRight Action = "resize_increase_right"
156	ActionResizeIncreaseUp    Action = "resize_increase_up"
157	ActionResizeIncreaseDown  Action = "resize_increase_down"
158	ActionResizeDecreaseLeft  Action = "resize_decrease_left"
159	ActionResizeDecreaseRight Action = "resize_decrease_right"
160	ActionResizeDecreaseUp    Action = "resize_decrease_up"
161	ActionResizeDecreaseDown  Action = "resize_decrease_down"
162	ActionResizeIncrease      Action = "resize_increase"
163	ActionResizeDecrease      Action = "resize_decrease"
164
165	// Stack navigation (within stacked panes)
166	ActionStackNavUp   Action = "stack_nav_up"
167	ActionStackNavDown Action = "stack_nav_down"
168
169	// Page navigation
170	ActionGoBack     Action = "go_back"
171	ActionGoForward  Action = "go_forward"
172	ActionReload     Action = "reload"
173	ActionHardReload Action = "hard_reload"
174	ActionStop       Action = "stop"
175	ActionPrintPage  Action = "print_page"
176
177	// Zoom
178	ActionZoomIn    Action = "zoom_in"
179	ActionZoomOut   Action = "zoom_out"
180	ActionZoomReset Action = "zoom_reset"
181
182	// UI
183	ActionOpenOmnibox               Action = "open_omnibox"
184	ActionOpenFind                  Action = "open_find"
185	ActionFindNext                  Action = "find_next"
186	ActionFindPrev                  Action = "find_prev"
187	ActionCloseFind                 Action = "close_find"
188	ActionOpenDevTools              Action = "open_devtools"
189	ActionToggleFullscreen          Action = "toggle_fullscreen"
190	ActionToggleFloatingPane        Action = "toggle_floating_pane"
191	ActionToggleHistorySystemView   Action = "toggle_history_systemview"
192	ActionToggleFavoritesSystemView Action = "toggle_favorites_systemview"
193	ActionToggleConfigSystemView    Action = "toggle_config_systemview"
194
195	// Clipboard
196	ActionCopyURL Action = "copy_url"
197
198	// Session management
199	ActionOpenSessionManager Action = "open_session_manager"
200
201	// Application
202	ActionQuit Action = "quit"
203)
204
205const floatingProfileActionPrefix = "open_floating_profile:"
206const floatingProfileActionSeparator = "\u241F"
207
208// ShortcutTable maps KeyBinding to Action.
209type ShortcutTable map[KeyBinding]Action
210
211// ShortcutSet holds all shortcut tables organized by context.
212type ShortcutSet struct {
213	// Global shortcuts are always active regardless of mode.
214	Global ShortcutTable
215	// TabMode shortcuts are only active in tab mode.
216	TabMode ShortcutTable
217	// PaneMode shortcuts are only active in pane mode.
218	PaneMode ShortcutTable
219	// SessionMode shortcuts are only active in session mode.
220	SessionMode ShortcutTable
221	// ResizeMode shortcuts are only active in resize mode.
222	ResizeMode ShortcutTable
223}
224
225// NewShortcutSet creates a ShortcutSet from workspace and session configuration.
226// workspace may be nil (workspace-specific shortcuts will be empty).
227// session may be nil (session mode shortcuts will be empty).
228func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) *ShortcutSet {
229	log := logging.FromContext(ctx)
230	set := &ShortcutSet{
231		Global:      make(ShortcutTable),
232		TabMode:     make(ShortcutTable),
233		PaneMode:    make(ShortcutTable),
234		SessionMode: make(ShortcutTable),
235		ResizeMode:  make(ShortcutTable),
236	}
237
238	set.buildGlobalShortcutsFromParts(ctx, workspace, session)
239	if workspace != nil {
240		set.buildTabModeShortcuts(ctx, workspace)
241		set.buildPaneModeShortcuts(ctx, workspace)
242		set.buildResizeModeShortcuts(ctx, workspace)
243	}
244	if session != nil {
245		set.buildSessionModeShortcuts(ctx, session)
246	}
247
248	log.Debug().
249		Int("global", len(set.Global)).
250		Int("tab", len(set.TabMode)).
251		Int("pane", len(set.PaneMode)).
252		Int("resize", len(set.ResizeMode)).
253		Int("session", len(set.SessionMode)).
254		Msg("shortcuts registered")
255
256	return set
257}
258
259// buildGlobalShortcutsFromParts populates global shortcuts from workspace and session configs.
260func (s *ShortcutSet) buildGlobalShortcutsFromParts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
261	s.registerActivationShortcutsFromParts(ctx, workspace, session)
262	s.registerConfiguredShortcuts(workspace)
263	s.registerStandardShortcuts()
264	s.registerPaneNavigationShortcuts()
265	s.registerTabSwitchShortcuts()
266	s.registerFloatingProfileShortcutsFromWorkspace(ctx, workspace)
267}
268
269// buildTabModeShortcuts populates tab mode shortcuts from config.
270func (s *ShortcutSet) buildTabModeShortcuts(ctx context.Context, cfg *entity.WorkspaceConfig) {
271	s.buildModeShortcuts(ctx, cfg.TabMode.GetKeyBindings(), s.TabMode, "tab")
272}
273
274// buildPaneModeShortcuts populates pane mode shortcuts from config.
275func (s *ShortcutSet) buildPaneModeShortcuts(ctx context.Context, cfg *entity.WorkspaceConfig) {
276	s.buildModeShortcuts(ctx, cfg.PaneMode.GetKeyBindings(), s.PaneMode, "pane")
277}
278
279// buildSessionModeShortcuts populates session mode shortcuts from config.
280func (s *ShortcutSet) buildSessionModeShortcuts(ctx context.Context, cfg *entity.SessionConfig) {
281	s.buildModeShortcuts(ctx, cfg.SessionMode.GetKeyBindings(), s.SessionMode, "session")
282}
283
284// buildResizeModeShortcuts populates resize mode shortcuts from config.
285func (s *ShortcutSet) buildResizeModeShortcuts(ctx context.Context, cfg *entity.WorkspaceConfig) {
286	s.buildModeShortcuts(ctx, cfg.ResizeMode.GetKeyBindings(), s.ResizeMode, "resize")
287}
288
289func (s *ShortcutSet) registerActivationShortcutsFromParts(
290	ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig,
291) {
292	log := logging.FromContext(ctx)
293	if workspace == nil {
294		// Still register session mode if available.
295		if session != nil {
296			if binding, ok := ParseKeyString(session.SessionMode.ActivationShortcut); ok {
297				s.Global[binding] = ActionEnterSessionMode
298				log.Trace().
299					Str("shortcut", session.SessionMode.ActivationShortcut).
300					Uint("keyval", binding.Keyval).
301					Uint("mod", uint(binding.Modifiers)).
302					Msg("session mode activation registered")
303			} else {
304				log.Warn().Str("shortcut", session.SessionMode.ActivationShortcut).Msg("failed to parse session mode activation shortcut")
305			}
306		}
307		return
308	}
309	if binding, ok := ParseKeyString(workspace.TabMode.ActivationShortcut); ok {
310		s.Global[binding] = ActionEnterTabMode
311		log.Trace().
312			Str("shortcut", workspace.TabMode.ActivationShortcut).
313			Uint("keyval", binding.Keyval).
314			Uint("mod", uint(binding.Modifiers)).
315			Msg("tab mode activation registered")
316	} else {
317		log.Warn().Str("shortcut", workspace.TabMode.ActivationShortcut).Msg("failed to parse tab mode activation shortcut")
318	}
319	if binding, ok := ParseKeyString(workspace.PaneMode.ActivationShortcut); ok {
320		s.Global[binding] = ActionEnterPaneMode
321		log.Trace().
322			Str("shortcut", workspace.PaneMode.ActivationShortcut).
323			Uint("keyval", binding.Keyval).
324			Uint("mod", uint(binding.Modifiers)).
325			Msg("pane mode activation registered")
326	} else {
327		log.Warn().Str("shortcut", workspace.PaneMode.ActivationShortcut).Msg("failed to parse pane mode activation shortcut")
328	}
329	if session != nil {
330		if binding, ok := ParseKeyString(session.SessionMode.ActivationShortcut); ok {
331			s.Global[binding] = ActionEnterSessionMode
332			log.Trace().
333				Str("shortcut", session.SessionMode.ActivationShortcut).
334				Uint("keyval", binding.Keyval).
335				Uint("mod", uint(binding.Modifiers)).
336				Msg("session mode activation registered")
337		} else {
338			log.Warn().Str("shortcut", session.SessionMode.ActivationShortcut).Msg("failed to parse session mode activation shortcut")
339		}
340	}
341	if binding, ok := ParseKeyString(workspace.ResizeMode.ActivationShortcut); ok {
342		s.Global[binding] = ActionEnterResizeMode
343		log.Trace().
344			Str("shortcut", workspace.ResizeMode.ActivationShortcut).
345			Uint("keyval", binding.Keyval).
346			Uint("mod", uint(binding.Modifiers)).
347			Msg("resize mode activation registered")
348	} else {
349		log.Warn().Str("shortcut", workspace.ResizeMode.ActivationShortcut).Msg("failed to parse resize mode activation shortcut")
350	}
351}
352
353func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) {
354	if cfg == nil {
355		return
356	}
357	// Note: Ctrl+T is NOT registered globally - it enters tab mode.
358	// In tab mode, use:
359	//   n = new tab
360	//   x = close tab
361	//   l/tab = next tab
362	//   h/shift+tab = previous tab
363	//   r = rename tab
364	// This follows Zellij-style modal keyboard interface.
365	//
366	// However, these standard browser shortcuts ARE global.
367	for actionName, actionBinding := range cfg.Shortcuts.Actions {
368		action, ok := configActionToAction[actionName]
369		if !ok {
370			continue
371		}
372		for _, keyStr := range actionBinding.Keys {
373			if binding, ok := ParseKeyString(keyStr); ok {
374				s.Global[binding] = action
375			}
376		}
377	}
378}
379
380func (s *ShortcutSet) registerFloatingProfileShortcutsFromWorkspace(ctx context.Context, workspace *entity.WorkspaceConfig) {
381	occupied := make(map[KeyBinding]Action, len(s.Global))
382	for binding, action := range s.Global {
383		occupied[binding] = action
384	}
385
386	for _, shortcut := range collectFloatingProfileShortcutsFromWorkspace(ctx, workspace, occupied) {
387		s.Global[shortcut.Binding] = shortcut.Action
388	}
389}
390
391type floatingProfileShortcut struct {
392	Binding KeyBinding
393	Action  Action
394}
395
396// collectFloatingProfileShortcutsFromWorkspace collects floating profile shortcuts from a workspace config.
397func collectFloatingProfileShortcutsFromWorkspace(
398	ctx context.Context,
399	workspace *entity.WorkspaceConfig,
400	occupied map[KeyBinding]Action,
401) []floatingProfileShortcut {
402	if workspace == nil {
403		return nil
404	}
405
406	log := logging.FromContext(ctx)
407	reserveGlobalOnlyShortcutBindings(occupied)
408	result := make([]floatingProfileShortcut, 0)
409	profileNames := make([]string, 0, len(workspace.FloatingPane.Profiles))
410	for profileName := range workspace.FloatingPane.Profiles {
411		profileNames = append(profileNames, profileName)
412	}
413	sort.Strings(profileNames)
414
415	for _, profileName := range profileNames {
416		profile := workspace.FloatingPane.Profiles[profileName]
417		url := strings.TrimSpace(profile.URL)
418		if url == "" {
419			log.Warn().Str("profile", profileName).Msg("floating profile missing URL, skipping")
420			continue
421		}
422
423		action := NewFloatingProfileAction(profileName, url)
424		keys := append([]string(nil), profile.Keys...)
425		sort.Strings(keys)
426		for _, keyStr := range keys {
427			binding, ok := ParseKeyString(keyStr)
428			if !ok {
429				log.Warn().Str("profile", profileName).Str("shortcut", keyStr).Msg("failed to parse floating profile shortcut")
430				continue
431			}
432
433			if existing, exists := occupied[binding]; exists {
434				log.Warn().
435					Str("profile", profileName).
436					Str("shortcut", keyStr).
437					Str("existing_action", string(existing)).
438					Msg("floating profile shortcut conflicts with existing global shortcut, skipping")
439				continue
440			}
441
442			occupied[binding] = action
443			result = append(result, floatingProfileShortcut{Binding: binding, Action: action})
444		}
445	}
446
447	return result
448}
449
450func reserveGlobalOnlyShortcutBindings(occupied map[KeyBinding]Action) {
451	if occupied == nil {
452		return
453	}
454
455	tabIndexActions := []Action{
456		ActionSwitchTabIndex1,
457		ActionSwitchTabIndex2,
458		ActionSwitchTabIndex3,
459		ActionSwitchTabIndex4,
460		ActionSwitchTabIndex5,
461		ActionSwitchTabIndex6,
462		ActionSwitchTabIndex7,
463		ActionSwitchTabIndex8,
464		ActionSwitchTabIndex9,
465	}
466
467	// Assumes gdk.KEY_1..gdk.KEY_9 are contiguous (ASCII '1'..'9').
468	// If that ever changes on a platform, map digits explicitly.
469	for i, action := range tabIndexActions {
470		binding := KeyBinding{Keyval: uint(gdk.KEY_1) + uint(i), Modifiers: ModAlt}
471		if _, exists := occupied[binding]; exists {
472			continue
473		}
474		occupied[binding] = action
475	}
476
477	altZero := KeyBinding{Keyval: uint(gdk.KEY_0), Modifiers: ModAlt}
478	if _, exists := occupied[altZero]; !exists {
479		occupied[altZero] = ActionSwitchTabIndex10
480	}
481
482	altTab := KeyBinding{Keyval: uint(gdk.KEY_Tab), Modifiers: ModAlt}
483	if _, exists := occupied[altTab]; !exists {
484		occupied[altTab] = ActionSwitchLastTab
485	}
486}
487
488func (s *ShortcutSet) registerStandardShortcuts() {
489	s.Global[KeyBinding{uint(gdk.KEY_l), ModCtrl}] = ActionOpenOmnibox
490	s.Global[KeyBinding{uint(gdk.KEY_f), ModCtrl}] = ActionOpenFind
491	s.Global[KeyBinding{uint(gdk.KEY_F3), ModNone}] = ActionFindNext
492	s.Global[KeyBinding{uint(gdk.KEY_F3), ModShift}] = ActionFindPrev
493	s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl}] = ActionFindNext
494	s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl | ModShift}] = ActionFindPrev
495	s.Global[KeyBinding{uint(gdk.KEY_r), ModCtrl}] = ActionReload
496	s.Global[KeyBinding{uint('r'), ModCtrl | ModShift}] = ActionHardReload
497	s.Global[KeyBinding{uint(gdk.KEY_F5), ModNone}] = ActionReload
498	s.Global[KeyBinding{uint(gdk.KEY_F5), ModCtrl}] = ActionHardReload
499	s.Global[KeyBinding{uint(gdk.KEY_F12), ModNone}] = ActionOpenDevTools
500	s.Global[KeyBinding{uint(gdk.KEY_Left), ModCtrl}] = ActionGoBack
501	s.Global[KeyBinding{uint(gdk.KEY_Right), ModCtrl}] = ActionGoForward
502	s.Global[KeyBinding{uint(gdk.KEY_plus), ModCtrl}] = ActionZoomIn
503	s.Global[KeyBinding{uint(gdk.KEY_equal), ModCtrl}] = ActionZoomIn // Ctrl+= (no shift needed)
504	s.Global[KeyBinding{uint(gdk.KEY_minus), ModCtrl}] = ActionZoomOut
505	s.Global[KeyBinding{uint(gdk.KEY_0), ModCtrl}] = ActionZoomReset
506	s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit
507	s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen
508	s.Global[KeyBinding{uint('c'), ModCtrl | ModShift}] = ActionCopyURL
509	s.Global[KeyBinding{uint('p'), ModCtrl | ModShift}] = ActionPrintPage
510	// Session management - direct shortcut to open session manager
511	s.Global[KeyBinding{uint(gdk.KEY_s), ModCtrl | ModShift}] = ActionOpenSessionManager
512}
513
514func (s *ShortcutSet) registerPaneNavigationShortcuts() {
515	s.Global[KeyBinding{uint(gdk.KEY_h), ModAlt}] = ActionFocusLeft
516	s.Global[KeyBinding{uint(gdk.KEY_l), ModAlt}] = ActionFocusRight
517	s.Global[KeyBinding{uint(gdk.KEY_k), ModAlt}] = ActionFocusUp
518	s.Global[KeyBinding{uint(gdk.KEY_j), ModAlt}] = ActionFocusDown
519
520	s.Global[KeyBinding{uint(gdk.KEY_Left), ModAlt}] = ActionFocusLeft
521	s.Global[KeyBinding{uint(gdk.KEY_Right), ModAlt}] = ActionFocusRight
522	s.Global[KeyBinding{uint(gdk.KEY_Up), ModAlt}] = ActionFocusUp
523	s.Global[KeyBinding{uint(gdk.KEY_Down), ModAlt}] = ActionFocusDown
524}
525
526func (s *ShortcutSet) registerTabSwitchShortcuts() {
527	// NOTE: Alt+1-9, Alt+0, and Alt+Tab are now handled by GlobalShortcutHandler
528	// using GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL.
529	// This is necessary because WebKitGTK's WebView consumes these key events
530	// before they reach the EventControllerKey in capture phase.
531	//
532	// Only Alt+Shift+Tab remains here as a fallback binding.
533	s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt | ModShift}] = ActionSwitchLastTab
534}
535
536func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[string]string, dest map[KeyBinding]Action, mode string) {
537	log := logging.FromContext(ctx)
538	var registered, parseErrors, unknownActions int
539	for key, configAction := range bindings {
540		if binding, ok := ParseKeyString(key); ok {
541			if action := mapConfigAction(configAction); action != "" {
542				dest[binding] = action
543				registered++
544				log.Trace().
545					Str("key", key).
546					Str("configAction", configAction).
547					Str("action", string(action)).
548					Uint("keyval", binding.Keyval).
549					Msg(mode + " mode shortcut registered")
550			} else {
551				unknownActions++
552				log.Warn().Str("key", key).Str("configAction", configAction).Msg("unknown config action in " + mode + " mode")
553			}
554		} else {
555			parseErrors++
556			log.Warn().Str("key", key).Msg("failed to parse " + mode + " mode key")
557		}
558	}
559	log.Debug().
560		Int("registered", registered).
561		Int("parseErrors", parseErrors).
562		Int("unknownActions", unknownActions).
563		Msg(mode + " mode shortcuts built")
564}
565
566var configActionToAction = map[string]Action{
567	"toggle_floating_pane":        ActionToggleFloatingPane,
568	"toggle-floating-pane":        ActionToggleFloatingPane,
569	"toggle_history_systemview":   ActionToggleHistorySystemView,
570	"toggle-history-systemview":   ActionToggleHistorySystemView,
571	"toggle_favorites_systemview": ActionToggleFavoritesSystemView,
572	"toggle-favorites-systemview": ActionToggleFavoritesSystemView,
573	"toggle_config_systemview":    ActionToggleConfigSystemView,
574	"toggle-config-systemview":    ActionToggleConfigSystemView,
575
576	// Tab actions
577	"new_tab":      ActionNewTab,
578	"new-tab":      ActionNewTab,
579	"close_tab":    ActionCloseTab,
580	"close-tab":    ActionCloseTab,
581	"next_tab":     ActionNextTab,
582	"next-tab":     ActionNextTab,
583	"previous_tab": ActionPreviousTab,
584	"previous-tab": ActionPreviousTab,
585	"rename_tab":   ActionRenameTab,
586	"rename-tab":   ActionRenameTab,
587
588	// Pane actions
589	"split_right":           ActionSplitRight,
590	"split-right":           ActionSplitRight,
591	"split_left":            ActionSplitLeft,
592	"split-left":            ActionSplitLeft,
593	"split_up":              ActionSplitUp,
594	"split-up":              ActionSplitUp,
595	"split_down":            ActionSplitDown,
596	"split-down":            ActionSplitDown,
597	"close_pane":            ActionClosePane,
598	"close-pane":            ActionClosePane,
599	"stack_pane":            ActionStackPane,
600	"stack-pane":            ActionStackPane,
601	"move_pane_to_tab":      ActionMovePaneToTab,
602	"move-pane-to-tab":      ActionMovePaneToTab,
603	"move_pane_to_next_tab": ActionMovePaneToNextTab,
604	"move-pane-to-next-tab": ActionMovePaneToNextTab,
605	"eject_pane_to_window":  ActionEjectPaneToWindow,
606	"eject-pane-to-window":  ActionEjectPaneToWindow,
607
608	"consume_or_expel_left":  ActionConsumeOrExpelLeft,
609	"consume-or-expel-left":  ActionConsumeOrExpelLeft,
610	"consume_or_expel_right": ActionConsumeOrExpelRight,
611	"consume-or-expel-right": ActionConsumeOrExpelRight,
612	"consume_or_expel_up":    ActionConsumeOrExpelUp,
613	"consume-or-expel-up":    ActionConsumeOrExpelUp,
614	"consume_or_expel_down":  ActionConsumeOrExpelDown,
615	"consume-or-expel-down":  ActionConsumeOrExpelDown,
616
617	// Focus navigation
618	"focus-right": ActionFocusRight,
619	"focus-left":  ActionFocusLeft,
620	"focus-up":    ActionFocusUp,
621	"focus-down":  ActionFocusDown,
622
623	// Stack navigation
624	"stack-nav-up":   ActionStackNavUp,
625	"stack-up":       ActionStackNavUp,
626	"stack-nav-down": ActionStackNavDown,
627	"stack-down":     ActionStackNavDown,
628
629	// Resize actions
630	"resize-increase-left":  ActionResizeIncreaseLeft,
631	"resize-increase-right": ActionResizeIncreaseRight,
632	"resize-increase-up":    ActionResizeIncreaseUp,
633	"resize-increase-down":  ActionResizeIncreaseDown,
634	"resize-decrease-left":  ActionResizeDecreaseLeft,
635	"resize-decrease-right": ActionResizeDecreaseRight,
636	"resize-decrease-up":    ActionResizeDecreaseUp,
637	"resize-decrease-down":  ActionResizeDecreaseDown,
638	"resize-increase":       ActionResizeIncrease,
639	"resize-decrease":       ActionResizeDecrease,
640
641	// Session actions
642	"session-manager": ActionOpenSessionManager,
643}
644
645// FloatingProfileTarget carries the session identity and URL for a floating profile action.
646type FloatingProfileTarget struct {
647	SessionID string
648	URL       string
649}
650
651// NewFloatingProfileAction builds an action value carrying a floating profile session and URL.
652func NewFloatingProfileAction(profileName, url string) Action {
653	sessionID := strings.TrimSpace(profileName)
654	trimmedURL := strings.TrimSpace(url)
655	if sessionID == "" {
656		sessionID = trimmedURL
657	}
658	return Action(floatingProfileActionPrefix + sessionID + floatingProfileActionSeparator + trimmedURL)
659}
660
661// ParseFloatingProfileTarget extracts the session identity and target URL.
662func ParseFloatingProfileTarget(action Action) (FloatingProfileTarget, bool) {
663	value := string(action)
664	if !strings.HasPrefix(value, floatingProfileActionPrefix) {
665		return FloatingProfileTarget{}, false
666	}
667
668	payload := strings.TrimPrefix(value, floatingProfileActionPrefix)
669	sessionID := ""
670	url := payload
671	if parts := strings.SplitN(payload, floatingProfileActionSeparator, 2); len(parts) == 2 {
672		sessionID = strings.TrimSpace(parts[0])
673		url = parts[1]
674	}
675
676	url = strings.TrimSpace(url)
677	if url == "" {
678		return FloatingProfileTarget{}, false
679	}
680	if sessionID == "" {
681		sessionID = url
682	}
683
684	return FloatingProfileTarget{SessionID: sessionID, URL: url}, true
685}
686
687// ParseFloatingProfileAction extracts the target URL from a floating profile action.
688func ParseFloatingProfileAction(action Action) (string, bool) {
689	target, ok := ParseFloatingProfileTarget(action)
690	if !ok {
691		return "", false
692	}
693	return target.URL, true
694}
695
696// mapConfigAction maps config action names to Action constants.
697func mapConfigAction(configAction string) Action {
698	if configAction == "cancel" || configAction == "confirm" {
699		return ActionExitMode
700	}
701	return configActionToAction[configAction]
702}
703
704// ParseKeyString converts a config key string like "ctrl+t" to a KeyBinding.
705// Returns false if the string cannot be parsed.
706func ParseKeyString(s string) (KeyBinding, bool) {
707	if s == "" {
708		return KeyBinding{}, false
709	}
710
711	s = strings.TrimSpace(s)
712	if s == "" {
713		return KeyBinding{}, false
714	}
715	if s == "+" {
716		return KeyBinding{Keyval: uint(gdk.KEY_plus), Modifiers: ModNone}, true
717	}
718
719	parts := strings.Split(s, "+")
720
721	var modifiers Modifier
722	var keyPart string
723
724	for _, part := range parts {
725		part = strings.TrimSpace(part)
726		if part == "" {
727			continue
728		}
729
730		lower := strings.ToLower(part)
731		switch lower {
732		case "ctrl", "control":
733			modifiers |= ModCtrl
734		case "shift":
735			modifiers |= ModShift
736		case "alt":
737			modifiers |= ModAlt
738		default:
739			if keyPart != "" {
740				return KeyBinding{}, false
741			}
742			keyPart = part
743		}
744	}
745
746	// Allow parsing "ctrl++" / "alt+shift++" where the key is "+".
747	if keyPart == "" && strings.HasSuffix(s, "++") {
748		keyPart = "+"
749	}
750
751	if keyPart == "" {
752		return KeyBinding{}, false
753	}
754
755	// Treat uppercase single-letter keys as Shift+<letter>.
756	if len(keyPart) == 1 && keyPart[0] >= 'A' && keyPart[0] <= 'Z' {
757		modifiers |= ModShift
758		keyPart = strings.ToLower(keyPart)
759	}
760
761	keyval, ok := stringToKeyval(keyPart)
762	if !ok {
763		return KeyBinding{}, false
764	}
765
766	return KeyBinding{
767		Keyval:    keyval,
768		Modifiers: modifiers,
769	}, true
770}
771
772// stringToKeyval converts a key name to its GDK keyval.
773func stringToKeyval(s string) (uint, bool) {
774	if s == "" {
775		return 0, false
776	}
777
778	if keyval, ok := keyvalByName[strings.ToLower(s)]; ok {
779		return keyval, true
780	}
781
782	// Single letter keys (a-z)
783	if len(s) == 1 && s[0] >= 'a' && s[0] <= 'z' {
784		// ASCII lowercase a=97, which matches gdk.KEY_a
785		return uint(s[0]), true
786	}
787
788	return 0, false
789}
790
791// Lookup finds an action for the given key binding in the appropriate table.
792// It first checks the mode-specific table, then falls back to global.
793func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) {
794	// Normalize the binding modifiers
795	binding.Modifiers &= modifierMask
796
797	// Check mode-specific table first
798	var modeTable ShortcutTable
799	switch mode {
800	case ModeTab:
801		modeTable = s.TabMode
802	case ModePane:
803		modeTable = s.PaneMode
804	case ModeResize:
805		modeTable = s.ResizeMode
806	case ModeSession:
807		modeTable = s.SessionMode
808	}
809
810	if modeTable != nil {
811		if action, ok := modeTable[binding]; ok {
812			return action, true
813		}
814	}
815
816	// Fall back to global
817	if action, ok := s.Global[binding]; ok {
818		return action, true
819	}
820
821	return "", false
822}
823
824// ShouldAutoExitMode returns true if the action should cause modal mode to exit.
825func ShouldAutoExitMode(action Action) bool {
826	switch action {
827	case ActionNewTab, ActionCloseTab, ActionRenameTab,
828		ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown,
829		ActionClosePane, ActionStackPane,
830		ActionMovePaneToTab, ActionMovePaneToNextTab, ActionEjectPaneToWindow,
831		ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionConsumeOrExpelUp, ActionConsumeOrExpelDown,
832		ActionOpenSessionManager:
833		return true
834	default:
835		return false
836	}
837}

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