v0.20.1 d9a391f

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/logging"
8	"github.com/jwijenbergh/puregotk/v4/gdk"
9	"github.com/jwijenbergh/puregotk/v4/glib"
10	"github.com/jwijenbergh/puregotk/v4/gtk"
11)
12
13// GlobalShortcutHandler manages keyboard shortcuts that must work globally,
14// even when WebView has focus. It uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL
15// to intercept shortcuts before they reach the WebView.
16type GlobalShortcutHandler struct {
17	controller *gtk.ShortcutController
18	onAction   ActionHandler
19	ctx        context.Context
20
21	// Keep references to callbacks to prevent GC from collecting them
22	callbacks []gtk.ShortcutFunc
23}
24
25// NewGlobalShortcutHandler creates a new global shortcut handler for shortcuts
26// that need to work even when WebView has focus (like Alt+1-9 for tab switching).
27func NewGlobalShortcutHandler(
28	ctx context.Context,
29	window *gtk.ApplicationWindow,
30	onAction ActionHandler,
31) *GlobalShortcutHandler {
32	log := logging.FromContext(ctx)
33	log.Debug().Msg("creating global shortcut handler")
34
35	h := &GlobalShortcutHandler{
36		controller: gtk.NewShortcutController(),
37		onAction:   onAction,
38		ctx:        ctx,
39		callbacks:  make([]gtk.ShortcutFunc, 0),
40	}
41
42	if h.controller == nil {
43		log.Error().Msg("failed to create shortcut controller")
44		return nil
45	}
46
47	// Set global scope - this is the key to making shortcuts work
48	// even when WebView has focus
49	h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
50
51	// Register Alt+1 through Alt+9 for tab switching
52	tabActions := []Action{
53		ActionSwitchTabIndex1,
54		ActionSwitchTabIndex2,
55		ActionSwitchTabIndex3,
56		ActionSwitchTabIndex4,
57		ActionSwitchTabIndex5,
58		ActionSwitchTabIndex6,
59		ActionSwitchTabIndex7,
60		ActionSwitchTabIndex8,
61		ActionSwitchTabIndex9,
62	}
63
64	for i, action := range tabActions {
65		keyval := uint(gdk.KEY_1) + uint(i) // KEY_1, KEY_2, ..., KEY_9
66		h.registerShortcut(keyval, gdk.AltMaskValue, action)
67		log.Trace().
68			Uint("keyval", keyval).
69			Str("action", string(action)).
70			Msg("registered global shortcut")
71	}
72
73	// Alt+0 for tab 10
74	h.registerShortcut(uint(gdk.KEY_0), gdk.AltMaskValue, ActionSwitchTabIndex10)
75	log.Trace().
76		Uint("keyval", uint(gdk.KEY_0)).
77		Str("action", string(ActionSwitchTabIndex10)).
78		Msg("registered global shortcut")
79
80	// Alt+Tab for switching to last active tab
81	h.registerShortcut(uint(gdk.KEY_Tab), gdk.AltMaskValue, ActionSwitchLastTab)
82	log.Trace().
83		Uint("keyval", uint(gdk.KEY_Tab)).
84		Str("action", string(ActionSwitchLastTab)).
85		Msg("registered global shortcut")
86
87	// Attach to window
88	window.AddController(&h.controller.EventController)
89
90	log.Debug().
91		Int("shortcuts", len(h.callbacks)).
92		Msg("global shortcut handler created and attached")
93
94	return h
95}
96
97// registerShortcut creates and registers a single shortcut with the controller.
98func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.ModifierType, action Action) {
99	// Create trigger for this key combination
100	trigger := gtk.NewKeyvalTrigger(keyval, modifiers)
101	if trigger == nil {
102		logging.FromContext(h.ctx).Error().
103			Uint("keyval", keyval).
104			Msg("failed to create keyval trigger")
105		return
106	}
107
108	// Create callback action
109	// We need to capture the action in the closure
110	actionToDispatch := action
111	callback := gtk.ShortcutFunc(func(_ uintptr, _ *glib.Variant, _ uintptr) bool {
112		log := logging.FromContext(h.ctx)
113		log.Debug().
114			Str("action", string(actionToDispatch)).
115			Msg("global shortcut triggered")
116
117		if h.onAction != nil {
118			if err := h.onAction(h.ctx, actionToDispatch); err != nil {
119				log.Error().
120					Err(err).
121					Str("action", string(actionToDispatch)).
122					Msg("global shortcut action failed")
123			}
124		}
125		return true // Event consumed
126	})
127
128	// Store callback reference to prevent GC
129	h.callbacks = append(h.callbacks, callback)
130
131	// Create the shortcut action
132	shortcutAction := gtk.NewCallbackAction(&callback, 0, nil)
133	if shortcutAction == nil {
134		logging.FromContext(h.ctx).Error().
135			Uint("keyval", keyval).
136			Msg("failed to create callback action")
137		return
138	}
139
140	// Create the shortcut combining trigger and action
141	shortcut := gtk.NewShortcut(&trigger.ShortcutTrigger, &shortcutAction.ShortcutAction)
142	if shortcut == nil {
143		logging.FromContext(h.ctx).Error().
144			Uint("keyval", keyval).
145			Msg("failed to create shortcut")
146		return
147	}
148
149	// Add to controller
150	h.controller.AddShortcut(shortcut)
151}
152
153// Detach removes the global shortcut handler from the window.
154// Note: GTK handles cleanup when the widget is destroyed,
155// but we clear our references here.
156func (h *GlobalShortcutHandler) Detach() {
157	h.controller = nil
158	h.callbacks = nil
159}

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