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}