v0.26.1 75cd777

global_shortcuts.go RawHistoryFolder

1// Package input provides keyboard event handling and modal input mode management.
2package input
3
4import (
5	"context"
6
7	"github.com/bnema/dumber/internal/infrastructure/config"
8	"github.com/bnema/dumber/internal/logging"
9	"github.com/jwijenbergh/puregotk/v4/gdk"
10	"github.com/jwijenbergh/puregotk/v4/glib"
11	"github.com/jwijenbergh/puregotk/v4/gtk"
12)
13
14// GlobalShortcutHandler manages keyboard shortcuts that must work globally,
15// even when WebView has focus. It uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL
16// to intercept shortcuts before they reach the WebView.
17type GlobalShortcutHandler struct {
18	controller *gtk.ShortcutController
19	onAction   ActionHandler
20	ctx        context.Context
21
22	// Keep references to callbacks to prevent GC from collecting them
23	callbacks []gtk.ShortcutFunc
24}
25
26// NewGlobalShortcutHandler creates a new global shortcut handler for shortcuts
27// that need to work even when WebView has focus (like Alt+1-9 for tab switching).
28func NewGlobalShortcutHandler(
29	ctx context.Context,
30	window *gtk.ApplicationWindow,
31	cfg *config.Config,
32	onAction ActionHandler,
33) *GlobalShortcutHandler {
34	log := logging.FromContext(ctx)
35	log.Debug().Msg("creating global shortcut handler")
36
37	h := &GlobalShortcutHandler{
38		controller: gtk.NewShortcutController(),
39		onAction:   onAction,
40		ctx:        ctx,
41		callbacks:  make([]gtk.ShortcutFunc, 0),
42	}
43
44	if h.controller == nil {
45		log.Error().Msg("failed to create shortcut controller")
46		return nil
47	}
48
49	// Set global scope - this is the key to making shortcuts work
50	// even when WebView has focus
51	h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
52
53	// Register Alt+1 through Alt+9 for tab switching
54	tabActions := []Action{
55		ActionSwitchTabIndex1,
56		ActionSwitchTabIndex2,
57		ActionSwitchTabIndex3,
58		ActionSwitchTabIndex4,
59		ActionSwitchTabIndex5,
60		ActionSwitchTabIndex6,
61		ActionSwitchTabIndex7,
62		ActionSwitchTabIndex8,
63		ActionSwitchTabIndex9,
64	}
65
66	for i, action := range tabActions {
67		keyval := uint(gdk.KEY_1) + uint(i) // KEY_1, KEY_2, ..., KEY_9
68		h.registerShortcut(keyval, gdk.AltMaskValue, action)
69		log.Trace().
70			Uint("keyval", keyval).
71			Str("action", string(action)).
72			Msg("registered global shortcut")
73	}
74
75	// Alt+0 for tab 10
76	h.registerShortcut(uint(gdk.KEY_0), gdk.AltMaskValue, ActionSwitchTabIndex10)
77	log.Trace().
78		Uint("keyval", uint(gdk.KEY_0)).
79		Str("action", string(ActionSwitchTabIndex10)).
80		Msg("registered global shortcut")
81
82	// Alt+Tab for switching to last active tab
83	h.registerShortcut(uint(gdk.KEY_Tab), gdk.AltMaskValue, ActionSwitchLastTab)
84	log.Trace().
85		Uint("keyval", uint(gdk.KEY_Tab)).
86		Str("action", string(ActionSwitchLastTab)).
87		Msg("registered global shortcut")
88
89	// Ctrl+Shift+S for direct session manager access (needs global scope for WebView focus)
90	h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager)
91	log.Trace().
92		Uint("keyval", uint(gdk.KEY_s)).
93		Str("action", string(ActionOpenSessionManager)).
94		Msg("registered global shortcut")
95
96	if cfg != nil {
97		// Map action names to action constants for global shortcuts
98		actionMap := map[string]Action{
99			"consume_or_expel_left":  ActionConsumeOrExpelLeft,
100			"consume_or_expel_right": ActionConsumeOrExpelRight,
101			"consume_or_expel_up":    ActionConsumeOrExpelUp,
102			"consume_or_expel_down":  ActionConsumeOrExpelDown,
103		}
104
105		for actionName, actionBinding := range cfg.Workspace.Shortcuts.Actions {
106			action, ok := actionMap[actionName]
107			if !ok {
108				continue
109			}
110			for _, keyStr := range actionBinding.Keys {
111				binding, ok := ParseKeyString(keyStr)
112				if !ok {
113					log.Warn().Str("shortcut", keyStr).Str("action", string(action)).Msg("failed to parse global shortcut")
114					continue
115				}
116				h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action)
117				log.Trace().Str("shortcut", keyStr).Str("action", string(action)).Msg("registered global shortcut")
118			}
119		}
120	}
121
122	// Attach to window
123	window.AddController(&h.controller.EventController)
124
125	log.Debug().
126		Int("shortcuts", len(h.callbacks)).
127		Msg("global shortcut handler created and attached")
128
129	return h
130}
131
132// registerShortcut creates and registers a single shortcut with the controller.
133func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.ModifierType, action Action) {
134	// Create trigger for this key combination
135	trigger := gtk.NewKeyvalTrigger(keyval, modifiers)
136	if trigger == nil {
137		logging.FromContext(h.ctx).Error().
138			Uint("keyval", keyval).
139			Msg("failed to create keyval trigger")
140		return
141	}
142
143	// Create callback action
144	// We need to capture the action in the closure
145	actionToDispatch := action
146	callback := gtk.ShortcutFunc(func(_ uintptr, _ *glib.Variant, _ uintptr) bool {
147		log := logging.FromContext(h.ctx)
148		log.Debug().
149			Str("action", string(actionToDispatch)).
150			Msg("global shortcut triggered")
151
152		if h.onAction != nil {
153			if err := h.onAction(h.ctx, actionToDispatch); err != nil {
154				log.Error().
155					Err(err).
156					Str("action", string(actionToDispatch)).
157					Msg("global shortcut action failed")
158			}
159		}
160		return true // Event consumed
161	})
162
163	// Store callback reference to prevent GC
164	h.callbacks = append(h.callbacks, callback)
165
166	// Create the shortcut action
167	shortcutAction := gtk.NewCallbackAction(&callback, 0, nil)
168	if shortcutAction == nil {
169		logging.FromContext(h.ctx).Error().
170			Uint("keyval", keyval).
171			Msg("failed to create callback action")
172		return
173	}
174
175	// Create the shortcut combining trigger and action
176	shortcut := gtk.NewShortcut(&trigger.ShortcutTrigger, &shortcutAction.ShortcutAction)
177	if shortcut == nil {
178		logging.FromContext(h.ctx).Error().
179			Uint("keyval", keyval).
180			Msg("failed to create shortcut")
181		return
182	}
183
184	// Add to controller
185	h.controller.AddShortcut(shortcut)
186}
187
188// Detach removes the global shortcut handler from the window.
189// Note: GTK handles cleanup when the widget is destroyed,
190// but we clear our references here.
191func (h *GlobalShortcutHandler) Detach() {
192	h.controller = nil
193	h.callbacks = nil
194}

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