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