v0.28.0 3cb3770

global_shortcuts.go RawHistoryFolder

1// Package input provides keyboard event handling and modal input mode management.
2package input
3
4import (
5	"context"
6	"fmt"
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	"github.com/bnema/puregotk/v4/glib"
13	"github.com/bnema/puregotk/v4/gtk"
14)
15
16// GlobalShortcutHandler manages keyboard shortcuts that must work globally,
17// even when WebView has focus. It uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL
18// to intercept shortcuts before they reach the WebView.
19type GlobalShortcutHandler struct {
20	controller *gtk.ShortcutController
21	window     *gtk.ApplicationWindow
22	kbHandler  *KeyboardHandler
23	onAction   ActionHandler
24	ctx        context.Context
25	registered map[KeyBinding]Action
26
27	// Keep references to callbacks to prevent GC from collecting them
28	callbacks []gtk.ShortcutFunc
29}
30
31// NewGlobalShortcutHandler creates a new global shortcut handler for shortcuts
32// that need to work even when WebView has focus (like Alt+1-9 for tab switching).
33func NewGlobalShortcutHandler(
34	ctx context.Context,
35	window *gtk.ApplicationWindow,
36	workspace *entity.WorkspaceConfig,
37	session *entity.SessionConfig,
38	kbHandler *KeyboardHandler,
39	onAction ActionHandler,
40) *GlobalShortcutHandler {
41	log := logging.FromContext(ctx)
42	log.Debug().Msg("creating global shortcut handler")
43
44	h := &GlobalShortcutHandler{
45		controller: gtk.NewShortcutController(),
46		window:     window,
47		kbHandler:  kbHandler,
48		onAction:   onAction,
49		ctx:        ctx,
50		callbacks:  make([]gtk.ShortcutFunc, 0),
51		registered: make(map[KeyBinding]Action),
52	}
53
54	if h.controller == nil {
55		log.Error().Msg("failed to create shortcut controller")
56		return nil
57	}
58
59	// Set global scope - this is the key to making shortcuts work
60	// even when WebView has focus
61	h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
62
63	// Register Alt+1 through Alt+9 for tab switching
64	tabActions := []Action{
65		ActionSwitchTabIndex1,
66		ActionSwitchTabIndex2,
67		ActionSwitchTabIndex3,
68		ActionSwitchTabIndex4,
69		ActionSwitchTabIndex5,
70		ActionSwitchTabIndex6,
71		ActionSwitchTabIndex7,
72		ActionSwitchTabIndex8,
73		ActionSwitchTabIndex9,
74	}
75
76	for i, action := range tabActions {
77		keyval := uint(gdk.KEY_1) + uint(i) // KEY_1, KEY_2, ..., KEY_9
78		h.registerShortcut(keyval, gdk.AltMaskValue, action)
79		log.Trace().
80			Uint("keyval", keyval).
81			Str("action", string(action)).
82			Msg("registered global shortcut")
83	}
84
85	// Alt+0 for tab 10
86	h.registerShortcut(uint(gdk.KEY_0), gdk.AltMaskValue, ActionSwitchTabIndex10)
87	log.Trace().
88		Uint("keyval", uint(gdk.KEY_0)).
89		Str("action", string(ActionSwitchTabIndex10)).
90		Msg("registered global shortcut")
91
92	// Alt+Tab for switching to last active tab
93	h.registerShortcut(uint(gdk.KEY_Tab), gdk.AltMaskValue, ActionSwitchLastTab)
94	log.Trace().
95		Uint("keyval", uint(gdk.KEY_Tab)).
96		Str("action", string(ActionSwitchLastTab)).
97		Msg("registered global shortcut")
98
99	// Ctrl+Shift+S for direct session manager access (needs global scope for WebView focus)
100	h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager)
101	log.Trace().
102		Uint("keyval", uint(gdk.KEY_s)).
103		Str("action", string(ActionOpenSessionManager)).
104		Msg("registered global shortcut")
105
106	if workspace != nil {
107		// Map action names to action constants for global shortcuts
108		actionMap := map[string]Action{
109			"toggle_floating_pane":   ActionToggleFloatingPane,
110			"toggle-floating-pane":   ActionToggleFloatingPane,
111			"consume_or_expel_left":  ActionConsumeOrExpelLeft,
112			"consume_or_expel_right": ActionConsumeOrExpelRight,
113			"consume_or_expel_up":    ActionConsumeOrExpelUp,
114			"consume_or_expel_down":  ActionConsumeOrExpelDown,
115		}
116
117		for actionName, actionBinding := range workspace.Shortcuts.Actions {
118			action, ok := actionMap[actionName]
119			if !ok {
120				continue
121			}
122			for _, keyStr := range actionBinding.Keys {
123				binding, ok := ParseKeyString(keyStr)
124				if !ok {
125					log.Warn().Str("shortcut", keyStr).Str("action", string(action)).Msg("failed to parse global shortcut")
126					continue
127				}
128				if h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action) {
129					log.Trace().Str("shortcut", keyStr).Str("action", string(action)).Msg("registered global shortcut")
130				}
131			}
132		}
133
134		occupied := make(map[KeyBinding]Action, len(h.registered))
135		for binding, action := range h.registered {
136			occupied[binding] = action
137		}
138		for _, shortcut := range collectFloatingProfileShortcutsFromWorkspace(ctx, workspace, occupied) {
139			if !h.registerShortcut(shortcut.Binding.Keyval, gdk.ModifierType(shortcut.Binding.Modifiers), shortcut.Action) {
140				continue
141			}
142			if url, ok := ParseFloatingProfileAction(shortcut.Action); ok {
143				log.Trace().
144					Str("shortcut", formatBinding(shortcut.Binding)).
145					Str("url", url).
146					Msg("registered floating profile global shortcut")
147			}
148		}
149
150		// Register all app-reserved shortcuts (mode activations, Ctrl+L, Ctrl+F, etc.)
151		// so they work even when WebView has focus.
152		shortcuts := NewShortcutSet(ctx, workspace, session)
153		for binding, action := range shortcuts.Global {
154			if _, exists := h.registered[binding]; exists {
155				continue
156			}
157			h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action)
158		}
159	}
160
161	// Attach to window
162	window.AddController(&h.controller.EventController)
163
164	log.Debug().
165		Int("shortcuts", len(h.callbacks)).
166		Msg("global shortcut handler created and attached")
167
168	return h
169}
170
171// registerShortcut creates and registers a single shortcut with the controller.
172func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.ModifierType, action Action) bool {
173	binding := KeyBinding{Keyval: keyval, Modifiers: Modifier(modifiers) & modifierMask}
174	if existing, exists := h.registered[binding]; exists {
175		logging.FromContext(h.ctx).Warn().
176			Str("existing_action", string(existing)).
177			Str("new_action", string(action)).
178			Str("shortcut", formatBinding(binding)).
179			Msg("global shortcut conflict, skipping")
180		return false
181	}
182
183	// Create trigger for this key combination
184	trigger := gtk.NewKeyvalTrigger(keyval, modifiers)
185	if trigger == nil {
186		logging.FromContext(h.ctx).Error().
187			Uint("keyval", keyval).
188			Msg("failed to create keyval trigger")
189		return false
190	}
191
192	// Create callback action
193	// We need to capture the action in the closure
194	actionToDispatch := action
195	callback := gtk.ShortcutFunc(func(_ uintptr, _ *glib.Variant, _ uintptr) bool {
196		log := logging.FromContext(h.ctx)
197		log.Debug().
198			Str("action", string(actionToDispatch)).
199			Msg("global shortcut triggered")
200
201		// Mode-enter/exit actions go through KeyboardHandler for modal state
202		if isModeAction(actionToDispatch) {
203			if h.kbHandler != nil {
204				h.kbHandler.DispatchAction(actionToDispatch)
205				return true
206			}
207			log.Warn().
208				Str("action", string(actionToDispatch)).
209				Msg("mode action triggered but keyboard handler not set, falling through to default handler")
210		}
211
212		if h.onAction != nil {
213			if err := h.onAction(h.ctx, actionToDispatch); err != nil {
214				log.Error().
215					Err(err).
216					Str("action", string(actionToDispatch)).
217					Msg("global shortcut action failed")
218			}
219		}
220		return true // Event consumed
221	})
222
223	// Store callback reference to prevent GC
224	h.callbacks = append(h.callbacks, callback)
225
226	// Create the shortcut action
227	shortcutAction := gtk.NewCallbackAction(&callback, 0, nil)
228	if shortcutAction == nil {
229		logging.FromContext(h.ctx).Error().
230			Uint("keyval", keyval).
231			Msg("failed to create callback action")
232		return false
233	}
234
235	// Create the shortcut combining trigger and action
236	shortcut := gtk.NewShortcut(&trigger.ShortcutTrigger, &shortcutAction.ShortcutAction)
237	if shortcut == nil {
238		logging.FromContext(h.ctx).Error().
239			Uint("keyval", keyval).
240			Msg("failed to create shortcut")
241		return false
242	}
243
244	// Add to controller
245	h.controller.AddShortcut(shortcut)
246	h.registered[binding] = action
247	return true
248}
249
250// ReloadShortcuts rebuilds global shortcuts from a new config.
251// It replaces the GTK shortcut controller to ensure stale shortcuts
252// are removed.
253func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
254	log := logging.FromContext(ctx)
255
256	if h.window == nil || h.controller == nil {
257		return
258	}
259
260	// Remove old controller from window
261	h.window.RemoveController(&h.controller.EventController)
262
263	// Create fresh controller
264	h.controller = gtk.NewShortcutController()
265	if h.controller == nil {
266		log.Error().Msg("failed to create shortcut controller during reload")
267		return
268	}
269	h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
270	h.callbacks = make([]gtk.ShortcutFunc, 0)
271	h.registered = make(map[KeyBinding]Action)
272
273	// Re-register hardcoded shortcuts (Alt+1-9, Alt+0, Alt+Tab, Ctrl+Shift+S)
274	tabActions := []Action{
275		ActionSwitchTabIndex1, ActionSwitchTabIndex2, ActionSwitchTabIndex3,
276		ActionSwitchTabIndex4, ActionSwitchTabIndex5, ActionSwitchTabIndex6,
277		ActionSwitchTabIndex7, ActionSwitchTabIndex8, ActionSwitchTabIndex9,
278	}
279	for i, action := range tabActions {
280		h.registerShortcut(uint(gdk.KEY_1)+uint(i), gdk.AltMaskValue, action)
281	}
282	h.registerShortcut(uint(gdk.KEY_0), gdk.AltMaskValue, ActionSwitchTabIndex10)
283	h.registerShortcut(uint(gdk.KEY_Tab), gdk.AltMaskValue, ActionSwitchLastTab)
284	h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager)
285
286	// Re-register config-driven shortcuts
287	if workspace != nil {
288		actionMap := map[string]Action{
289			"toggle_floating_pane":   ActionToggleFloatingPane,
290			"toggle-floating-pane":   ActionToggleFloatingPane,
291			"consume_or_expel_left":  ActionConsumeOrExpelLeft,
292			"consume_or_expel_right": ActionConsumeOrExpelRight,
293			"consume_or_expel_up":    ActionConsumeOrExpelUp,
294			"consume_or_expel_down":  ActionConsumeOrExpelDown,
295		}
296
297		for actionName, actionBinding := range workspace.Shortcuts.Actions {
298			action, ok := actionMap[actionName]
299			if !ok {
300				continue
301			}
302			for _, keyStr := range actionBinding.Keys {
303				binding, ok := ParseKeyString(keyStr)
304				if !ok {
305					continue
306				}
307				h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action)
308			}
309		}
310
311		occupied := make(map[KeyBinding]Action, len(h.registered))
312		for binding, action := range h.registered {
313			occupied[binding] = action
314		}
315		for _, shortcut := range collectFloatingProfileShortcutsFromWorkspace(ctx, workspace, occupied) {
316			if h.registerShortcut(shortcut.Binding.Keyval, gdk.ModifierType(shortcut.Binding.Modifiers), shortcut.Action) {
317				if url, ok := ParseFloatingProfileAction(shortcut.Action); ok {
318					log.Trace().Str("shortcut", formatBinding(shortcut.Binding)).Str("url", url).Msg("registered floating profile global shortcut")
319				}
320			}
321		}
322
323		shortcuts := NewShortcutSet(ctx, workspace, session)
324		for binding, action := range shortcuts.Global {
325			if _, exists := h.registered[binding]; exists {
326				continue
327			}
328			h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action)
329		}
330	}
331
332	// Attach new controller to window
333	h.window.AddController(&h.controller.EventController)
334
335	log.Debug().
336		Int("shortcuts", len(h.callbacks)).
337		Msg("global shortcuts reloaded")
338}
339
340// Detach removes the global shortcut handler from the window.
341// Note: GTK handles cleanup when the widget is destroyed,
342// but we clear our references here.
343func (h *GlobalShortcutHandler) Detach() {
344	h.controller = nil
345	h.callbacks = nil
346	h.registered = nil
347}
348
349func formatBinding(binding KeyBinding) string {
350	parts := make([]string, 0, 3)
351	if binding.Modifiers&ModCtrl != 0 {
352		parts = append(parts, "ctrl")
353	}
354	if binding.Modifiers&ModShift != 0 {
355		parts = append(parts, "shift")
356	}
357	if binding.Modifiers&ModAlt != 0 {
358		parts = append(parts, "alt")
359	}
360	keyName := gdk.KeyvalName(binding.Keyval)
361	if keyName == "" {
362		keyName = fmt.Sprintf("0x%x", binding.Keyval)
363	}
364	parts = append(parts, strings.ToLower(keyName))
365	return strings.Join(parts, "+")
366}
367
368func isModeAction(action Action) bool {
369	switch action {
370	case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionExitMode:
371		return true
372	default:
373		return false
374	}
375}

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