v0.23.2 bfd5eb4

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		registerFromConfig := func(key string, action Action) {
98			binding, ok := ParseKeyString(key)
99			if !ok {
100				log.Warn().Str("shortcut", key).Str("action", string(action)).Msg("failed to parse global shortcut")
101				return
102			}
103			h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action)
104			log.Trace().Str("shortcut", key).Str("action", string(action)).Msg("registered global shortcut")
105		}
106
107		registerFromConfig(cfg.Workspace.Shortcuts.ConsumeOrExpelLeft, ActionConsumeOrExpelLeft)
108		registerFromConfig(cfg.Workspace.Shortcuts.ConsumeOrExpelRight, ActionConsumeOrExpelRight)
109		registerFromConfig(cfg.Workspace.Shortcuts.ConsumeOrExpelUp, ActionConsumeOrExpelUp)
110		registerFromConfig(cfg.Workspace.Shortcuts.ConsumeOrExpelDown, ActionConsumeOrExpelDown)
111	}
112
113	// Attach to window
114	window.AddController(&h.controller.EventController)
115
116	log.Debug().
117		Int("shortcuts", len(h.callbacks)).
118		Msg("global shortcut handler created and attached")
119
120	return h
121}
122
123// registerShortcut creates and registers a single shortcut with the controller.
124func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.ModifierType, action Action) {
125	// Create trigger for this key combination
126	trigger := gtk.NewKeyvalTrigger(keyval, modifiers)
127	if trigger == nil {
128		logging.FromContext(h.ctx).Error().
129			Uint("keyval", keyval).
130			Msg("failed to create keyval trigger")
131		return
132	}
133
134	// Create callback action
135	// We need to capture the action in the closure
136	actionToDispatch := action
137	callback := gtk.ShortcutFunc(func(_ uintptr, _ *glib.Variant, _ uintptr) bool {
138		log := logging.FromContext(h.ctx)
139		log.Debug().
140			Str("action", string(actionToDispatch)).
141			Msg("global shortcut triggered")
142
143		if h.onAction != nil {
144			if err := h.onAction(h.ctx, actionToDispatch); err != nil {
145				log.Error().
146					Err(err).
147					Str("action", string(actionToDispatch)).
148					Msg("global shortcut action failed")
149			}
150		}
151		return true // Event consumed
152	})
153
154	// Store callback reference to prevent GC
155	h.callbacks = append(h.callbacks, callback)
156
157	// Create the shortcut action
158	shortcutAction := gtk.NewCallbackAction(&callback, 0, nil)
159	if shortcutAction == nil {
160		logging.FromContext(h.ctx).Error().
161			Uint("keyval", keyval).
162			Msg("failed to create callback action")
163		return
164	}
165
166	// Create the shortcut combining trigger and action
167	shortcut := gtk.NewShortcut(&trigger.ShortcutTrigger, &shortcutAction.ShortcutAction)
168	if shortcut == nil {
169		logging.FromContext(h.ctx).Error().
170			Uint("keyval", keyval).
171			Msg("failed to create shortcut")
172		return
173	}
174
175	// Add to controller
176	h.controller.AddShortcut(shortcut)
177}
178
179// Detach removes the global shortcut handler from the window.
180// Note: GTK handles cleanup when the widget is destroyed,
181// but we clear our references here.
182func (h *GlobalShortcutHandler) Detach() {
183	h.controller = nil
184	h.callbacks = nil
185}

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