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