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/infrastructure/config"
10 "github.com/bnema/dumber/internal/logging"
11 "github.com/jwijenbergh/puregotk/v4/gdk"
12 "github.com/jwijenbergh/puregotk/v4/glib"
13 "github.com/jwijenbergh/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 onAction ActionHandler
22 ctx context.Context
23 registered map[KeyBinding]Action
24
25 // Keep references to callbacks to prevent GC from collecting them
26 callbacks []gtk.ShortcutFunc
27}
28
29// NewGlobalShortcutHandler creates a new global shortcut handler for shortcuts
30// that need to work even when WebView has focus (like Alt+1-9 for tab switching).
31func NewGlobalShortcutHandler(
32 ctx context.Context,
33 window *gtk.ApplicationWindow,
34 cfg *config.Config,
35 onAction ActionHandler,
36) *GlobalShortcutHandler {
37 log := logging.FromContext(ctx)
38 log.Debug().Msg("creating global shortcut handler")
39
40 h := &GlobalShortcutHandler{
41 controller: gtk.NewShortcutController(),
42 onAction: onAction,
43 ctx: ctx,
44 callbacks: make([]gtk.ShortcutFunc, 0),
45 registered: make(map[KeyBinding]Action),
46 }
47
48 if h.controller == nil {
49 log.Error().Msg("failed to create shortcut controller")
50 return nil
51 }
52
53 // Set global scope - this is the key to making shortcuts work
54 // even when WebView has focus
55 h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
56
57 // Register Alt+1 through Alt+9 for tab switching
58 tabActions := []Action{
59 ActionSwitchTabIndex1,
60 ActionSwitchTabIndex2,
61 ActionSwitchTabIndex3,
62 ActionSwitchTabIndex4,
63 ActionSwitchTabIndex5,
64 ActionSwitchTabIndex6,
65 ActionSwitchTabIndex7,
66 ActionSwitchTabIndex8,
67 ActionSwitchTabIndex9,
68 }
69
70 for i, action := range tabActions {
71 keyval := uint(gdk.KEY_1) + uint(i) // KEY_1, KEY_2, ..., KEY_9
72 h.registerShortcut(keyval, gdk.AltMaskValue, action)
73 log.Trace().
74 Uint("keyval", keyval).
75 Str("action", string(action)).
76 Msg("registered global shortcut")
77 }
78
79 // Alt+0 for tab 10
80 h.registerShortcut(uint(gdk.KEY_0), gdk.AltMaskValue, ActionSwitchTabIndex10)
81 log.Trace().
82 Uint("keyval", uint(gdk.KEY_0)).
83 Str("action", string(ActionSwitchTabIndex10)).
84 Msg("registered global shortcut")
85
86 // Alt+Tab for switching to last active tab
87 h.registerShortcut(uint(gdk.KEY_Tab), gdk.AltMaskValue, ActionSwitchLastTab)
88 log.Trace().
89 Uint("keyval", uint(gdk.KEY_Tab)).
90 Str("action", string(ActionSwitchLastTab)).
91 Msg("registered global shortcut")
92
93 // Ctrl+Shift+S for direct session manager access (needs global scope for WebView focus)
94 h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager)
95 log.Trace().
96 Uint("keyval", uint(gdk.KEY_s)).
97 Str("action", string(ActionOpenSessionManager)).
98 Msg("registered global shortcut")
99
100 if cfg != nil {
101 // Map action names to action constants for global shortcuts
102 actionMap := map[string]Action{
103 "toggle_floating_pane": ActionToggleFloatingPane,
104 "toggle-floating-pane": ActionToggleFloatingPane,
105 "consume_or_expel_left": ActionConsumeOrExpelLeft,
106 "consume_or_expel_right": ActionConsumeOrExpelRight,
107 "consume_or_expel_up": ActionConsumeOrExpelUp,
108 "consume_or_expel_down": ActionConsumeOrExpelDown,
109 }
110
111 for actionName, actionBinding := range cfg.Workspace.Shortcuts.Actions {
112 action, ok := actionMap[actionName]
113 if !ok {
114 continue
115 }
116 for _, keyStr := range actionBinding.Keys {
117 binding, ok := ParseKeyString(keyStr)
118 if !ok {
119 log.Warn().Str("shortcut", keyStr).Str("action", string(action)).Msg("failed to parse global shortcut")
120 continue
121 }
122 if h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action) {
123 log.Trace().Str("shortcut", keyStr).Str("action", string(action)).Msg("registered global shortcut")
124 }
125 }
126 }
127
128 occupied := make(map[KeyBinding]Action, len(h.registered))
129 for binding, action := range h.registered {
130 occupied[binding] = action
131 }
132 for _, shortcut := range collectFloatingProfileShortcuts(ctx, cfg, occupied) {
133 if !h.registerShortcut(shortcut.Binding.Keyval, gdk.ModifierType(shortcut.Binding.Modifiers), shortcut.Action) {
134 continue
135 }
136 if url, ok := ParseFloatingProfileAction(shortcut.Action); ok {
137 log.Trace().
138 Str("shortcut", formatBinding(shortcut.Binding)).
139 Str("url", url).
140 Msg("registered floating profile global shortcut")
141 }
142 }
143 }
144
145 // Attach to window
146 window.AddController(&h.controller.EventController)
147
148 log.Debug().
149 Int("shortcuts", len(h.callbacks)).
150 Msg("global shortcut handler created and attached")
151
152 return h
153}
154
155// registerShortcut creates and registers a single shortcut with the controller.
156func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.ModifierType, action Action) bool {
157 binding := KeyBinding{Keyval: keyval, Modifiers: Modifier(modifiers) & modifierMask}
158 if existing, exists := h.registered[binding]; exists {
159 logging.FromContext(h.ctx).Warn().
160 Str("existing_action", string(existing)).
161 Str("new_action", string(action)).
162 Str("shortcut", formatBinding(binding)).
163 Msg("global shortcut conflict, skipping")
164 return false
165 }
166
167 // Create trigger for this key combination
168 trigger := gtk.NewKeyvalTrigger(keyval, modifiers)
169 if trigger == nil {
170 logging.FromContext(h.ctx).Error().
171 Uint("keyval", keyval).
172 Msg("failed to create keyval trigger")
173 return false
174 }
175
176 // Create callback action
177 // We need to capture the action in the closure
178 actionToDispatch := action
179 callback := gtk.ShortcutFunc(func(_ uintptr, _ *glib.Variant, _ uintptr) bool {
180 log := logging.FromContext(h.ctx)
181 log.Debug().
182 Str("action", string(actionToDispatch)).
183 Msg("global shortcut triggered")
184
185 if h.onAction != nil {
186 if err := h.onAction(h.ctx, actionToDispatch); err != nil {
187 log.Error().
188 Err(err).
189 Str("action", string(actionToDispatch)).
190 Msg("global shortcut action failed")
191 }
192 }
193 return true // Event consumed
194 })
195
196 // Store callback reference to prevent GC
197 h.callbacks = append(h.callbacks, callback)
198
199 // Create the shortcut action
200 shortcutAction := gtk.NewCallbackAction(&callback, 0, nil)
201 if shortcutAction == nil {
202 logging.FromContext(h.ctx).Error().
203 Uint("keyval", keyval).
204 Msg("failed to create callback action")
205 return false
206 }
207
208 // Create the shortcut combining trigger and action
209 shortcut := gtk.NewShortcut(&trigger.ShortcutTrigger, &shortcutAction.ShortcutAction)
210 if shortcut == nil {
211 logging.FromContext(h.ctx).Error().
212 Uint("keyval", keyval).
213 Msg("failed to create shortcut")
214 return false
215 }
216
217 // Add to controller
218 h.controller.AddShortcut(shortcut)
219 h.registered[binding] = action
220 return true
221}
222
223// Detach removes the global shortcut handler from the window.
224// Note: GTK handles cleanup when the widget is destroyed,
225// but we clear our references here.
226func (h *GlobalShortcutHandler) Detach() {
227 h.controller = nil
228 h.callbacks = nil
229 h.registered = nil
230}
231
232func formatBinding(binding KeyBinding) string {
233 parts := make([]string, 0, 3)
234 if binding.Modifiers&ModCtrl != 0 {
235 parts = append(parts, "ctrl")
236 }
237 if binding.Modifiers&ModShift != 0 {
238 parts = append(parts, "shift")
239 }
240 if binding.Modifiers&ModAlt != 0 {
241 parts = append(parts, "alt")
242 }
243 keyName := gdk.KeyvalName(binding.Keyval)
244 if keyName == "" {
245 keyName = fmt.Sprintf("0x%x", binding.Keyval)
246 }
247 parts = append(parts, strings.ToLower(keyName))
248 return strings.Join(parts, "+")
249}