1package input
2
3// Manual Testing: Dead Key / Compose Support
4//
5// To verify native dead key support works correctly:
6//
7// 1. Set keyboard layout to "US International with dead keys"
8// 2. Open dumber, navigate to a page with a text input (e.g. Google search)
9// 3. Click into the text input field
10// 4. Type: ' then e -- should produce e with acute accent
11// 5. Type: " then u -- should produce u with diaeresis
12// 6. Type: ` then a -- should produce a with grave accent
13// 7. Type: ~ then n -- should produce n with tilde
14// 8. Verify Ctrl+L still opens omnibox
15// 9. Verify Alt+1-9 still switches tabs
16// 10. Verify modal modes (Ctrl+T for tab mode) still work
17// 11. In omnibox, verify long-press accent picker still works (hold 'e' for 400ms)
18// 12. Verify Escape closes omnibox / exits modal modes
19// 13. Verify session manager overlay captures all keyboard input
20
21import (
22 "context"
23 "sync"
24 "time"
25
26 "github.com/bnema/dumber/internal/domain/entity"
27 "github.com/bnema/dumber/internal/logging"
28 "github.com/bnema/puregotk/v4/gdk"
29 "github.com/bnema/puregotk/v4/glib"
30 "github.com/bnema/puregotk/v4/gtk"
31 "github.com/rs/zerolog"
32)
33
34// ActionHandler is called when a keyboard action is triggered.
35// It receives the context and the action to perform.
36// Return an error if the action fails.
37type ActionHandler func(ctx context.Context, action Action) error
38
39// AccentHandler handles long-press accent detection.
40// Called for character keys that may have accent variants.
41type AccentHandler interface {
42 // OnKeyPressed is called when a key is pressed.
43 // Returns true if the accent handler is handling this key (picker visible or will show).
44 OnKeyPressed(ctx context.Context, char rune, shiftHeld bool) bool
45 // OnKeyReleased is called when a key is released.
46 OnKeyReleased(ctx context.Context, char rune)
47 // IsPickerVisible returns true if the accent picker is currently visible.
48 IsPickerVisible() bool
49 // Cancel cancels any pending long-press detection.
50 Cancel(ctx context.Context)
51}
52
53// KeyboardHandler processes keyboard events and dispatches actions.
54// It manages modal input modes and routes key events to the appropriate handlers.
55type KeyboardHandler struct {
56 shortcuts *ShortcutSet
57 modal *ModalState
58 workspace *entity.WorkspaceConfig
59 session *entity.SessionConfig
60
61 // Action handler callback
62 onAction ActionHandler
63 // Optional routing callback that determines how a key should be handled.
64 // Returns RouteHandleShortcuts (default), RoutePassToWidget (let focused
65 // widget handle it), or RouteAccentDetection (long-press accent for GTK entries).
66 routeKey func(KeyContext) KeyRoute
67 // Optional accent handler for dead keys support
68 accentHandler AccentHandler
69 // Optional escape hook for app-level overlays
70 onEscape func(ctx context.Context) bool
71
72 // GTK controller (nil until attached)
73 controller *gtk.EventControllerKey
74
75 // Callback retention: must stay reachable by Go GC.
76 keyPressedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool
77 keyReleasedCb func(gtk.EventControllerKey, uint, uint, gdk.ModifierType)
78
79 ctx context.Context
80 mu sync.RWMutex
81}
82
83// NewKeyboardHandler creates a new keyboard handler.
84func NewKeyboardHandler(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) *KeyboardHandler {
85 log := logging.FromContext(ctx)
86
87 log.Debug().Msg("creating keyboard handler")
88
89 h := &KeyboardHandler{
90 shortcuts: NewShortcutSet(ctx, workspace, session),
91 modal: NewModalState(ctx),
92 workspace: workspace,
93 session: session,
94 ctx: ctx,
95 }
96
97 return h
98}
99
100// SetOnAction sets the callback for when actions are triggered.
101func (h *KeyboardHandler) SetOnAction(fn ActionHandler) {
102 h.mu.Lock()
103 defer h.mu.Unlock()
104 h.onAction = fn
105}
106
107// ReloadShortcuts rebuilds the shortcut set from new config values.
108// This enables hot-reloading of keybindings without restarting.
109func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
110 h.mu.Lock()
111 defer h.mu.Unlock()
112
113 log := logging.FromContext(ctx)
114 log.Debug().Msg("reloading shortcuts from config")
115
116 h.shortcuts = NewShortcutSet(ctx, workspace, session)
117 h.workspace = workspace
118 h.session = session
119}
120
121// SetOnModeChange sets the callback for mode changes (for UI updates).
122func (h *KeyboardHandler) SetOnModeChange(fn func(from, to Mode)) {
123 h.modal.SetOnModeChange(func(from, to Mode) {
124 // Switch controller phase: capture during modal, bubble for normal.
125 // This ensures plain modal keys (s, h, etc.) reach the handler
126 // before WebView, while normal typing still goes through WebKit IM.
127 if to == ModeNormal {
128 h.setControllerPhase(gtk.PhaseBubbleValue)
129 } else if from == ModeNormal {
130 h.setControllerPhase(gtk.PhaseCaptureValue)
131 }
132 // Forward to app-level callback
133 if fn != nil {
134 fn(from, to)
135 }
136 })
137}
138
139// setControllerPhase changes the propagation phase of the key controller.
140// Capture phase during modal modes so plain keys reach the handler before WebView.
141// Bubble phase in normal mode for WebKit IM/dead-key support.
142//
143// This is called synchronously from onModeChange so the phase switch takes
144// effect before the next key event. The caller must ensure GTK thread safety;
145// modal timeouts dispatch to the GTK main thread via glib.IdleAdd before
146// calling ExitMode, so the onModeChange callback always runs on the GTK thread.
147func (h *KeyboardHandler) setControllerPhase(phase gtk.PropagationPhase) {
148 if h.controller == nil {
149 return
150 }
151 h.controller.SetPropagationPhase(phase)
152}
153
154// SetRouteKey sets the callback that determines how each key event should be routed.
155// The callback receives key context and returns a KeyRoute indicating whether the
156// key should be handled by the shortcut system, passed to the focused widget
157// (for IM/compose support), or routed through accent detection.
158//
159// If not set, all keys are routed through the shortcut system (RouteHandleShortcuts).
160func (h *KeyboardHandler) SetRouteKey(fn func(KeyContext) KeyRoute) {
161 h.mu.Lock()
162 defer h.mu.Unlock()
163 h.routeKey = fn
164}
165
166// SetAccentHandler sets the handler for long-press accent detection.
167func (h *KeyboardHandler) SetAccentHandler(handler AccentHandler) {
168 h.mu.Lock()
169 defer h.mu.Unlock()
170 h.accentHandler = handler
171}
172
173// SetOnEscape sets an optional callback invoked for plain Escape in normal mode.
174// Return true to consume the key and stop further handling.
175func (h *KeyboardHandler) SetOnEscape(fn func(ctx context.Context) bool) {
176 h.mu.Lock()
177 defer h.mu.Unlock()
178 h.onEscape = fn
179}
180
181// Mode returns the current input mode.
182func (h *KeyboardHandler) Mode() Mode {
183 return h.modal.Mode()
184}
185
186// AttachTo attaches the keyboard handler to a GTK window.
187// The handler runs in the bubble phase so that WebKit's IM pipeline (including
188// dead-key / compose sequences) processes key events first. App-level shortcuts
189// are still intercepted because WebKit only consumes events it recognizes as
190// text input; unhandled events bubble up to this controller.
191func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) {
192 log := logging.FromContext(h.ctx)
193
194 if window == nil {
195 log.Error().Msg("cannot attach keyboard handler to nil window")
196 return
197 }
198
199 h.controller = gtk.NewEventControllerKey()
200 if h.controller == nil {
201 log.Error().Msg("failed to create event controller key")
202 return
203 }
204
205 // Bubble phase: let WebKit IM handle key events first (dead keys, compose
206 // sequences, input methods). Events not consumed by WebKit bubble up here
207 // so app shortcuts still work.
208 h.controller.SetPropagationPhase(gtk.PhaseBubbleValue)
209
210 // Wire GTK main thread scheduler for modal timeouts. Timer goroutines
211 // must dispatch ExitMode to the GTK thread because onModeChange may
212 // call setControllerPhase (a GTK operation).
213 h.modal.SetMainThreadScheduler(func(fn func()) {
214 cb := glib.SourceFunc(func(_ uintptr) bool {
215 fn()
216 return false
217 })
218 glib.IdleAdd(&cb, 0)
219 })
220
221 // Connect key pressed handler (retain callback to prevent GC).
222 // The callback receives: keyval (translated key), keycode (hardware key position), state (modifiers)
223 h.keyPressedCb = func(_ gtk.EventControllerKey, keyval uint, keycode uint, state gdk.ModifierType) bool {
224 return h.handleKeyPress(keyval, keycode, state)
225 }
226 h.controller.ConnectKeyPressed(&h.keyPressedCb)
227
228 // Connect key released handler for accent detection.
229 h.keyReleasedCb = func(_ gtk.EventControllerKey, keyval uint, _ uint, _ gdk.ModifierType) {
230 h.handleKeyRelease(keyval)
231 }
232 h.controller.ConnectKeyReleased(&h.keyReleasedCb)
233
234 // Add controller to window
235 window.AddController(&h.controller.EventController)
236
237 log.Debug().Msg("keyboard handler attached to window")
238}
239
240// Detach removes the keyboard handler.
241// Note: GTK handles cleanup when the widget is destroyed,
242// but we clear our reference here.
243func (h *KeyboardHandler) Detach() {
244 h.controller = nil
245 h.keyPressedCb = nil
246 h.keyReleasedCb = nil
247}
248
249// handleKeyPress processes a key press event.
250// Returns true if the event was handled and should not propagate further.
251func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.ModifierType) bool {
252 log := logging.FromContext(h.ctx)
253
254 h.mu.RLock()
255 routeKey := h.routeKey
256 accentHandler := h.accentHandler
257 onEscape := h.onEscape
258 h.mu.RUnlock()
259
260 // Accent picker takes absolute priority when visible -- it has its own key controller
261 if accentHandler != nil && accentHandler.IsPickerVisible() {
262 return false
263 }
264
265 modifiers := Modifier(state) & modifierMask
266
267 // Escape in normal mode: check app-level escape hook first
268 if h.modal.Mode() == ModeNormal && keyval == uint(gdk.KEY_Escape) && modifiers == 0 {
269 if onEscape != nil && onEscape(h.ctx) {
270 return true
271 }
272 }
273
274 // Determine routing for this key event
275 route := RouteHandleShortcuts // default: process through shortcut system
276 if routeKey != nil && h.modal.Mode() == ModeNormal {
277 route = routeKey(KeyContext{
278 Keyval: keyval,
279 Keycode: keycode,
280 Modifiers: modifiers,
281 })
282 }
283
284 switch route {
285 case RoutePassToWidget:
286 // Let the focused widget handle this key directly (WebView IM, etc.)
287 // No accent detection here -- WebView has its own JS-based accent detection.
288 log.Trace().Uint("keyval", keyval).Msg("routing key to focused widget")
289 return false
290
291 case RouteAccentDetection:
292 // Try long-press accent detection for GTK Entry widgets (omnibox, find bar).
293 // If the accent handler doesn't consume the key, pass it to the widget.
294 if h.tryAccentDetection(accentHandler, keyval, modifiers) {
295 return true
296 }
297 log.Trace().Uint("keyval", keyval).Msg("accent detection declined, routing to widget")
298 return false
299
300 case RouteHandleShortcuts:
301 // Fall through to shortcut processing below
302 }
303
304 // --- Shortcut processing path ---
305
306 // Normalize uppercase letters for consistent binding lookup
307 keyval = normalizeKeyval(keyval)
308
309 binding := KeyBinding{Keyval: keyval, Modifiers: modifiers}
310 mode := h.modal.Mode()
311 action, found := h.lookupAction(log, binding, mode, modifiers, keycode)
312
313 if !found {
314 return mode != ModeNormal // Consume unrecognized keys in modal mode
315 }
316
317 return h.dispatchAction(action, mode)
318}
319
320// tryAccentDetection starts long-press detection for accent-eligible keys.
321// It is used only for the RouteAccentDetection route (GTK Entry widgets such as
322// the omnibox and find bar). RoutePassToWidget does not call tryAccentDetection;
323// WebView long-press accent behavior is handled entirely by the injected JS bridge
324// (accentDetectionScript) and the Go-side accent key detection script, not here.
325// Returns true if the key should be suppressed (i.e. RouteAccentDetection consumed it).
326func (h *KeyboardHandler) tryAccentDetection(accentHandler AccentHandler, keyval uint, modifiers Modifier) bool {
327 if accentHandler == nil {
328 return false
329 }
330 // Only consider keys without Ctrl/Alt modifiers (Shift is OK for uppercase)
331 if modifiers != 0 && modifiers != ModShift {
332 return false
333 }
334 if char := KeyvalToRune(keyval); char != 0 {
335 return accentHandler.OnKeyPressed(h.ctx, char, modifiers == ModShift)
336 }
337 return false
338}
339
340// normalizeKeyval converts uppercase A-Z to lowercase for consistent binding lookup.
341func normalizeKeyval(keyval uint) uint {
342 if keyval >= uint('A') && keyval <= uint('Z') {
343 return keyval + (uint('a') - uint('A'))
344 }
345 return keyval
346}
347
348// dispatchAction dispatches the action and handles mode-related logic.
349func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool {
350 if h.handleModeAction(action) {
351 return true
352 }
353
354 h.mu.RLock()
355 handler := h.onAction
356 h.mu.RUnlock()
357
358 if handler != nil {
359 if err := handler(h.ctx, action); err != nil {
360 log := logging.FromContext(h.ctx)
361 log.Error().Err(err).Str("action", string(action)).Msg("action handler error")
362 }
363 }
364
365 if mode == ModeResize && isResizeAction(action) {
366 h.modal.ResetTimeout(h.ctx)
367 }
368
369 if mode != ModeNormal && ShouldAutoExitMode(action) {
370 h.modal.ExitMode(h.ctx)
371 }
372
373 return true // Consumed the key
374}
375
376func (h *KeyboardHandler) lookupAction(
377 log *zerolog.Logger,
378 binding KeyBinding,
379 mode Mode,
380 modifiers Modifier,
381 keycode uint,
382) (Action, bool) {
383 action, found := h.shortcuts.Lookup(binding, mode)
384
385 // Fallback: check hardware keycode for Alt+number shortcuts on non-QWERTY layouts.
386 if !found && mode == ModeNormal && modifiers == ModAlt {
387 if keycodeAction, ok := KeycodeToTabAction[keycode]; ok {
388 action = keycodeAction
389 found = true
390 if log != nil {
391 log.Debug().
392 Uint("keycode", keycode).
393 Str("action", string(action)).
394 Msg("tab switch via hardware keycode fallback (non-QWERTY layout)")
395 }
396 }
397 }
398
399 if !found && mode == ModeNormal && (modifiers == ModAlt || modifiers == (ModAlt|ModShift)) {
400 if bracketActions, ok := KeycodeToBracketAction[keycode]; ok {
401 if modifiers == (ModAlt | ModShift) {
402 action = bracketActions.WithShift
403 } else {
404 action = bracketActions.NoShift
405 }
406 found = action != ""
407 if found && log != nil {
408 log.Debug().
409 Uint("keycode", keycode).
410 Str("action", string(action)).
411 Msg("consume/expel via hardware keycode fallback (non-QWERTY layout)")
412 }
413 }
414 }
415
416 return action, found
417}
418
419func isResizeAction(action Action) bool {
420 switch action {
421 case ActionResizeIncreaseLeft,
422 ActionResizeIncreaseRight,
423 ActionResizeIncreaseUp,
424 ActionResizeIncreaseDown,
425 ActionResizeDecreaseLeft,
426 ActionResizeDecreaseRight,
427 ActionResizeDecreaseUp,
428 ActionResizeDecreaseDown,
429 ActionResizeIncrease,
430 ActionResizeDecrease:
431 return true
432 default:
433 return false
434 }
435}
436
437func (h *KeyboardHandler) handleModeAction(action Action) bool {
438 h.mu.RLock()
439 workspace := h.workspace
440 session := h.session
441 h.mu.RUnlock()
442
443 switch action {
444 case ActionEnterTabMode:
445 var ms int
446 if workspace != nil {
447 ms = workspace.TabMode.TimeoutMilliseconds
448 }
449 h.modal.EnterTabMode(h.ctx, time.Duration(ms)*time.Millisecond)
450 return true
451 case ActionEnterPaneMode:
452 var ms int
453 if workspace != nil {
454 ms = workspace.PaneMode.TimeoutMilliseconds
455 }
456 h.modal.EnterPaneMode(h.ctx, time.Duration(ms)*time.Millisecond)
457 return true
458 case ActionEnterSessionMode:
459 var ms int
460 if session != nil {
461 ms = session.SessionMode.TimeoutMilliseconds
462 }
463 h.modal.EnterSessionMode(h.ctx, time.Duration(ms)*time.Millisecond)
464 return true
465 case ActionEnterResizeMode:
466 if h.modal.Mode() == ModeResize {
467 h.modal.ExitMode(h.ctx)
468 return true
469 }
470 var ms int
471 if workspace != nil {
472 ms = workspace.ResizeMode.TimeoutMilliseconds
473 }
474 h.modal.EnterResizeMode(h.ctx, time.Duration(ms)*time.Millisecond)
475 return true
476 case ActionExitMode:
477 h.modal.ExitMode(h.ctx)
478 return true
479 default:
480 return false
481 }
482}
483
484// EnterTabMode programmatically enters tab mode.
485// Useful for testing or programmatic mode changes.
486func (h *KeyboardHandler) EnterTabMode() {
487 h.mu.RLock()
488 workspace := h.workspace
489 h.mu.RUnlock()
490 var ms int
491 if workspace != nil {
492 ms = workspace.TabMode.TimeoutMilliseconds
493 }
494 h.modal.EnterTabMode(h.ctx, time.Duration(ms)*time.Millisecond)
495}
496
497// EnterPaneMode programmatically enters pane mode.
498// Useful for testing or programmatic mode changes.
499func (h *KeyboardHandler) EnterPaneMode() {
500 h.mu.RLock()
501 workspace := h.workspace
502 h.mu.RUnlock()
503 var ms int
504 if workspace != nil {
505 ms = workspace.PaneMode.TimeoutMilliseconds
506 }
507 h.modal.EnterPaneMode(h.ctx, time.Duration(ms)*time.Millisecond)
508}
509
510// EnterSessionMode programmatically enters session mode.
511// Useful for testing or programmatic mode changes.
512func (h *KeyboardHandler) EnterSessionMode() {
513 h.mu.RLock()
514 session := h.session
515 h.mu.RUnlock()
516 var ms int
517 if session != nil {
518 ms = session.SessionMode.TimeoutMilliseconds
519 }
520 h.modal.EnterSessionMode(h.ctx, time.Duration(ms)*time.Millisecond)
521}
522
523// ExitMode programmatically exits modal mode.
524// Useful for testing or programmatic mode changes.
525func (h *KeyboardHandler) ExitMode() {
526 h.modal.ExitMode(h.ctx)
527}
528
529// DispatchAction processes an action externally triggered (e.g., by a global
530// shortcut). Mode-enter actions update modal state; other actions are forwarded
531// to the registered action handler.
532func (h *KeyboardHandler) DispatchAction(action Action) {
533 mode := h.modal.Mode()
534 h.dispatchAction(action, mode)
535}
536
537// handleKeyRelease processes a key release event for accent detection.
538func (h *KeyboardHandler) handleKeyRelease(keyval uint) {
539 h.mu.RLock()
540 accentHandler := h.accentHandler
541 h.mu.RUnlock()
542
543 if accentHandler == nil {
544 return
545 }
546
547 // Convert keyval to rune for the accent handler
548 if char := KeyvalToRune(keyval); char != 0 {
549 accentHandler.OnKeyReleased(h.ctx, char)
550 }
551}
552
553// KeyvalToRune converts a GTK keyval to a lowercase rune.
554// Returns 0 if the keyval is not a printable character with potential accents.
555func KeyvalToRune(keyval uint) rune {
556 // Handle lowercase a-z
557 if keyval >= uint('a') && keyval <= uint('z') {
558 return rune(keyval)
559 }
560 // Handle uppercase A-Z (convert to lowercase for accent lookup)
561 if keyval >= uint('A') && keyval <= uint('Z') {
562 return rune(keyval + ('a' - 'A'))
563 }
564 return 0
565}
566
567// KeyRoute determines how a key event should be routed.
568type KeyRoute int
569
570const (
571 // RouteHandleShortcuts means the key should be processed by the shortcut system.
572 // This is the default route -- app-level shortcuts, modal mode keys, etc.
573 RouteHandleShortcuts KeyRoute = iota
574
575 // RoutePassToWidget means the key should propagate to the focused widget.
576 // Used when overlays (session manager, tab picker) are visible, or when a
577 // WebView should receive text input for native IM/compose/dead-key processing.
578 // Long-press accent detection still runs first for accessibility: users with
579 // motor disabilities may not be able to execute rapid compose sequences.
580 RoutePassToWidget
581
582 // RouteAccentDetection means the key should go through long-press accent
583 // detection, then pass to the focused widget. Used for GTK Entry widgets
584 // (omnibox, find bar) and as a universal accessibility fallback.
585 RouteAccentDetection
586)
587
588// KeyContext provides key event information for routing decisions.
589type KeyContext struct {
590 Keyval uint // Translated key value
591 Keycode uint // Hardware keycode
592 Modifiers Modifier // Active modifiers (masked)
593}
594
595// IsShortcutModified returns true if the modifiers indicate a shortcut combination
596// (Ctrl or Alt pressed), as opposed to plain text input (no modifier or Shift-only).
597// Dead keys and compose sequences only use no-modifier or Shift, so this correctly
598// identifies keys that should be intercepted vs passed through to the IM pipeline.
599func IsShortcutModified(modifiers Modifier) bool {
600 return modifiers&(ModCtrl|ModAlt) != 0
601}
602
603// IsTextInputKey returns true if the keyval represents a text input key
604// (printable character or dead key) as opposed to a function/navigation key.
605// This is used to determine whether a key should be passed through to the
606// WebKit IM pipeline for compose/dead-key processing.
607func IsTextInputKey(keyval uint) bool {
608 // Dead keys used by compose sequences (US International, etc.)
609 // GDK dead key range: KEY_dead_grave (0xfe50) to KEY_dead_greek (0xfe8c)
610 if keyval >= 0xfe50 && keyval <= 0xfe8c {
611 return true
612 }
613
614 // Printable ASCII range (space through tilde)
615 if keyval >= 0x020 && keyval <= 0x07e {
616 return true
617 }
618 // Latin-1 supplement (non-breaking space through ÿ)
619 if keyval >= 0x0a0 && keyval <= 0x0ff {
620 return true
621 }
622 // Extended Latin and other Unicode characters mapped in GDK keyval space
623 if keyval >= 0x100 && keyval <= 0x20ff {
624 return true
625 }
626
627 return false
628}