v0.31.0 bf0756b

shortcuts.go RawHistoryFolder

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

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