v0.20.1 d9a391f

shortcuts.go RawHistoryFolder

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

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