v0.27.1 c78ce83

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/infrastructure/config"
10	"github.com/bnema/dumber/internal/logging"
11	"github.com/jwijenbergh/puregotk/v4/gdk"
12	"github.com/jwijenbergh/puregotk/v4/glib"
13	"github.com/jwijenbergh/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	onAction   ActionHandler
22	ctx        context.Context
23	registered map[KeyBinding]Action
24
25	// Keep references to callbacks to prevent GC from collecting them
26	callbacks []gtk.ShortcutFunc
27}
28
29// NewGlobalShortcutHandler creates a new global shortcut handler for shortcuts
30// that need to work even when WebView has focus (like Alt+1-9 for tab switching).
31func NewGlobalShortcutHandler(
32	ctx context.Context,
33	window *gtk.ApplicationWindow,
34	cfg *config.Config,
35	onAction ActionHandler,
36) *GlobalShortcutHandler {
37	log := logging.FromContext(ctx)
38	log.Debug().Msg("creating global shortcut handler")
39
40	h := &GlobalShortcutHandler{
41		controller: gtk.NewShortcutController(),
42		onAction:   onAction,
43		ctx:        ctx,
44		callbacks:  make([]gtk.ShortcutFunc, 0),
45		registered: make(map[KeyBinding]Action),
46	}
47
48	if h.controller == nil {
49		log.Error().Msg("failed to create shortcut controller")
50		return nil
51	}
52
53	// Set global scope - this is the key to making shortcuts work
54	// even when WebView has focus
55	h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
56
57	// Register Alt+1 through Alt+9 for tab switching
58	tabActions := []Action{
59		ActionSwitchTabIndex1,
60		ActionSwitchTabIndex2,
61		ActionSwitchTabIndex3,
62		ActionSwitchTabIndex4,
63		ActionSwitchTabIndex5,
64		ActionSwitchTabIndex6,
65		ActionSwitchTabIndex7,
66		ActionSwitchTabIndex8,
67		ActionSwitchTabIndex9,
68	}
69
70	for i, action := range tabActions {
71		keyval := uint(gdk.KEY_1) + uint(i) // KEY_1, KEY_2, ..., KEY_9
72		h.registerShortcut(keyval, gdk.AltMaskValue, action)
73		log.Trace().
74			Uint("keyval", keyval).
75			Str("action", string(action)).
76			Msg("registered global shortcut")
77	}
78
79	// Alt+0 for tab 10
80	h.registerShortcut(uint(gdk.KEY_0), gdk.AltMaskValue, ActionSwitchTabIndex10)
81	log.Trace().
82		Uint("keyval", uint(gdk.KEY_0)).
83		Str("action", string(ActionSwitchTabIndex10)).
84		Msg("registered global shortcut")
85
86	// Alt+Tab for switching to last active tab
87	h.registerShortcut(uint(gdk.KEY_Tab), gdk.AltMaskValue, ActionSwitchLastTab)
88	log.Trace().
89		Uint("keyval", uint(gdk.KEY_Tab)).
90		Str("action", string(ActionSwitchLastTab)).
91		Msg("registered global shortcut")
92
93	// Ctrl+Shift+S for direct session manager access (needs global scope for WebView focus)
94	h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager)
95	log.Trace().
96		Uint("keyval", uint(gdk.KEY_s)).
97		Str("action", string(ActionOpenSessionManager)).
98		Msg("registered global shortcut")
99
100	if cfg != nil {
101		// Map action names to action constants for global shortcuts
102		actionMap := map[string]Action{
103			"toggle_floating_pane":   ActionToggleFloatingPane,
104			"toggle-floating-pane":   ActionToggleFloatingPane,
105			"consume_or_expel_left":  ActionConsumeOrExpelLeft,
106			"consume_or_expel_right": ActionConsumeOrExpelRight,
107			"consume_or_expel_up":    ActionConsumeOrExpelUp,
108			"consume_or_expel_down":  ActionConsumeOrExpelDown,
109		}
110
111		for actionName, actionBinding := range cfg.Workspace.Shortcuts.Actions {
112			action, ok := actionMap[actionName]
113			if !ok {
114				continue
115			}
116			for _, keyStr := range actionBinding.Keys {
117				binding, ok := ParseKeyString(keyStr)
118				if !ok {
119					log.Warn().Str("shortcut", keyStr).Str("action", string(action)).Msg("failed to parse global shortcut")
120					continue
121				}
122				if h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action) {
123					log.Trace().Str("shortcut", keyStr).Str("action", string(action)).Msg("registered global shortcut")
124				}
125			}
126		}
127
128		occupied := make(map[KeyBinding]Action, len(h.registered))
129		for binding, action := range h.registered {
130			occupied[binding] = action
131		}
132		for _, shortcut := range collectFloatingProfileShortcuts(ctx, cfg, occupied) {
133			if !h.registerShortcut(shortcut.Binding.Keyval, gdk.ModifierType(shortcut.Binding.Modifiers), shortcut.Action) {
134				continue
135			}
136			if url, ok := ParseFloatingProfileAction(shortcut.Action); ok {
137				log.Trace().
138					Str("shortcut", formatBinding(shortcut.Binding)).
139					Str("url", url).
140					Msg("registered floating profile global shortcut")
141			}
142		}
143	}
144
145	// Attach to window
146	window.AddController(&h.controller.EventController)
147
148	log.Debug().
149		Int("shortcuts", len(h.callbacks)).
150		Msg("global shortcut handler created and attached")
151
152	return h
153}
154
155// registerShortcut creates and registers a single shortcut with the controller.
156func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.ModifierType, action Action) bool {
157	binding := KeyBinding{Keyval: keyval, Modifiers: Modifier(modifiers) & modifierMask}
158	if existing, exists := h.registered[binding]; exists {
159		logging.FromContext(h.ctx).Warn().
160			Str("existing_action", string(existing)).
161			Str("new_action", string(action)).
162			Str("shortcut", formatBinding(binding)).
163			Msg("global shortcut conflict, skipping")
164		return false
165	}
166
167	// Create trigger for this key combination
168	trigger := gtk.NewKeyvalTrigger(keyval, modifiers)
169	if trigger == nil {
170		logging.FromContext(h.ctx).Error().
171			Uint("keyval", keyval).
172			Msg("failed to create keyval trigger")
173		return false
174	}
175
176	// Create callback action
177	// We need to capture the action in the closure
178	actionToDispatch := action
179	callback := gtk.ShortcutFunc(func(_ uintptr, _ *glib.Variant, _ uintptr) bool {
180		log := logging.FromContext(h.ctx)
181		log.Debug().
182			Str("action", string(actionToDispatch)).
183			Msg("global shortcut triggered")
184
185		if h.onAction != nil {
186			if err := h.onAction(h.ctx, actionToDispatch); err != nil {
187				log.Error().
188					Err(err).
189					Str("action", string(actionToDispatch)).
190					Msg("global shortcut action failed")
191			}
192		}
193		return true // Event consumed
194	})
195
196	// Store callback reference to prevent GC
197	h.callbacks = append(h.callbacks, callback)
198
199	// Create the shortcut action
200	shortcutAction := gtk.NewCallbackAction(&callback, 0, nil)
201	if shortcutAction == nil {
202		logging.FromContext(h.ctx).Error().
203			Uint("keyval", keyval).
204			Msg("failed to create callback action")
205		return false
206	}
207
208	// Create the shortcut combining trigger and action
209	shortcut := gtk.NewShortcut(&trigger.ShortcutTrigger, &shortcutAction.ShortcutAction)
210	if shortcut == nil {
211		logging.FromContext(h.ctx).Error().
212			Uint("keyval", keyval).
213			Msg("failed to create shortcut")
214		return false
215	}
216
217	// Add to controller
218	h.controller.AddShortcut(shortcut)
219	h.registered[binding] = action
220	return true
221}
222
223// Detach removes the global shortcut handler from the window.
224// Note: GTK handles cleanup when the widget is destroyed,
225// but we clear our references here.
226func (h *GlobalShortcutHandler) Detach() {
227	h.controller = nil
228	h.callbacks = nil
229	h.registered = nil
230}
231
232func formatBinding(binding KeyBinding) string {
233	parts := make([]string, 0, 3)
234	if binding.Modifiers&ModCtrl != 0 {
235		parts = append(parts, "ctrl")
236	}
237	if binding.Modifiers&ModShift != 0 {
238		parts = append(parts, "shift")
239	}
240	if binding.Modifiers&ModAlt != 0 {
241		parts = append(parts, "alt")
242	}
243	keyName := gdk.KeyvalName(binding.Keyval)
244	if keyName == "" {
245		keyName = fmt.Sprintf("0x%x", binding.Keyval)
246	}
247	parts = append(parts, strings.ToLower(keyName))
248	return strings.Join(parts, "+")
249}

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