1package input
2
3import (
4 "context"
5 "sync"
6 "time"
7
8 "github.com/bnema/dumber/internal/infrastructure/config"
9 "github.com/bnema/dumber/internal/logging"
10 "github.com/jwijenbergh/puregotk/v4/gdk"
11 "github.com/jwijenbergh/puregotk/v4/gtk"
12 "github.com/rs/zerolog"
13)
14
15// ActionHandler is called when a keyboard action is triggered.
16// It receives the context and the action to perform.
17// Return an error if the action fails.
18type ActionHandler func(ctx context.Context, action Action) error
19
20// KeyboardHandler processes keyboard events and dispatches actions.
21// It manages modal input modes and routes key events to the appropriate handlers.
22type KeyboardHandler struct {
23 shortcuts *ShortcutSet
24 modal *ModalState
25 cfg *config.Config
26
27 // Action handler callback
28 onAction ActionHandler
29 // Optional bypass check (e.g., omnibox visible)
30 shouldBypass func() bool
31
32 // GTK controller (nil until attached)
33 controller *gtk.EventControllerKey
34
35 // Callback retention: must stay reachable by Go GC.
36 keyPressedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool
37
38 ctx context.Context
39 mu sync.RWMutex
40}
41
42// NewKeyboardHandler creates a new keyboard handler.
43func NewKeyboardHandler(ctx context.Context, cfg *config.Config) *KeyboardHandler {
44 log := logging.FromContext(ctx)
45
46 log.Debug().Msg("creating keyboard handler")
47
48 h := &KeyboardHandler{
49 shortcuts: NewShortcutSet(ctx, cfg),
50 modal: NewModalState(ctx),
51 cfg: cfg,
52 ctx: ctx,
53 }
54
55 return h
56}
57
58// SetOnAction sets the callback for when actions are triggered.
59func (h *KeyboardHandler) SetOnAction(fn ActionHandler) {
60 h.mu.Lock()
61 defer h.mu.Unlock()
62 h.onAction = fn
63}
64
65// SetOnModeChange sets the callback for mode changes (for UI updates).
66func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) {
67 h.modal.SetOnModeChange(fn)
68}
69
70// SetShouldBypassInput sets a hook to bypass keyboard handling entirely.
71// When true, events propagate to focused widgets instead.
72func (h *KeyboardHandler) SetShouldBypassInput(fn func() bool) {
73 h.mu.Lock()
74 defer h.mu.Unlock()
75 h.shouldBypass = fn
76}
77
78// Mode returns the current input mode.
79func (h *KeyboardHandler) Mode() Mode {
80 return h.modal.Mode()
81}
82
83// AttachTo attaches the keyboard handler to a GTK window.
84// The handler will intercept key events in the capture phase.
85func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
86 log := logging.FromContext(h.ctx)
87
88 if window == nil {
89 log.Error().Msg("cannot attach keyboard handler to nil window")
90 return
91 }
92
93 h.controller = gtk.NewEventControllerKey()
94 if h.controller == nil {
95 log.Error().Msg("failed to create event controller key")
96 return
97 }
98
99 // Set capture phase to intercept events before WebView gets them
100 h.controller.SetPropagationPhase(gtk.PhaseCaptureValue)
101
102 // Connect key pressed handler (retain callback to prevent GC).
103 // The callback receives: keyval (translated key), keycode (hardware key position), state (modifiers)
104 h.keyPressedCb = func(_ gtk.EventControllerKey, keyval uint, keycode uint, state gdk.ModifierType) bool {
105 return h.handleKeyPress(keyval, keycode, state)
106 }
107 h.controller.ConnectKeyPressed(&h.keyPressedCb)
108
109 // Add controller to window
110 window.AddController(&h.controller.EventController)
111
112 log.Debug().Msg("keyboard handler attached to window")
113}
114
115// Detach removes the keyboard handler.
116// Note: GTK handles cleanup when the widget is destroyed,
117// but we clear our reference here.
118func (h *KeyboardHandler) Detach() {
119 h.controller = nil
120 h.keyPressedCb = nil
121}
122
123// handleKeyPress processes a key press event.
124// Returns true if the event was handled and should not propagate further.
125// Parameters:
126// - keyval: the translated key value (depends on keyboard layout)
127// - keycode: the hardware keycode (physical key position, layout-independent)
128// - state: modifier keys state
129func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {
130 log := logging.FromContext(h.ctx)
131
132 h.mu.RLock()
133 shouldBypass := h.shouldBypass
134 h.mu.RUnlock()
135 if shouldBypass != nil && shouldBypass() {
136 log.Debug().Uint("keyval", keyval).Uint("keycode", keycode).Uint("state", uint(state)).Msg("keyboard handler bypassed")
137 return false
138 }
139
140 // Build KeyBinding from event
141 modifiers := Modifier(state) & modifierMask
142
143 // GTK reports shifted letters as uppercase keyvals (e.g. Shift+M -> gdk.KEY_M).
144 // Normalize ASCII A-Z to lowercase so bindings can consistently use lowercase
145 // keyvals and rely on ModShift to represent Shift.
146 if keyval >= uint('A') && keyval <= uint('Z') {
147 keyval += (uint('a') - uint('A'))
148 }
149
150 binding := KeyBinding{
151 Keyval: keyval,
152 Modifiers: modifiers,
153 }
154
155 mode := h.modal.Mode()
156
157 action, found := h.lookupAction(log, binding, mode, modifiers, keycode)
158
159 if !found {
160 if mode != ModeNormal {
161 // In modal mode, consume unrecognized keys to prevent WebView handling
162 return true
163 }
164 // In normal mode, let the key pass through to WebView
165 return false
166 }
167
168 if h.handleModeAction(action) {
169 return true
170 }
171
172 // Dispatch action to handler
173 h.mu.RLock()
174 handler := h.onAction
175 h.mu.RUnlock()
176
177 if handler != nil {
178 if err := handler(h.ctx, action); err != nil {
179 log := logging.FromContext(h.ctx)
180 log.Error().
181 Err(err).
182 Str("action", string(action)).
183 Msg("action handler error")
184 }
185 }
186
187 // In resize mode, any resize keypress should extend the timeout.
188 if mode == ModeResize && isResizeAction(action) {
189 h.modal.ResetTimeout(h.ctx)
190 }
191
192 // Auto-exit modal mode after certain actions
193 if mode != ModeNormal && ShouldAutoExitMode(action) {
194 h.modal.ExitMode(h.ctx)
195 }
196
197 return true // Consumed the key
198}
199
200func (h *KeyboardHandler) lookupAction(
201 log *zerolog.Logger,
202 binding KeyBinding,
203 mode Mode,
204 modifiers Modifier,
205 keycode uint,
206) (Action, bool) {
207 action, found := h.shortcuts.Lookup(binding, mode)
208
209 // Fallback: check hardware keycode for Alt+number shortcuts on non-QWERTY layouts.
210 if !found && mode == ModeNormal && modifiers == ModAlt {
211 if keycodeAction, ok := KeycodeToTabAction[keycode]; ok {
212 action = keycodeAction
213 found = true
214 if log != nil {
215 log.Debug().
216 Uint("keycode", keycode).
217 Str("action", string(action)).
218 Msg("tab switch via hardware keycode fallback (non-QWERTY layout)")
219 }
220 }
221 }
222
223 if !found && mode == ModeNormal && (modifiers == ModAlt || modifiers == (ModAlt|ModShift)) {
224 if bracketActions, ok := KeycodeToBracketAction[keycode]; ok {
225 if modifiers == (ModAlt | ModShift) {
226 action = bracketActions.WithShift
227 } else {
228 action = bracketActions.NoShift
229 }
230 found = action != ""
231 if found && log != nil {
232 log.Debug().
233 Uint("keycode", keycode).
234 Str("action", string(action)).
235 Msg("consume/expel via hardware keycode fallback (non-QWERTY layout)")
236 }
237 }
238 }
239
240 return action, found
241}
242
243func isResizeAction(action Action) bool {
244 switch action {
245 case ActionResizeIncreaseLeft,
246 ActionResizeIncreaseRight,
247 ActionResizeIncreaseUp,
248 ActionResizeIncreaseDown,
249 ActionResizeDecreaseLeft,
250 ActionResizeDecreaseRight,
251 ActionResizeDecreaseUp,
252 ActionResizeDecreaseDown,
253 ActionResizeIncrease,
254 ActionResizeDecrease:
255 return true
256 default:
257 return false
258 }
259}
260
261func (h *KeyboardHandler) handleModeAction(action Action) bool {
262 switch action {
263 case ActionEnterTabMode:
264 timeout := time.Duration(h.cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond
265 h.modal.EnterTabMode(h.ctx, timeout)
266 return true
267 case ActionEnterPaneMode:
268 timeout := time.Duration(h.cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond
269 h.modal.EnterPaneMode(h.ctx, timeout)
270 return true
271 case ActionEnterSessionMode:
272 timeout := time.Duration(h.cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond
273 h.modal.EnterSessionMode(h.ctx, timeout)
274 return true
275 case ActionEnterResizeMode:
276 if h.modal.Mode() == ModeResize {
277 h.modal.ExitMode(h.ctx)
278 return true
279 }
280 timeout := time.Duration(h.cfg.Workspace.ResizeMode.TimeoutMilliseconds) * time.Millisecond
281 h.modal.EnterResizeMode(h.ctx, timeout)
282 return true
283 case ActionExitMode:
284 h.modal.ExitMode(h.ctx)
285 return true
286 default:
287 return false
288 }
289}
290
291// EnterTabMode programmatically enters tab mode.
292// Useful for testing or programmatic mode changes.
293func (h *KeyboardHandler) EnterTabMode() {
294 timeout := time.Duration(h.cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond
295 h.modal.EnterTabMode(h.ctx, timeout)
296}
297
298// EnterPaneMode programmatically enters pane mode.
299// Useful for testing or programmatic mode changes.
300func (h *KeyboardHandler) EnterPaneMode() {
301 timeout := time.Duration(h.cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond
302 h.modal.EnterPaneMode(h.ctx, timeout)
303}
304
305// EnterSessionMode programmatically enters session mode.
306// Useful for testing or programmatic mode changes.
307func (h *KeyboardHandler) EnterSessionMode() {
308 timeout := time.Duration(h.cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond
309 h.modal.EnterSessionMode(h.ctx, timeout)
310}
311
312// ExitMode programmatically exits modal mode.
313// Useful for testing or programmatic mode changes.
314func (h *KeyboardHandler) ExitMode() {
315 h.modal.ExitMode(h.ctx)
316}