1// Package input provides keyboard event handling and modal input mode management.
2package input
3
4import (
5 "context"
6 "fmt"
7 "strings"
8
9 "github.com/bnema/dumber/internal/domain/entity"
10 "github.com/bnema/dumber/internal/logging"
11 "github.com/bnema/puregotk/v4/gdk"
12 "github.com/bnema/puregotk/v4/glib"
13 "github.com/bnema/puregotk/v4/gtk"
14)
15
16// GlobalShortcutHandler manages keyboard shortcuts that must work globally,
17// even when WebView has focus. It uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL
18// to intercept shortcuts before they reach the WebView.
19type GlobalShortcutHandler struct {
20 controller *gtk.ShortcutController
21 window *gtk.ApplicationWindow
22 kbHandler *KeyboardHandler
23 onAction ActionHandler
24 ctx context.Context
25 registered map[KeyBinding]Action
26
27 // Keep references to callbacks to prevent GC from collecting them
28 callbacks []gtk.ShortcutFunc
29}
30
31// NewGlobalShortcutHandler creates a new global shortcut handler for shortcuts
32// that need to work even when WebView has focus (like Alt+1-9 for tab switching).
33func NewGlobalShortcutHandler(
34 ctx context.Context,
35 window *gtk.ApplicationWindow,
36 workspace *entity.WorkspaceConfig,
37 session *entity.SessionConfig,
38 kbHandler *KeyboardHandler,
39 onAction ActionHandler,
40) *GlobalShortcutHandler {
41 log := logging.FromContext(ctx)
42 log.Debug().Msg("creating global shortcut handler")
43
44 h := &GlobalShortcutHandler{
45 controller: gtk.NewShortcutController(),
46 window: window,
47 kbHandler: kbHandler,
48 onAction: onAction,
49 ctx: ctx,
50 callbacks: make([]gtk.ShortcutFunc, 0),
51 registered: make(map[KeyBinding]Action),
52 }
53
54 if h.controller == nil {
55 log.Error().Msg("failed to create shortcut controller")
56 return nil
57 }
58
59 // Set global scope - this is the key to making shortcuts work
60 // even when WebView has focus
61 h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
62
63 // Register Alt+1 through Alt+9 for tab switching
64 tabActions := []Action{
65 ActionSwitchTabIndex1,
66 ActionSwitchTabIndex2,
67 ActionSwitchTabIndex3,
68 ActionSwitchTabIndex4,
69 ActionSwitchTabIndex5,
70 ActionSwitchTabIndex6,
71 ActionSwitchTabIndex7,
72 ActionSwitchTabIndex8,
73 ActionSwitchTabIndex9,
74 }
75
76 for i, action := range tabActions {
77 keyval := uint(gdk.KEY_1) + uint(i) // KEY_1, KEY_2, ..., KEY_9
78 h.registerShortcut(keyval, gdk.AltMaskValue, action)
79 log.Trace().
80 Uint("keyval", keyval).
81 Str("action", string(action)).
82 Msg("registered global shortcut")
83 }
84
85 // Alt+0 for tab 10
86 h.registerShortcut(uint(gdk.KEY_0), gdk.AltMaskValue, ActionSwitchTabIndex10)
87 log.Trace().
88 Uint("keyval", uint(gdk.KEY_0)).
89 Str("action", string(ActionSwitchTabIndex10)).
90 Msg("registered global shortcut")
91
92 // Alt+Tab for switching to last active tab
93 h.registerShortcut(uint(gdk.KEY_Tab), gdk.AltMaskValue, ActionSwitchLastTab)
94 log.Trace().
95 Uint("keyval", uint(gdk.KEY_Tab)).
96 Str("action", string(ActionSwitchLastTab)).
97 Msg("registered global shortcut")
98
99 // Ctrl+Shift+S for direct session manager access (needs global scope for WebView focus)
100 h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager)
101 log.Trace().
102 Uint("keyval", uint(gdk.KEY_s)).
103 Str("action", string(ActionOpenSessionManager)).
104 Msg("registered global shortcut")
105
106 if workspace != nil {
107 // Map action names to action constants for global shortcuts
108 actionMap := map[string]Action{
109 "toggle_floating_pane": ActionToggleFloatingPane,
110 "toggle-floating-pane": ActionToggleFloatingPane,
111 "consume_or_expel_left": ActionConsumeOrExpelLeft,
112 "consume_or_expel_right": ActionConsumeOrExpelRight,
113 "consume_or_expel_up": ActionConsumeOrExpelUp,
114 "consume_or_expel_down": ActionConsumeOrExpelDown,
115 }
116
117 for actionName, actionBinding := range workspace.Shortcuts.Actions {
118 action, ok := actionMap[actionName]
119 if !ok {
120 continue
121 }
122 for _, keyStr := range actionBinding.Keys {
123 binding, ok := ParseKeyString(keyStr)
124 if !ok {
125 log.Warn().Str("shortcut", keyStr).Str("action", string(action)).Msg("failed to parse global shortcut")
126 continue
127 }
128 if h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action) {
129 log.Trace().Str("shortcut", keyStr).Str("action", string(action)).Msg("registered global shortcut")
130 }
131 }
132 }
133
134 occupied := make(map[KeyBinding]Action, len(h.registered))
135 for binding, action := range h.registered {
136 occupied[binding] = action
137 }
138 for _, shortcut := range collectFloatingProfileShortcutsFromWorkspace(ctx, workspace, occupied) {
139 if !h.registerShortcut(shortcut.Binding.Keyval, gdk.ModifierType(shortcut.Binding.Modifiers), shortcut.Action) {
140 continue
141 }
142 if url, ok := ParseFloatingProfileAction(shortcut.Action); ok {
143 log.Trace().
144 Str("shortcut", formatBinding(shortcut.Binding)).
145 Str("url", url).
146 Msg("registered floating profile global shortcut")
147 }
148 }
149
150 // Register all app-reserved shortcuts (mode activations, Ctrl+L, Ctrl+F, etc.)
151 // so they work even when WebView has focus.
152 shortcuts := NewShortcutSet(ctx, workspace, session)
153 for binding, action := range shortcuts.Global {
154 if _, exists := h.registered[binding]; exists {
155 continue
156 }
157 h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action)
158 }
159 }
160
161 // Attach to window
162 window.AddController(&h.controller.EventController)
163
164 log.Debug().
165 Int("shortcuts", len(h.callbacks)).
166 Msg("global shortcut handler created and attached")
167
168 return h
169}
170
171// registerShortcut creates and registers a single shortcut with the controller.
172func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.ModifierType, action Action) bool {
173 binding := KeyBinding{Keyval: keyval, Modifiers: Modifier(modifiers) & modifierMask}
174 if existing, exists := h.registered[binding]; exists {
175 logging.FromContext(h.ctx).Warn().
176 Str("existing_action", string(existing)).
177 Str("new_action", string(action)).
178 Str("shortcut", formatBinding(binding)).
179 Msg("global shortcut conflict, skipping")
180 return false
181 }
182
183 // Create trigger for this key combination
184 trigger := gtk.NewKeyvalTrigger(keyval, modifiers)
185 if trigger == nil {
186 logging.FromContext(h.ctx).Error().
187 Uint("keyval", keyval).
188 Msg("failed to create keyval trigger")
189 return false
190 }
191
192 // Create callback action
193 // We need to capture the action in the closure
194 actionToDispatch := action
195 callback := gtk.ShortcutFunc(func(_ uintptr, _ *glib.Variant, _ uintptr) bool {
196 log := logging.FromContext(h.ctx)
197 log.Debug().
198 Str("action", string(actionToDispatch)).
199 Msg("global shortcut triggered")
200
201 // Mode-enter/exit actions go through KeyboardHandler for modal state
202 if isModeAction(actionToDispatch) {
203 if h.kbHandler != nil {
204 h.kbHandler.DispatchAction(actionToDispatch)
205 return true
206 }
207 log.Warn().
208 Str("action", string(actionToDispatch)).
209 Msg("mode action triggered but keyboard handler not set, falling through to default handler")
210 }
211
212 if h.onAction != nil {
213 if err := h.onAction(h.ctx, actionToDispatch); err != nil {
214 log.Error().
215 Err(err).
216 Str("action", string(actionToDispatch)).
217 Msg("global shortcut action failed")
218 }
219 }
220 return true // Event consumed
221 })
222
223 // Store callback reference to prevent GC
224 h.callbacks = append(h.callbacks, callback)
225
226 // Create the shortcut action
227 shortcutAction := gtk.NewCallbackAction(&callback, 0, nil)
228 if shortcutAction == nil {
229 logging.FromContext(h.ctx).Error().
230 Uint("keyval", keyval).
231 Msg("failed to create callback action")
232 return false
233 }
234
235 // Create the shortcut combining trigger and action
236 shortcut := gtk.NewShortcut(&trigger.ShortcutTrigger, &shortcutAction.ShortcutAction)
237 if shortcut == nil {
238 logging.FromContext(h.ctx).Error().
239 Uint("keyval", keyval).
240 Msg("failed to create shortcut")
241 return false
242 }
243
244 // Add to controller
245 h.controller.AddShortcut(shortcut)
246 h.registered[binding] = action
247 return true
248}
249
250// ReloadShortcuts rebuilds global shortcuts from a new config.
251// It replaces the GTK shortcut controller to ensure stale shortcuts
252// are removed.
253func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
254 log := logging.FromContext(ctx)
255
256 if h.window == nil || h.controller == nil {
257 return
258 }
259
260 // Remove old controller from window
261 h.window.RemoveController(&h.controller.EventController)
262
263 // Create fresh controller
264 h.controller = gtk.NewShortcutController()
265 if h.controller == nil {
266 log.Error().Msg("failed to create shortcut controller during reload")
267 return
268 }
269 h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
270 h.callbacks = make([]gtk.ShortcutFunc, 0)
271 h.registered = make(map[KeyBinding]Action)
272
273 // Re-register hardcoded shortcuts (Alt+1-9, Alt+0, Alt+Tab, Ctrl+Shift+S)
274 tabActions := []Action{
275 ActionSwitchTabIndex1, ActionSwitchTabIndex2, ActionSwitchTabIndex3,
276 ActionSwitchTabIndex4, ActionSwitchTabIndex5, ActionSwitchTabIndex6,
277 ActionSwitchTabIndex7, ActionSwitchTabIndex8, ActionSwitchTabIndex9,
278 }
279 for i, action := range tabActions {
280 h.registerShortcut(uint(gdk.KEY_1)+uint(i), gdk.AltMaskValue, action)
281 }
282 h.registerShortcut(uint(gdk.KEY_0), gdk.AltMaskValue, ActionSwitchTabIndex10)
283 h.registerShortcut(uint(gdk.KEY_Tab), gdk.AltMaskValue, ActionSwitchLastTab)
284 h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager)
285
286 // Re-register config-driven shortcuts
287 if workspace != nil {
288 actionMap := map[string]Action{
289 "toggle_floating_pane": ActionToggleFloatingPane,
290 "toggle-floating-pane": ActionToggleFloatingPane,
291 "consume_or_expel_left": ActionConsumeOrExpelLeft,
292 "consume_or_expel_right": ActionConsumeOrExpelRight,
293 "consume_or_expel_up": ActionConsumeOrExpelUp,
294 "consume_or_expel_down": ActionConsumeOrExpelDown,
295 }
296
297 for actionName, actionBinding := range workspace.Shortcuts.Actions {
298 action, ok := actionMap[actionName]
299 if !ok {
300 continue
301 }
302 for _, keyStr := range actionBinding.Keys {
303 binding, ok := ParseKeyString(keyStr)
304 if !ok {
305 continue
306 }
307 h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action)
308 }
309 }
310
311 occupied := make(map[KeyBinding]Action, len(h.registered))
312 for binding, action := range h.registered {
313 occupied[binding] = action
314 }
315 for _, shortcut := range collectFloatingProfileShortcutsFromWorkspace(ctx, workspace, occupied) {
316 if h.registerShortcut(shortcut.Binding.Keyval, gdk.ModifierType(shortcut.Binding.Modifiers), shortcut.Action) {
317 if url, ok := ParseFloatingProfileAction(shortcut.Action); ok {
318 log.Trace().Str("shortcut", formatBinding(shortcut.Binding)).Str("url", url).Msg("registered floating profile global shortcut")
319 }
320 }
321 }
322
323 shortcuts := NewShortcutSet(ctx, workspace, session)
324 for binding, action := range shortcuts.Global {
325 if _, exists := h.registered[binding]; exists {
326 continue
327 }
328 h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action)
329 }
330 }
331
332 // Attach new controller to window
333 h.window.AddController(&h.controller.EventController)
334
335 log.Debug().
336 Int("shortcuts", len(h.callbacks)).
337 Msg("global shortcuts reloaded")
338}
339
340// Detach removes the global shortcut handler from the window.
341// Note: GTK handles cleanup when the widget is destroyed,
342// but we clear our references here.
343func (h *GlobalShortcutHandler) Detach() {
344 h.controller = nil
345 h.callbacks = nil
346 h.registered = nil
347}
348
349func formatBinding(binding KeyBinding) string {
350 parts := make([]string, 0, 3)
351 if binding.Modifiers&ModCtrl != 0 {
352 parts = append(parts, "ctrl")
353 }
354 if binding.Modifiers&ModShift != 0 {
355 parts = append(parts, "shift")
356 }
357 if binding.Modifiers&ModAlt != 0 {
358 parts = append(parts, "alt")
359 }
360 keyName := gdk.KeyvalName(binding.Keyval)
361 if keyName == "" {
362 keyName = fmt.Sprintf("0x%x", binding.Keyval)
363 }
364 parts = append(parts, strings.ToLower(keyName))
365 return strings.Join(parts, "+")
366}
367
368func isModeAction(action Action) bool {
369 switch action {
370 case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionExitMode:
371 return true
372 default:
373 return false
374 }
375}