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// AccentHandler handles long-press accent detection.
21// Called for character keys that may have accent variants.
22type AccentHandler interface {
23 // OnKeyPressed is called when a key is pressed.
24 // Returns true if the accent handler is handling this key (picker visible or will show).
25 OnKeyPressed(ctx context.Context, char rune, shiftHeld bool) bool
26 // OnKeyReleased is called when a key is released.
27 OnKeyReleased(ctx context.Context, char rune)
28 // IsPickerVisible returns true if the accent picker is currently visible.
29 IsPickerVisible() bool
30 // Cancel cancels any pending long-press detection.
31 Cancel(ctx context.Context)
32}
33
34// KeyboardHandler processes keyboard events and dispatches actions.
35// It manages modal input modes and routes key events to the appropriate handlers.
36type KeyboardHandler struct {
37 shortcuts *ShortcutSet
38 modal *ModalState
39 cfg *config.Config
40
41 // Action handler callback
42 onAction ActionHandler
43 // Optional bypass check (e.g., omnibox visible)
44 shouldBypass func(modifiers Modifier) bool
45 // Optional accent handler for dead keys support
46 accentHandler AccentHandler
47 // Optional escape hook for app-level overlays
48 onEscape func(ctx context.Context) bool
49
50 // GTK controller (nil until attached)
51 controller *gtk.EventControllerKey
52
53 // Callback retention: must stay reachable by Go GC.
54 keyPressedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool
55 keyReleasedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType)
56
57 ctx context.Context
58 mu sync.RWMutex
59}
60
61// NewKeyboardHandler creates a new keyboard handler.
62func NewKeyboardHandler(ctx context.Context, cfg *config.Config) *KeyboardHandler {
63 log := logging.FromContext(ctx)
64
65 log.Debug().Msg("creating keyboard handler")
66
67 h := &KeyboardHandler{
68 shortcuts: NewShortcutSet(ctx, cfg),
69 modal: NewModalState(ctx),
70 cfg: cfg,
71 ctx: ctx,
72 }
73
74 return h
75}
76
77// SetOnAction sets the callback for when actions are triggered.
78func (h *KeyboardHandler) SetOnAction(fn ActionHandler) {
79 h.mu.Lock()
80 defer h.mu.Unlock()
81 h.onAction = fn
82}
83
84// ReloadShortcuts rebuilds the shortcut set from the new config.
85// This enables hot-reloading of keybindings without restarting.
86func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, cfg *config.Config) {
87 h.mu.Lock()
88 defer h.mu.Unlock()
89
90 log := logging.FromContext(ctx)
91 log.Debug().Msg("reloading shortcuts from config")
92
93 h.shortcuts = NewShortcutSet(ctx, cfg)
94 h.cfg = cfg
95}
96
97// SetOnModeChange sets the callback for mode changes (for UI updates).
98func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) {
99 h.modal.SetOnModeChange(fn)
100}
101
102// SetShouldBypassInput sets a hook to bypass keyboard handling entirely.
103// When true, events propagate to focused widgets instead.
104func (h *KeyboardHandler) SetShouldBypassInput(fn func(modifiers Modifier) bool) {
105 h.mu.Lock()
106 defer h.mu.Unlock()
107 h.shouldBypass = fn
108}
109
110// SetAccentHandler sets the handler for long-press accent detection.
111func (h *KeyboardHandler) SetAccentHandler(handler AccentHandler) {
112 h.mu.Lock()
113 defer h.mu.Unlock()
114 h.accentHandler = handler
115}
116
117// SetOnEscape sets an optional callback invoked for plain Escape in normal mode.
118// Return true to consume the key and stop further handling.
119func (h *KeyboardHandler) SetOnEscape(fn func(ctx context.Context) bool) {
120 h.mu.Lock()
121 defer h.mu.Unlock()
122 h.onEscape = fn
123}
124
125// Mode returns the current input mode.
126func (h *KeyboardHandler) Mode() Mode {
127 return h.modal.Mode()
128}
129
130// AttachTo attaches the keyboard handler to a GTK window.
131// The handler will intercept key events in the capture phase.
132func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
133 log := logging.FromContext(h.ctx)
134
135 if window == nil {
136 log.Error().Msg("cannot attach keyboard handler to nil window")
137 return
138 }
139
140 h.controller = gtk.NewEventControllerKey()
141 if h.controller == nil {
142 log.Error().Msg("failed to create event controller key")
143 return
144 }
145
146 // Set capture phase to intercept events before WebView gets them
147 h.controller.SetPropagationPhase(gtk.PhaseCaptureValue)
148
149 // Connect key pressed handler (retain callback to prevent GC).
150 // The callback receives: keyval (translated key), keycode (hardware key position), state (modifiers)
151 h.keyPressedCb = func(_ gtk.EventControllerKey, keyval uint, keycode uint, state gdk.ModifierType) bool {
152 return h.handleKeyPress(keyval, keycode, state)
153 }
154 h.controller.ConnectKeyPressed(&h.keyPressedCb)
155
156 // Connect key released handler for accent detection.
157 h.keyReleasedCb = func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) {
158 h.handleKeyRelease(keyval)
159 }
160 h.controller.ConnectKeyReleased(&h.keyReleasedCb)
161
162 // Add controller to window
163 window.AddController(&h.controller.EventController)
164
165 log.Debug().Msg("keyboard handler attached to window")
166}
167
168// Detach removes the keyboard handler.
169// Note: GTK handles cleanup when the widget is destroyed,
170// but we clear our reference here.
171func (h *KeyboardHandler) Detach() {
172 h.controller = nil
173 h.keyPressedCb = nil
174 h.keyReleasedCb = nil
175}
176
177// handleKeyPress processes a key press event.
178// Returns true if the event was handled and should not propagate further.
179// Parameters:
180// - keyval: the translated key value (depends on keyboard layout)
181// - keycode: the hardware keycode (physical key position, layout-independent)
182// - state: modifier keys state
183func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {
184 log := logging.FromContext(h.ctx)
185
186 h.mu.RLock()
187 shouldBypass := h.shouldBypass
188 accentHandler := h.accentHandler
189 onEscape := h.onEscape
190 h.mu.RUnlock()
191
192 // Check if accent picker is visible - it takes priority
193 if accentHandler != nil && accentHandler.IsPickerVisible() {
194 return false // Let the accent picker handle the key via its own controller
195 }
196
197 modifiers := Modifier(state) & modifierMask
198 if h.modal.Mode() == ModeNormal && keyval == uint(gdk.KEY_Escape) && modifiers == 0 {
199 if onEscape != nil && onEscape(h.ctx) {
200 return true
201 }
202 }
203
204 // Check accent detection first - works in both normal and bypass mode
205 // This enables accent picker for omnibox (GTK SearchEntry) as well as WebView
206 if h.tryAccentDetection(accentHandler, keyval, modifiers) {
207 return true
208 }
209
210 if shouldBypass != nil && shouldBypass(modifiers) {
211 log.Debug().Uint("keyval", keyval).Uint("keycode", keycode).Uint("state", uint(state)).Msg("keyboard handler bypassed")
212 return false
213 }
214
215 // Normalize uppercase letters for consistent binding lookup
216 keyval = normalizeKeyval(keyval)
217
218 binding := KeyBinding{Keyval: keyval, Modifiers: modifiers}
219 mode := h.modal.Mode()
220 action, found := h.lookupAction(log, binding, mode, modifiers, keycode)
221
222 if !found {
223 return mode != ModeNormal // Consume unrecognized keys in modal mode
224 }
225
226 return h.dispatchAction(action, mode)
227}
228
229// tryAccentDetection starts long-press detection for accent-eligible keys.
230// Returns true if the key should be suppressed (blocked from reaching the WebView).
231func (h *KeyboardHandler) tryAccentDetection(accentHandler AccentHandler, keyval uint, modifiers Modifier) bool {
232 if h.modal.Mode() != ModeNormal || accentHandler == nil {
233 return false
234 }
235 // Only consider keys without Ctrl/Alt modifiers (Shift is OK for uppercase)
236 if modifiers != 0 && modifiers != ModShift {
237 return false
238 }
239 if char := keyvalToRune(keyval); char != 0 {
240 return accentHandler.OnKeyPressed(h.ctx, char, modifiers == ModShift)
241 }
242 return false
243}
244
245// normalizeKeyval converts uppercase A-Z to lowercase for consistent binding lookup.
246func normalizeKeyval(keyval uint) uint {
247 if keyval >= uint('A') && keyval <= uint('Z') {
248 return keyval + (uint('a') - uint('A'))
249 }
250 return keyval
251}
252
253// dispatchAction dispatches the action and handles mode-related logic.
254func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool {
255 if h.handleModeAction(action) {
256 return true
257 }
258
259 h.mu.RLock()
260 handler := h.onAction
261 h.mu.RUnlock()
262
263 if handler != nil {
264 if err := handler(h.ctx, action); err != nil {
265 log := logging.FromContext(h.ctx)
266 log.Error().Err(err).Str("action", string(action)).Msg("action handler error")
267 }
268 }
269
270 if mode == ModeResize && isResizeAction(action) {
271 h.modal.ResetTimeout(h.ctx)
272 }
273
274 if mode != ModeNormal && ShouldAutoExitMode(action) {
275 h.modal.ExitMode(h.ctx)
276 }
277
278 return true // Consumed the key
279}
280
281func (h *KeyboardHandler) lookupAction(
282 log *zerolog.Logger,
283 binding KeyBinding,
284 mode Mode,
285 modifiers Modifier,
286 keycode uint,
287) (Action, bool) {
288 action, found := h.shortcuts.Lookup(binding, mode)
289
290 // Fallback: check hardware keycode for Alt+number shortcuts on non-QWERTY layouts.
291 if !found && mode == ModeNormal && modifiers == ModAlt {
292 if keycodeAction, ok := KeycodeToTabAction[keycode]; ok {
293 action = keycodeAction
294 found = true
295 if log != nil {
296 log.Debug().
297 Uint("keycode", keycode).
298 Str("action", string(action)).
299 Msg("tab switch via hardware keycode fallback (non-QWERTY layout)")
300 }
301 }
302 }
303
304 if !found && mode == ModeNormal && (modifiers == ModAlt || modifiers == (ModAlt|ModShift)) {
305 if bracketActions, ok := KeycodeToBracketAction[keycode]; ok {
306 if modifiers == (ModAlt | ModShift) {
307 action = bracketActions.WithShift
308 } else {
309 action = bracketActions.NoShift
310 }
311 found = action != ""
312 if found && log != nil {
313 log.Debug().
314 Uint("keycode", keycode).
315 Str("action", string(action)).
316 Msg("consume/expel via hardware keycode fallback (non-QWERTY layout)")
317 }
318 }
319 }
320
321 return action, found
322}
323
324func isResizeAction(action Action) bool {
325 switch action {
326 case ActionResizeIncreaseLeft,
327 ActionResizeIncreaseRight,
328 ActionResizeIncreaseUp,
329 ActionResizeIncreaseDown,
330 ActionResizeDecreaseLeft,
331 ActionResizeDecreaseRight,
332 ActionResizeDecreaseUp,
333 ActionResizeDecreaseDown,
334 ActionResizeIncrease,
335 ActionResizeDecrease:
336 return true
337 default:
338 return false
339 }
340}
341
342func (h *KeyboardHandler) handleModeAction(action Action) bool {
343 switch action {
344 case ActionEnterTabMode:
345 timeout := time.Duration(h.cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond
346 h.modal.EnterTabMode(h.ctx, timeout)
347 return true
348 case ActionEnterPaneMode:
349 timeout := time.Duration(h.cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond
350 h.modal.EnterPaneMode(h.ctx, timeout)
351 return true
352 case ActionEnterSessionMode:
353 timeout := time.Duration(h.cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond
354 h.modal.EnterSessionMode(h.ctx, timeout)
355 return true
356 case ActionEnterResizeMode:
357 if h.modal.Mode() == ModeResize {
358 h.modal.ExitMode(h.ctx)
359 return true
360 }
361 timeout := time.Duration(h.cfg.Workspace.ResizeMode.TimeoutMilliseconds) * time.Millisecond
362 h.modal.EnterResizeMode(h.ctx, timeout)
363 return true
364 case ActionExitMode:
365 h.modal.ExitMode(h.ctx)
366 return true
367 default:
368 return false
369 }
370}
371
372// EnterTabMode programmatically enters tab mode.
373// Useful for testing or programmatic mode changes.
374func (h *KeyboardHandler) EnterTabMode() {
375 timeout := time.Duration(h.cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond
376 h.modal.EnterTabMode(h.ctx, timeout)
377}
378
379// EnterPaneMode programmatically enters pane mode.
380// Useful for testing or programmatic mode changes.
381func (h *KeyboardHandler) EnterPaneMode() {
382 timeout := time.Duration(h.cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond
383 h.modal.EnterPaneMode(h.ctx, timeout)
384}
385
386// EnterSessionMode programmatically enters session mode.
387// Useful for testing or programmatic mode changes.
388func (h *KeyboardHandler) EnterSessionMode() {
389 timeout := time.Duration(h.cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond
390 h.modal.EnterSessionMode(h.ctx, timeout)
391}
392
393// ExitMode programmatically exits modal mode.
394// Useful for testing or programmatic mode changes.
395func (h *KeyboardHandler) ExitMode() {
396 h.modal.ExitMode(h.ctx)
397}
398
399// handleKeyRelease processes a key release event for accent detection.
400func (h *KeyboardHandler) handleKeyRelease(keyval uint) {
401 h.mu.RLock()
402 accentHandler := h.accentHandler
403 h.mu.RUnlock()
404
405 if accentHandler == nil {
406 return
407 }
408
409 // Convert keyval to rune for the accent handler
410 if char := keyvalToRune(keyval); char != 0 {
411 accentHandler.OnKeyReleased(h.ctx, char)
412 }
413}
414
415// keyvalToRune converts a GTK keyval to a lowercase rune.
416// Returns 0 if the keyval is not a printable character with potential accents.
417func keyvalToRune(keyval uint) rune {
418 // Handle lowercase a-z
419 if keyval >= uint('a') && keyval <= uint('z') {
420 return rune(keyval)
421 }
422 // Handle uppercase A-Z (convert to lowercase for accent lookup)
423 if keyval >= uint('A') && keyval <= uint('Z') {
424 return rune(keyval + ('a' - 'A'))
425 }
426 return 0
427}