v0.32.0 0617278

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

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