detached 5dc745a

fix: use GtkShortcutController for Alt+number tab switching (fixes #16)

commit
5dc745a40aa0af953b5f6b7d4ef5d32a84c979d6
author
brice · · 2025-12-24 06:09 UTC
committer
brice · · 2025-12-24 16:11 UTC
parent
c741a39
signature
unsigned
4 files · +181 -13
M CHANGELOG.md +3 -0
@@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.
44
55 ## [Unreleased]
66
7+### Fixed
8+- **Alt+number tab switching**: Fixed Alt+1-9 shortcuts not working when WebView has focus by using GtkShortcutController with global scope instead of EventControllerKey.
9+
710 ### Added
811 - **Clean-architecture rewrite (pure Go)**: Major refactor to a ports/adapters style architecture with explicit `domain`, `application` (use cases), `infrastructure`, and `ui` layers.
912 - **New CLI commands**: `doctor` (runtime dependency checks), `about` (build info), `setup` (desktop integration), plus expanded `purge` with an interactive TUI.
@@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.
4 4
5 ## [Unreleased]5 ## [Unreleased]
6 6
7+### Fixed
8+- **Alt+number tab switching**: Fixed Alt+1-9 shortcuts not working when WebView has focus by using GtkShortcutController with global scope instead of EventControllerKey.
9+
7 ### Added10 ### Added
8 - **Clean-architecture rewrite (pure Go)**: Major refactor to a ports/adapters style architecture with explicit `domain`, `application` (use cases), `infrastructure`, and `ui` layers.11 - **Clean-architecture rewrite (pure Go)**: Major refactor to a ports/adapters style architecture with explicit `domain`, `application` (use cases), `infrastructure`, and `ui` layers.
9 - **New CLI commands**: `doctor` (runtime dependency checks), `about` (build info), `setup` (desktop integration), plus expanded `purge` with an interactive TUI.12 - **New CLI commands**: `doctor` (runtime dependency checks), `about` (build info), `setup` (desktop integration), plus expanded `purge` with an interactive TUI.
M internal/ui/app.go +13 -1
@@ -61,7 +61,8 @@ type App struct {
6161 stackedPaneMgr *component.StackedPaneManager
6262
6363 // Input handling
64- keyboardHandler *input.KeyboardHandler
64+ keyboardHandler *input.KeyboardHandler
65+ globalShortcutHandler *input.GlobalShortcutHandler
6566
6667 // Focus and border management
6768 focusMgr *focus.Manager
@@ -305,6 +306,17 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
305306 return wsView.IsOmniboxVisible()
306307 })
307308 a.keyboardHandler.AttachTo(a.mainWindow.Window())
309+
310+ // Create global shortcut handler for Alt+1-9 tab switching.
311+ // This uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL
312+ // to intercept shortcuts even when WebView has focus.
313+ a.globalShortcutHandler = input.NewGlobalShortcutHandler(
314+ ctx,
315+ a.mainWindow.Window(),
316+ func(ctx context.Context, action input.Action) error {
317+ return a.kbDispatcher.Dispatch(ctx, action)
318+ },
319+ )
308320 }
309321
310322 func (a *App) initOmniboxConfig(ctx context.Context) {
@@ -61,7 +61,8 @@ type App struct {
61 stackedPaneMgr *component.StackedPaneManager61 stackedPaneMgr *component.StackedPaneManager
62 62
63 // Input handling63 // Input handling
64- keyboardHandler *input.KeyboardHandler64+ keyboardHandler *input.KeyboardHandler
65+ globalShortcutHandler *input.GlobalShortcutHandler
65 66
66 // Focus and border management67 // Focus and border management
67 focusMgr *focus.Manager68 focusMgr *focus.Manager
@@ -305,6 +306,17 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
305 return wsView.IsOmniboxVisible()306 return wsView.IsOmniboxVisible()
306 })307 })
307 a.keyboardHandler.AttachTo(a.mainWindow.Window())308 a.keyboardHandler.AttachTo(a.mainWindow.Window())
309+
310+ // Create global shortcut handler for Alt+1-9 tab switching.
311+ // This uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL
312+ // to intercept shortcuts even when WebView has focus.
313+ a.globalShortcutHandler = input.NewGlobalShortcutHandler(
314+ ctx,
315+ a.mainWindow.Window(),
316+ func(ctx context.Context, action input.Action) error {
317+ return a.kbDispatcher.Dispatch(ctx, action)
318+ },
319+ )
308 }320 }
309 321
310 func (a *App) initOmniboxConfig(ctx context.Context) {322 func (a *App) initOmniboxConfig(ctx context.Context) {
A internal/ui/input/global_shortcuts.go +159 -0
@@ -0,0 +1,159 @@
1+// Package input provides keyboard event handling and modal input mode management.
2+package input
3+
4+import (
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.
16+type 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).
27+func 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.
98+func (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.
156+func (h *GlobalShortcutHandler) Detach() {
157+ h.controller = nil
158+ h.callbacks = nil
159+}
@@ -0,0 +1,159 @@
1+// Package input provides keyboard event handling and modal input mode management.
2+package input
3+
4+import (
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.
16+type 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).
27+func 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.
98+func (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.
156+func (h *GlobalShortcutHandler) Detach() {
157+ h.controller = nil
158+ h.callbacks = nil
159+}
M internal/ui/input/shortcuts.go +6 -12
@@ -291,18 +291,12 @@ func (s *ShortcutSet) registerPaneNavigationShortcuts() {
291291 }
292292
293293 func (s *ShortcutSet) registerTabSwitchShortcuts() {
294- s.Global[KeyBinding{uint(gdk.KEY_1), ModAlt}] = ActionSwitchTabIndex1
295- s.Global[KeyBinding{uint(gdk.KEY_2), ModAlt}] = ActionSwitchTabIndex2
296- s.Global[KeyBinding{uint(gdk.KEY_3), ModAlt}] = ActionSwitchTabIndex3
297- s.Global[KeyBinding{uint(gdk.KEY_4), ModAlt}] = ActionSwitchTabIndex4
298- s.Global[KeyBinding{uint(gdk.KEY_5), ModAlt}] = ActionSwitchTabIndex5
299- s.Global[KeyBinding{uint(gdk.KEY_6), ModAlt}] = ActionSwitchTabIndex6
300- s.Global[KeyBinding{uint(gdk.KEY_7), ModAlt}] = ActionSwitchTabIndex7
301- s.Global[KeyBinding{uint(gdk.KEY_8), ModAlt}] = ActionSwitchTabIndex8
302- s.Global[KeyBinding{uint(gdk.KEY_9), ModAlt}] = ActionSwitchTabIndex9
303- s.Global[KeyBinding{uint(gdk.KEY_0), ModAlt}] = ActionSwitchTabIndex10
304-
305- s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt}] = ActionSwitchLastTab
294+ // NOTE: Alt+1-9, Alt+0, and Alt+Tab are now handled by GlobalShortcutHandler
295+ // using GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL.
296+ // This is necessary because WebKitGTK's WebView consumes these key events
297+ // before they reach the EventControllerKey in capture phase.
298+ //
299+ // Only Alt+Shift+Tab remains here as a fallback binding.
306300 s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt | ModShift}] = ActionSwitchLastTab
307301 }
308302
@@ -291,18 +291,12 @@ func (s *ShortcutSet) registerPaneNavigationShortcuts() {
291 }291 }
292 292
293 func (s *ShortcutSet) registerTabSwitchShortcuts() {293 func (s *ShortcutSet) registerTabSwitchShortcuts() {
294- s.Global[KeyBinding{uint(gdk.KEY_1), ModAlt}] = ActionSwitchTabIndex1294+ // NOTE: Alt+1-9, Alt+0, and Alt+Tab are now handled by GlobalShortcutHandler
295- s.Global[KeyBinding{uint(gdk.KEY_2), ModAlt}] = ActionSwitchTabIndex2295+ // using GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL.
296- s.Global[KeyBinding{uint(gdk.KEY_3), ModAlt}] = ActionSwitchTabIndex3296+ // This is necessary because WebKitGTK's WebView consumes these key events
297- s.Global[KeyBinding{uint(gdk.KEY_4), ModAlt}] = ActionSwitchTabIndex4297+ // before they reach the EventControllerKey in capture phase.
298- s.Global[KeyBinding{uint(gdk.KEY_5), ModAlt}] = ActionSwitchTabIndex5298+ //
299- s.Global[KeyBinding{uint(gdk.KEY_6), ModAlt}] = ActionSwitchTabIndex6299+ // Only Alt+Shift+Tab remains here as a fallback binding.
300- s.Global[KeyBinding{uint(gdk.KEY_7), ModAlt}] = ActionSwitchTabIndex7
301- s.Global[KeyBinding{uint(gdk.KEY_8), ModAlt}] = ActionSwitchTabIndex8
302- s.Global[KeyBinding{uint(gdk.KEY_9), ModAlt}] = ActionSwitchTabIndex9
303- s.Global[KeyBinding{uint(gdk.KEY_0), ModAlt}] = ActionSwitchTabIndex10
304-
305- s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt}] = ActionSwitchLastTab
306 s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt | ModShift}] = ActionSwitchLastTab300 s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt | ModShift}] = ActionSwitchLastTab
307 }301 }
308 302

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