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 // Map action names to action constants for global shortcuts
98 actionMap := map[string]Action{
99 "consume_or_expel_left": ActionConsumeOrExpelLeft,
100 "consume_or_expel_right": ActionConsumeOrExpelRight,
101 "consume_or_expel_up": ActionConsumeOrExpelUp,
102 "consume_or_expel_down": ActionConsumeOrExpelDown,
103 }
104
105 for actionName, actionBinding := range cfg.Workspace.Shortcuts.Actions {
106 action, ok := actionMap[actionName]
107 if !ok {
108 continue
109 }
110 for _, keyStr := range actionBinding.Keys {
111 binding, ok := ParseKeyString(keyStr)
112 if !ok {
113 log.Warn().Str("shortcut", keyStr).Str("action", string(action)).Msg("failed to parse global shortcut")
114 continue
115 }
116 h.registerShortcut(binding.Keyval, gdk.ModifierType(binding.Modifiers), action)
117 log.Trace().Str("shortcut", keyStr).Str("action", string(action)).Msg("registered global shortcut")
118 }
119 }
120 }
121
122 // Attach to window
123 window.AddController(&h.controller.EventController)
124
125 log.Debug().
126 Int("shortcuts", len(h.callbacks)).
127 Msg("global shortcut handler created and attached")
128
129 return h
130}
131
132// registerShortcut creates and registers a single shortcut with the controller.
133func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.ModifierType, action Action) {
134 // Create trigger for this key combination
135 trigger := gtk.NewKeyvalTrigger(keyval, modifiers)
136 if trigger == nil {
137 logging.FromContext(h.ctx).Error().
138 Uint("keyval", keyval).
139 Msg("failed to create keyval trigger")
140 return
141 }
142
143 // Create callback action
144 // We need to capture the action in the closure
145 actionToDispatch := action
146 callback := gtk.ShortcutFunc(func(_ uintptr, _ *glib.Variant, _ uintptr) bool {
147 log := logging.FromContext(h.ctx)
148 log.Debug().
149 Str("action", string(actionToDispatch)).
150 Msg("global shortcut triggered")
151
152 if h.onAction != nil {
153 if err := h.onAction(h.ctx, actionToDispatch); err != nil {
154 log.Error().
155 Err(err).
156 Str("action", string(actionToDispatch)).
157 Msg("global shortcut action failed")
158 }
159 }
160 return true // Event consumed
161 })
162
163 // Store callback reference to prevent GC
164 h.callbacks = append(h.callbacks, callback)
165
166 // Create the shortcut action
167 shortcutAction := gtk.NewCallbackAction(&callback, 0, nil)
168 if shortcutAction == nil {
169 logging.FromContext(h.ctx).Error().
170 Uint("keyval", keyval).
171 Msg("failed to create callback action")
172 return
173 }
174
175 // Create the shortcut combining trigger and action
176 shortcut := gtk.NewShortcut(&trigger.ShortcutTrigger, &shortcutAction.ShortcutAction)
177 if shortcut == nil {
178 logging.FromContext(h.ctx).Error().
179 Uint("keyval", keyval).
180 Msg("failed to create shortcut")
181 return
182 }
183
184 // Add to controller
185 h.controller.AddShortcut(shortcut)
186}
187
188// Detach removes the global shortcut handler from the window.
189// Note: GTK handles cleanup when the widget is destroyed,
190// but we clear our references here.
191func (h *GlobalShortcutHandler) Detach() {
192 h.controller = nil
193 h.callbacks = nil
194}