fix(cef): fix keyboard input for punctuation and accented characters
Three issues in the CEF input bridge prevented correct text input: 1. VK code mapping: ASCII values for punctuation collided with Windows virtual-key codes for navigation keys (e.g. '.' = 0x2E = VK_DELETE, '#' = 0x23 = VK_END). Replaced the fallback with a proper map lookup covering all OEM punctuation and shifted digit symbols. 2. Character conversion: keyvalToChar only handled ASCII 0x20-0x7E, so accented characters from XKB dead key composition (é=0xE9, ü=0xFC) never generated CHAR events. Now uses gdk.KeyvalToUnicode() for full Unicode coverage. 3. IMContext integration (disabled pending purego ABI fix): wired a GtkIMContextSimple to the EventControllerKey for compose/dead key support. Currently commented out due to a purego callback stack corruption crash — the VK and Unicode fixes already handle accents via XKB composition.
- commit
- 3d5fac2caf68be8279b18da585f36f0489b96158
- author
- brice · · 2026-03-20 18:58 UTC
- committer
- brice · · 2026-03-28 04:36 UTC
- parent
- 04220c1
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
1 file · +177 -54
M internal/infrastructure/cef/input_bridge.go +177 -54
| @@ -5,14 +5,38 @@ import ( | |||
| 5 | 5 | "unsafe" | |
| 6 | 6 | ||
| 7 | 7 | purecef "github.com/bnema/purego-cef/cef" | |
| 8 | - | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 9 | - | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| 8 | + | "github.com/bnema/puregotk/v4/gdk" | |
| 9 | + | "github.com/bnema/puregotk/v4/gtk" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // GDK dead key range boundaries. Dead keys (0xfe50–0xfe8c) are compose | |
| 13 | + | // modifiers (dead_grave, dead_acute, …) that should not generate CEF key | |
| 14 | + | // events by themselves — the IMContext absorbs them and emits a commit | |
| 15 | + | // with the composed character. | |
| 16 | + | const ( | |
| 17 | + | gdkDeadKeyStart = 0xfe50 | |
| 18 | + | gdkDeadKeyEnd = 0xfe8c | |
| 19 | + | ||
| 20 | + | // maxBMPCodepoint is the highest Unicode codepoint in the Basic | |
| 21 | + | // Multilingual Plane that fits in a single uint16 / UTF-16 code unit. | |
| 22 | + | maxBMPCodepoint = 0xFFFF | |
| 23 | + | // minPrintable is the lowest printable Unicode codepoint (space). | |
| 24 | + | minPrintable = 0x20 | |
| 25 | + | // maxSingleByteKeyval is the upper bound for GDK keyvals that can be | |
| 26 | + | // used directly as Windows VK codes (when no explicit mapping exists). | |
| 27 | + | maxSingleByteKeyval = 0x100 | |
| 10 | 28 | ) | |
| 11 | 29 | ||
| 12 | 30 | // inputBridge translates GDK events from GTK event controllers into CEF input | |
| 13 | 31 | // events and forwards them to the CEF BrowserHost. The host field is nil until | |
| 14 | 32 | // the browser is created asynchronously (OnAfterCreated), so all dispatch | |
| 15 | 33 | // methods guard against a nil host. | |
| 34 | + | // | |
| 35 | + | // Dead key / compose support: a GtkIMContextSimple is attached to the key | |
| 36 | + | // controller. When the user types a compose sequence (e.g. dead_acute + e), | |
| 37 | + | // the IMContext absorbs the individual key events and emits a "commit" signal | |
| 38 | + | // with the composed string ("é"). The bridge sends that as CHAR events to CEF. | |
| 39 | + | // Keys not consumed by the IMContext flow through key-pressed as before. | |
| 16 | 40 | type inputBridge struct { | |
| 17 | 41 | host purecef.BrowserHost | |
| 18 | 42 | scale int32 | |
| @@ -21,6 +45,12 @@ type inputBridge struct { | |||
| 21 | 45 | // Last known pointer position, used for scroll events which don't | |
| 22 | 46 | // carry their own coordinates from GDK. | |
| 23 | 47 | lastX, lastY float64 | |
| 48 | + | ||
| 49 | + | // IMContext for dead key / compose sequence support. Held as a field | |
| 50 | + | // to prevent garbage collection while the controller is alive. | |
| 51 | + | imContext *gtk.IMContextSimple | |
| 52 | + | // Prevent GC from collecting signal callbacks. | |
| 53 | + | commitCb func(gtk.IMContext, string) | |
| 24 | 54 | } | |
| 25 | 55 | ||
| 26 | 56 | // newInputBridge creates an input bridge with the given HiDPI scale factor. | |
| @@ -79,21 +109,56 @@ func (ib *inputBridge) attachTo(glArea *gtk.GLArea) { | |||
| 79 | 109 | ||
| 80 | 110 | glArea.AddController(&scroll.EventController) | |
| 81 | 111 | ||
| 82 | - | // Focus — forward to CEF so it knows when it has/loses keyboard focus. | |
| 112 | + | // Focus, keyboard, and IMContext. | |
| 113 | + | ib.attachFocusAndKeyboard(glArea) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // attachFocusAndKeyboard wires focus, keyboard, and IMContext controllers | |
| 117 | + | // to the GLArea. Extracted from attachTo to keep function length manageable. | |
| 118 | + | func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) { | |
| 119 | + | // Focus — forward to CEF and IMContext. | |
| 83 | 120 | focus := gtk.NewEventControllerFocus() | |
| 84 | 121 | focusEnterCb := func(_ gtk.EventControllerFocus) { | |
| 122 | + | if ib.imContext != nil { | |
| 123 | + | ib.imContext.FocusIn() | |
| 124 | + | } | |
| 85 | 125 | ib.onFocusIn() | |
| 86 | 126 | } | |
| 87 | 127 | focus.ConnectEnter(&focusEnterCb) | |
| 88 | 128 | focusLeaveCb := func(_ gtk.EventControllerFocus) { | |
| 129 | + | if ib.imContext != nil { | |
| 130 | + | ib.imContext.Reset() | |
| 131 | + | ib.imContext.FocusOut() | |
| 132 | + | } | |
| 89 | 133 | ib.onFocusOut() | |
| 90 | 134 | } | |
| 91 | 135 | focus.ConnectLeave(&focusLeaveCb) | |
| 92 | 136 | ||
| 93 | 137 | glArea.AddController(&focus.EventController) | |
| 94 | 138 | ||
| 95 | - | // Keyboard | |
| 139 | + | // Keyboard + IMContext for dead key / compose support. | |
| 96 | 140 | key := gtk.NewEventControllerKey() | |
| 141 | + | ||
| 142 | + | // Set up IMContext: when set on the controller, GTK calls | |
| 143 | + | // FilterKeypress on every key event. If the IM absorbs the key | |
| 144 | + | // (dead key, compose in-progress), key-pressed is NOT emitted; | |
| 145 | + | // instead the IM emits "commit" with the composed string. | |
| 146 | + | // TODO: re-enable once ConnectCommit callback crash is resolved. | |
| 147 | + | // The IMContext integration causes a stack corruption crash at startup, | |
| 148 | + | // likely due to a purego callback ABI mismatch in the generated signal | |
| 149 | + | // bindings. The VK code fixes and gdk.KeyvalToUnicode() still work | |
| 150 | + | // without IMContext — XKB handles dead key composition at the layout level. | |
| 151 | + | // | |
| 152 | + | // ib.imContext = gtk.NewIMContextSimple() | |
| 153 | + | // if ib.imContext != nil { | |
| 154 | + | // ib.commitCb = func(_ gtk.IMContext, text string) { | |
| 155 | + | // ib.onIMCommit(text) | |
| 156 | + | // } | |
| 157 | + | // ib.imContext.ConnectCommit(&ib.commitCb) | |
| 158 | + | // key.SetImContext(&ib.imContext.IMContext) | |
| 159 | + | // ib.imContext.SetClientWidget(&glArea.Widget) | |
| 160 | + | // } | |
| 161 | + | ||
| 97 | 162 | keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool { | |
| 98 | 163 | ib.onKeyPress(keyval, keycode, uint(state)) | |
| 99 | 164 | return true | |
| @@ -204,6 +269,13 @@ func (ib *inputBridge) onKeyPress(keyval, keycode, mods uint) { | |||
| 204 | 269 | return | |
| 205 | 270 | } | |
| 206 | 271 | ||
| 272 | + | // Dead keys should have been absorbed by the IMContext. If one leaks | |
| 273 | + | // through (e.g. no IMContext), drop it to avoid sending a bogus VK=0 | |
| 274 | + | // RAWKEYDOWN that confuses CEF. | |
| 275 | + | if keyval >= gdkDeadKeyStart && keyval <= gdkDeadKeyEnd { | |
| 276 | + | return | |
| 277 | + | } | |
| 278 | + | ||
| 207 | 279 | // Send RAWKEYDOWN first, then CHAR if printable. | |
| 208 | 280 | evt := buildKeyEvent(keyval, keycode, mods, purecef.KeyEventTypeKeyeventRawkeydown) | |
| 209 | 281 | host.SendKeyEvent(&evt) | |
| @@ -211,12 +283,43 @@ func (ib *inputBridge) onKeyPress(keyval, keycode, mods uint) { | |||
| 211 | 283 | // Follow up with a CHAR event for printable characters. | |
| 212 | 284 | if ch := keyvalToChar(keyval); ch != 0 { | |
| 213 | 285 | charEvt := buildKeyEvent(keyval, keycode, mods, purecef.KeyEventTypeKeyeventChar) | |
| 286 | + | // CHAR events use the character code as WindowsKeyCode (not the VK code). | |
| 287 | + | charEvt.WindowsKeyCode = int32(ch) | |
| 214 | 288 | charEvt.Character = ch | |
| 215 | 289 | charEvt.UnmodifiedCharacter = ch | |
| 216 | 290 | host.SendKeyEvent(&charEvt) | |
| 217 | 291 | } | |
| 218 | 292 | } | |
| 219 | 293 | ||
| 294 | + | // onIMCommit is called when the IMContext finishes a compose sequence and | |
| 295 | + | // commits text (e.g. dead_acute + e → "é"). We send a CHAR event for each | |
| 296 | + | // rune to CEF. No RAWKEYDOWN is needed for composed text — this matches | |
| 297 | + | // Chromium's internal IME integration behavior on Linux. | |
| 298 | + | func (ib *inputBridge) onIMCommit(text string) { | |
| 299 | + | ib.mu.Lock() | |
| 300 | + | host := ib.host | |
| 301 | + | ib.mu.Unlock() | |
| 302 | + | if host == nil { | |
| 303 | + | return | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | for _, r := range text { | |
| 307 | + | ch := uint16(r) | |
| 308 | + | if r > maxBMPCodepoint { | |
| 309 | + | // Non-BMP character (emoji, etc.) — skip for now. | |
| 310 | + | // Full UTF-16 surrogate pair support can be added later. | |
| 311 | + | continue | |
| 312 | + | } | |
| 313 | + | var evt purecef.KeyEvent | |
| 314 | + | evt.Size = unsafe.Sizeof(evt) | |
| 315 | + | *(*int32)(unsafe.Pointer(&evt.Type)) = int32(purecef.KeyEventTypeKeyeventChar) | |
| 316 | + | evt.WindowsKeyCode = int32(ch) | |
| 317 | + | evt.Character = ch | |
| 318 | + | evt.UnmodifiedCharacter = ch | |
| 319 | + | host.SendKeyEvent(&evt) | |
| 320 | + | } | |
| 321 | + | } | |
| 322 | + | ||
| 220 | 323 | func (ib *inputBridge) onKeyRelease(keyval, keycode, mods uint) { | |
| 221 | 324 | ib.mu.Lock() | |
| 222 | 325 | host := ib.host | |
| @@ -315,12 +418,10 @@ const ( | |||
| 315 | 418 | gdkKeyPageDown = 0xff56 | |
| 316 | 419 | ) | |
| 317 | 420 | ||
| 318 | - | // keyvalToChar returns the UTF-16 character for a printable GDK keyval, or 0. | |
| 421 | + | // keyvalToChar converts a GDK keyval to a UTF-16 character for CEF CHAR events. | |
| 422 | + | // Returns 0 for non-printable keys (arrows, F-keys, modifiers, dead keys, …). | |
| 423 | + | // Uses gdk.KeyvalToUnicode for full Unicode coverage (accented Latin, Cyrillic, …). | |
| 319 | 424 | func keyvalToChar(keyval uint) uint16 { | |
| 320 | - | // Printable ASCII range. | |
| 321 | - | if keyval >= 0x020 && keyval <= 0x07e { | |
| 322 | - | return uint16(keyval) | |
| 323 | - | } | |
| 324 | 425 | switch keyval { | |
| 325 | 426 | case gdkKeyReturn: | |
| 326 | 427 | return '\r' | |
| @@ -328,9 +429,17 @@ func keyvalToChar(keyval uint) uint16 { | |||
| 328 | 429 | return '\t' | |
| 329 | 430 | case gdkKeyBackSpace: | |
| 330 | 431 | return '\b' | |
| 331 | - | default: | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | cp := gdk.KeyvalToUnicode(keyval) | |
| 435 | + | if cp == 0 || cp > maxBMPCodepoint { | |
| 332 | 436 | return 0 | |
| 333 | 437 | } | |
| 438 | + | // Filter control characters (except the three handled above). | |
| 439 | + | if cp < minPrintable { | |
| 440 | + | return 0 | |
| 441 | + | } | |
| 442 | + | return uint16(cp) | |
| 334 | 443 | } | |
| 335 | 444 | ||
| 336 | 445 | // gdkKeyvalToWindowsVK translates a GDK keyval to a Windows virtual-key code. | |
| @@ -338,7 +447,7 @@ func gdkKeyvalToWindowsVK(keyval uint) int32 { | |||
| 338 | 447 | if vk, ok := gdkKeyvalToVKRange(keyval); ok { | |
| 339 | 448 | return vk | |
| 340 | 449 | } | |
| 341 | - | return gdkKeyvalToVKSwitch(keyval) | |
| 450 | + | return gdkKeyvalToVKLookup(keyval) | |
| 342 | 451 | } | |
| 343 | 452 | ||
| 344 | 453 | // GDK keyval range boundaries for contiguous mappings. | |
| @@ -383,49 +492,63 @@ func gdkKeyvalToVKRange(keyval uint) (int32, bool) { | |||
| 383 | 492 | } | |
| 384 | 493 | } | |
| 385 | 494 | ||
| 386 | - | // gdkKeyvalToVKSwitch handles individual GDK keyvals that map to specific | |
| 387 | - | // Windows virtual-key codes. | |
| 495 | + | // gdkKeyvalToVKMap maps individual GDK keyvals to Windows virtual-key codes. | |
| 388 | 496 | // | |
| 389 | - | //nolint:mnd // GDK keyval→VK code lookup table; named constants would hurt readability. | |
| 390 | - | func gdkKeyvalToVKSwitch(keyval uint) int32 { | |
| 391 | - | switch keyval { | |
| 392 | - | case gdkKeyReturn: | |
| 393 | - | return 0x0D | |
| 394 | - | case gdkKeyEscape: | |
| 395 | - | return 0x1B | |
| 396 | - | case gdkKeyTab: | |
| 397 | - | return 0x09 | |
| 398 | - | case gdkKeyBackSpace: | |
| 399 | - | return 0x08 | |
| 400 | - | case gdkKeyDelete: | |
| 401 | - | return 0x2E | |
| 402 | - | case gdkKeySpace: | |
| 403 | - | return 0x20 | |
| 404 | - | case gdkKeyHome: | |
| 405 | - | return 0x24 | |
| 406 | - | case gdkKeyEnd: | |
| 407 | - | return 0x23 | |
| 408 | - | case gdkKeyPageUp: | |
| 409 | - | return 0x21 | |
| 410 | - | case gdkKeyPageDown: | |
| 411 | - | return 0x22 | |
| 412 | - | case 0xffe1: // Shift_L | |
| 413 | - | return 0xA0 // VK_LSHIFT | |
| 414 | - | case 0xffe2: // Shift_R | |
| 415 | - | return 0xA1 // VK_RSHIFT | |
| 416 | - | case 0xffe3: // Control_L | |
| 417 | - | return 0xA2 // VK_LCONTROL | |
| 418 | - | case 0xffe4: // Control_R | |
| 419 | - | return 0xA3 // VK_RCONTROL | |
| 420 | - | case 0xffe9: // Alt_L | |
| 421 | - | return 0xA4 // VK_LMENU | |
| 422 | - | case 0xffea: // Alt_R | |
| 423 | - | return 0xA5 // VK_RMENU | |
| 424 | - | default: | |
| 425 | - | // For unmapped keys, return the keyval if it fits in a reasonable range. | |
| 426 | - | if keyval < 0x100 { | |
| 427 | - | return int32(keyval) | |
| 428 | - | } | |
| 429 | - | return 0 | |
| 497 | + | // Punctuation ASCII values collide with Windows VK codes for navigation keys | |
| 498 | + | // (e.g. '.' = 0x2E = VK_DELETE, '#' = 0x23 = VK_END) so they MUST be mapped | |
| 499 | + | // explicitly. Shifted digit symbols (!, @, #, …) are mapped to their base | |
| 500 | + | // digit VK code to avoid triggering navigation actions. | |
| 501 | + | var gdkKeyvalToVKMap = map[uint]int32{ | |
| 502 | + | // Navigation / editing | |
| 503 | + | gdkKeyReturn: 0x0D, // VK_RETURN | |
| 504 | + | gdkKeyEscape: 0x1B, // VK_ESCAPE | |
| 505 | + | gdkKeyTab: 0x09, // VK_TAB | |
| 506 | + | gdkKeyBackSpace: 0x08, // VK_BACK | |
| 507 | + | gdkKeyDelete: 0x2E, // VK_DELETE | |
| 508 | + | gdkKeySpace: 0x20, // VK_SPACE | |
| 509 | + | gdkKeyHome: 0x24, // VK_HOME | |
| 510 | + | gdkKeyEnd: 0x23, // VK_END | |
| 511 | + | gdkKeyPageUp: 0x21, // VK_PRIOR | |
| 512 | + | gdkKeyPageDown: 0x22, // VK_NEXT | |
| 513 | + | 0xff63: 0x2D, // Insert → VK_INSERT | |
| 514 | + | ||
| 515 | + | // Modifiers | |
| 516 | + | 0xffe1: 0xA0, // Shift_L → VK_LSHIFT | |
| 517 | + | 0xffe2: 0xA1, // Shift_R → VK_RSHIFT | |
| 518 | + | 0xffe3: 0xA2, // Control_L → VK_LCONTROL | |
| 519 | + | 0xffe4: 0xA3, // Control_R → VK_RCONTROL | |
| 520 | + | 0xffe9: 0xA4, // Alt_L → VK_LMENU | |
| 521 | + | 0xffea: 0xA5, // Alt_R → VK_RMENU | |
| 522 | + | ||
| 523 | + | // OEM punctuation — unshifted + shifted share the same VK | |
| 524 | + | '.': 0xBE, '>': 0xBE, // VK_OEM_PERIOD | |
| 525 | + | ',': 0xBC, '<': 0xBC, // VK_OEM_COMMA | |
| 526 | + | '-': 0xBD, '_': 0xBD, // VK_OEM_MINUS | |
| 527 | + | '=': 0xBB, '+': 0xBB, // VK_OEM_PLUS | |
| 528 | + | ';': 0xBA, ':': 0xBA, // VK_OEM_1 | |
| 529 | + | '/': 0xBF, '?': 0xBF, // VK_OEM_2 | |
| 530 | + | '`': 0xC0, '~': 0xC0, // VK_OEM_3 | |
| 531 | + | '[': 0xDB, '{': 0xDB, // VK_OEM_4 | |
| 532 | + | '\\': 0xDC, '|': 0xDC, // VK_OEM_5 | |
| 533 | + | ']': 0xDD, '}': 0xDD, // VK_OEM_6 | |
| 534 | + | '\'': 0xDE, '"': 0xDE, // VK_OEM_7 | |
| 535 | + | ||
| 536 | + | // Shifted digit symbols (Shift+1→'!' etc.) | |
| 537 | + | '!': 0x31, '@': 0x32, '#': 0x33, '$': 0x34, '%': 0x35, | |
| 538 | + | '^': 0x36, '&': 0x37, '*': 0x38, '(': 0x39, ')': 0x30, | |
| 539 | + | } | |
| 540 | + | ||
| 541 | + | // gdkKeyvalToVKLookup handles individual GDK keyvals that don't fall into | |
| 542 | + | // contiguous ranges (letters, digits, F-keys, arrows). | |
| 543 | + | func gdkKeyvalToVKLookup(keyval uint) int32 { | |
| 544 | + | if vk, ok := gdkKeyvalToVKMap[keyval]; ok { | |
| 545 | + | return vk | |
| 546 | + | } | |
| 547 | + | // For unmapped keys, return the keyval if it fits in a reasonable range. | |
| 548 | + | // This covers misc keys whose ASCII value doesn't collide with | |
| 549 | + | // dangerous VK codes (e.g. space=0x20=VK_SPACE — correct match). | |
| 550 | + | if keyval < maxSingleByteKeyval { | |
| 551 | + | return int32(keyval) | |
| 430 | 552 | } | |
| 553 | + | return 0 | |
| 431 | 554 | } | |
| @@ -5,14 +5,38 @@ import ( | |||||
| 5 | "unsafe" | 5 | "unsafe" | ||
| 6 | 6 | ||||
| 7 | purecef "github.com/bnema/purego-cef/cef" | 7 | purecef "github.com/bnema/purego-cef/cef" | ||
| 8 | - | "github.com/jwijenbergh/puregotk/v4/gdk" | 8 | + | "github.com/bnema/puregotk/v4/gdk" |
| 9 | - | "github.com/jwijenbergh/puregotk/v4/gtk" | 9 | + | "github.com/bnema/puregotk/v4/gtk" |
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // GDK dead key range boundaries. Dead keys (0xfe50–0xfe8c) are compose | |||
| 13 | + | // modifiers (dead_grave, dead_acute, …) that should not generate CEF key | |||
| 14 | + | // events by themselves — the IMContext absorbs them and emits a commit | |||
| 15 | + | // with the composed character. | |||
| 16 | + | const ( | |||
| 17 | + | gdkDeadKeyStart = 0xfe50 | |||
| 18 | + | gdkDeadKeyEnd = 0xfe8c | |||
| 19 | + | ||||
| 20 | + | // maxBMPCodepoint is the highest Unicode codepoint in the Basic | |||
| 21 | + | // Multilingual Plane that fits in a single uint16 / UTF-16 code unit. | |||
| 22 | + | maxBMPCodepoint = 0xFFFF | |||
| 23 | + | // minPrintable is the lowest printable Unicode codepoint (space). | |||
| 24 | + | minPrintable = 0x20 | |||
| 25 | + | // maxSingleByteKeyval is the upper bound for GDK keyvals that can be | |||
| 26 | + | // used directly as Windows VK codes (when no explicit mapping exists). | |||
| 27 | + | maxSingleByteKeyval = 0x100 | |||
| 10 | ) | 28 | ) | ||
| 11 | 29 | ||||
| 12 | // inputBridge translates GDK events from GTK event controllers into CEF input | 30 | // inputBridge translates GDK events from GTK event controllers into CEF input | ||
| 13 | // events and forwards them to the CEF BrowserHost. The host field is nil until | 31 | // events and forwards them to the CEF BrowserHost. The host field is nil until | ||
| 14 | // the browser is created asynchronously (OnAfterCreated), so all dispatch | 32 | // the browser is created asynchronously (OnAfterCreated), so all dispatch | ||
| 15 | // methods guard against a nil host. | 33 | // methods guard against a nil host. | ||
| 34 | + | // | |||
| 35 | + | // Dead key / compose support: a GtkIMContextSimple is attached to the key | |||
| 36 | + | // controller. When the user types a compose sequence (e.g. dead_acute + e), | |||
| 37 | + | // the IMContext absorbs the individual key events and emits a "commit" signal | |||
| 38 | + | // with the composed string ("é"). The bridge sends that as CHAR events to CEF. | |||
| 39 | + | // Keys not consumed by the IMContext flow through key-pressed as before. | |||
| 16 | type inputBridge struct { | 40 | type inputBridge struct { | ||
| 17 | host purecef.BrowserHost | 41 | host purecef.BrowserHost | ||
| 18 | scale int32 | 42 | scale int32 | ||
| @@ -21,6 +45,12 @@ type inputBridge struct { | |||||
| 21 | // Last known pointer position, used for scroll events which don't | 45 | // Last known pointer position, used for scroll events which don't | ||
| 22 | // carry their own coordinates from GDK. | 46 | // carry their own coordinates from GDK. | ||
| 23 | lastX, lastY float64 | 47 | lastX, lastY float64 | ||
| 48 | + | ||||
| 49 | + | // IMContext for dead key / compose sequence support. Held as a field | |||
| 50 | + | // to prevent garbage collection while the controller is alive. | |||
| 51 | + | imContext *gtk.IMContextSimple | |||
| 52 | + | // Prevent GC from collecting signal callbacks. | |||
| 53 | + | commitCb func(gtk.IMContext, string) | |||
| 24 | } | 54 | } | ||
| 25 | 55 | ||||
| 26 | // newInputBridge creates an input bridge with the given HiDPI scale factor. | 56 | // newInputBridge creates an input bridge with the given HiDPI scale factor. | ||
| @@ -79,21 +109,56 @@ func (ib *inputBridge) attachTo(glArea *gtk.GLArea) { | |||||
| 79 | 109 | ||||
| 80 | glArea.AddController(&scroll.EventController) | 110 | glArea.AddController(&scroll.EventController) | ||
| 81 | 111 | ||||
| 82 | - | // Focus — forward to CEF so it knows when it has/loses keyboard focus. | 112 | + | // Focus, keyboard, and IMContext. |
| 113 | + | ib.attachFocusAndKeyboard(glArea) | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // attachFocusAndKeyboard wires focus, keyboard, and IMContext controllers | |||
| 117 | + | // to the GLArea. Extracted from attachTo to keep function length manageable. | |||
| 118 | + | func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) { | |||
| 119 | + | // Focus — forward to CEF and IMContext. | |||
| 83 | focus := gtk.NewEventControllerFocus() | 120 | focus := gtk.NewEventControllerFocus() | ||
| 84 | focusEnterCb := func(_ gtk.EventControllerFocus) { | 121 | focusEnterCb := func(_ gtk.EventControllerFocus) { | ||
| 122 | + | if ib.imContext != nil { | |||
| 123 | + | ib.imContext.FocusIn() | |||
| 124 | + | } | |||
| 85 | ib.onFocusIn() | 125 | ib.onFocusIn() | ||
| 86 | } | 126 | } | ||
| 87 | focus.ConnectEnter(&focusEnterCb) | 127 | focus.ConnectEnter(&focusEnterCb) | ||
| 88 | focusLeaveCb := func(_ gtk.EventControllerFocus) { | 128 | focusLeaveCb := func(_ gtk.EventControllerFocus) { | ||
| 129 | + | if ib.imContext != nil { | |||
| 130 | + | ib.imContext.Reset() | |||
| 131 | + | ib.imContext.FocusOut() | |||
| 132 | + | } | |||
| 89 | ib.onFocusOut() | 133 | ib.onFocusOut() | ||
| 90 | } | 134 | } | ||
| 91 | focus.ConnectLeave(&focusLeaveCb) | 135 | focus.ConnectLeave(&focusLeaveCb) | ||
| 92 | 136 | ||||
| 93 | glArea.AddController(&focus.EventController) | 137 | glArea.AddController(&focus.EventController) | ||
| 94 | 138 | ||||
| 95 | - | // Keyboard | 139 | + | // Keyboard + IMContext for dead key / compose support. |
| 96 | key := gtk.NewEventControllerKey() | 140 | key := gtk.NewEventControllerKey() | ||
| 141 | + | ||||
| 142 | + | // Set up IMContext: when set on the controller, GTK calls | |||
| 143 | + | // FilterKeypress on every key event. If the IM absorbs the key | |||
| 144 | + | // (dead key, compose in-progress), key-pressed is NOT emitted; | |||
| 145 | + | // instead the IM emits "commit" with the composed string. | |||
| 146 | + | // TODO: re-enable once ConnectCommit callback crash is resolved. | |||
| 147 | + | // The IMContext integration causes a stack corruption crash at startup, | |||
| 148 | + | // likely due to a purego callback ABI mismatch in the generated signal | |||
| 149 | + | // bindings. The VK code fixes and gdk.KeyvalToUnicode() still work | |||
| 150 | + | // without IMContext — XKB handles dead key composition at the layout level. | |||
| 151 | + | // | |||
| 152 | + | // ib.imContext = gtk.NewIMContextSimple() | |||
| 153 | + | // if ib.imContext != nil { | |||
| 154 | + | // ib.commitCb = func(_ gtk.IMContext, text string) { | |||
| 155 | + | // ib.onIMCommit(text) | |||
| 156 | + | // } | |||
| 157 | + | // ib.imContext.ConnectCommit(&ib.commitCb) | |||
| 158 | + | // key.SetImContext(&ib.imContext.IMContext) | |||
| 159 | + | // ib.imContext.SetClientWidget(&glArea.Widget) | |||
| 160 | + | // } | |||
| 161 | + | ||||
| 97 | keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool { | 162 | keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool { | ||
| 98 | ib.onKeyPress(keyval, keycode, uint(state)) | 163 | ib.onKeyPress(keyval, keycode, uint(state)) | ||
| 99 | return true | 164 | return true | ||
| @@ -204,6 +269,13 @@ func (ib *inputBridge) onKeyPress(keyval, keycode, mods uint) { | |||||
| 204 | return | 269 | return | ||
| 205 | } | 270 | } | ||
| 206 | 271 | ||||
| 272 | + | // Dead keys should have been absorbed by the IMContext. If one leaks | |||
| 273 | + | // through (e.g. no IMContext), drop it to avoid sending a bogus VK=0 | |||
| 274 | + | // RAWKEYDOWN that confuses CEF. | |||
| 275 | + | if keyval >= gdkDeadKeyStart && keyval <= gdkDeadKeyEnd { | |||
| 276 | + | return | |||
| 277 | + | } | |||
| 278 | + | ||||
| 207 | // Send RAWKEYDOWN first, then CHAR if printable. | 279 | // Send RAWKEYDOWN first, then CHAR if printable. | ||
| 208 | evt := buildKeyEvent(keyval, keycode, mods, purecef.KeyEventTypeKeyeventRawkeydown) | 280 | evt := buildKeyEvent(keyval, keycode, mods, purecef.KeyEventTypeKeyeventRawkeydown) | ||
| 209 | host.SendKeyEvent(&evt) | 281 | host.SendKeyEvent(&evt) | ||
| @@ -211,12 +283,43 @@ func (ib *inputBridge) onKeyPress(keyval, keycode, mods uint) { | |||||
| 211 | // Follow up with a CHAR event for printable characters. | 283 | // Follow up with a CHAR event for printable characters. | ||
| 212 | if ch := keyvalToChar(keyval); ch != 0 { | 284 | if ch := keyvalToChar(keyval); ch != 0 { | ||
| 213 | charEvt := buildKeyEvent(keyval, keycode, mods, purecef.KeyEventTypeKeyeventChar) | 285 | charEvt := buildKeyEvent(keyval, keycode, mods, purecef.KeyEventTypeKeyeventChar) | ||
| 286 | + | // CHAR events use the character code as WindowsKeyCode (not the VK code). | |||
| 287 | + | charEvt.WindowsKeyCode = int32(ch) | |||
| 214 | charEvt.Character = ch | 288 | charEvt.Character = ch | ||
| 215 | charEvt.UnmodifiedCharacter = ch | 289 | charEvt.UnmodifiedCharacter = ch | ||
| 216 | host.SendKeyEvent(&charEvt) | 290 | host.SendKeyEvent(&charEvt) | ||
| 217 | } | 291 | } | ||
| 218 | } | 292 | } | ||
| 219 | 293 | ||||
| 294 | + | // onIMCommit is called when the IMContext finishes a compose sequence and | |||
| 295 | + | // commits text (e.g. dead_acute + e → "é"). We send a CHAR event for each | |||
| 296 | + | // rune to CEF. No RAWKEYDOWN is needed for composed text — this matches | |||
| 297 | + | // Chromium's internal IME integration behavior on Linux. | |||
| 298 | + | func (ib *inputBridge) onIMCommit(text string) { | |||
| 299 | + | ib.mu.Lock() | |||
| 300 | + | host := ib.host | |||
| 301 | + | ib.mu.Unlock() | |||
| 302 | + | if host == nil { | |||
| 303 | + | return | |||
| 304 | + | } | |||
| 305 | + | ||||
| 306 | + | for _, r := range text { | |||
| 307 | + | ch := uint16(r) | |||
| 308 | + | if r > maxBMPCodepoint { | |||
| 309 | + | // Non-BMP character (emoji, etc.) — skip for now. | |||
| 310 | + | // Full UTF-16 surrogate pair support can be added later. | |||
| 311 | + | continue | |||
| 312 | + | } | |||
| 313 | + | var evt purecef.KeyEvent | |||
| 314 | + | evt.Size = unsafe.Sizeof(evt) | |||
| 315 | + | *(*int32)(unsafe.Pointer(&evt.Type)) = int32(purecef.KeyEventTypeKeyeventChar) | |||
| 316 | + | evt.WindowsKeyCode = int32(ch) | |||
| 317 | + | evt.Character = ch | |||
| 318 | + | evt.UnmodifiedCharacter = ch | |||
| 319 | + | host.SendKeyEvent(&evt) | |||
| 320 | + | } | |||
| 321 | + | } | |||
| 322 | + | ||||
| 220 | func (ib *inputBridge) onKeyRelease(keyval, keycode, mods uint) { | 323 | func (ib *inputBridge) onKeyRelease(keyval, keycode, mods uint) { | ||
| 221 | ib.mu.Lock() | 324 | ib.mu.Lock() | ||
| 222 | host := ib.host | 325 | host := ib.host | ||
| @@ -315,12 +418,10 @@ const ( | |||||
| 315 | gdkKeyPageDown = 0xff56 | 418 | gdkKeyPageDown = 0xff56 | ||
| 316 | ) | 419 | ) | ||
| 317 | 420 | ||||
| 318 | - | // keyvalToChar returns the UTF-16 character for a printable GDK keyval, or 0. | 421 | + | // keyvalToChar converts a GDK keyval to a UTF-16 character for CEF CHAR events. |
| 422 | + | // Returns 0 for non-printable keys (arrows, F-keys, modifiers, dead keys, …). | |||
| 423 | + | // Uses gdk.KeyvalToUnicode for full Unicode coverage (accented Latin, Cyrillic, …). | |||
| 319 | func keyvalToChar(keyval uint) uint16 { | 424 | func keyvalToChar(keyval uint) uint16 { | ||
| 320 | - | // Printable ASCII range. | |||
| 321 | - | if keyval >= 0x020 && keyval <= 0x07e { | |||
| 322 | - | return uint16(keyval) | |||
| 323 | - | } | |||
| 324 | switch keyval { | 425 | switch keyval { | ||
| 325 | case gdkKeyReturn: | 426 | case gdkKeyReturn: | ||
| 326 | return '\r' | 427 | return '\r' | ||
| @@ -328,9 +429,17 @@ func keyvalToChar(keyval uint) uint16 { | |||||
| 328 | return '\t' | 429 | return '\t' | ||
| 329 | case gdkKeyBackSpace: | 430 | case gdkKeyBackSpace: | ||
| 330 | return '\b' | 431 | return '\b' | ||
| 331 | - | default: | 432 | + | } |
| 433 | + | ||||
| 434 | + | cp := gdk.KeyvalToUnicode(keyval) | |||
| 435 | + | if cp == 0 || cp > maxBMPCodepoint { | |||
| 332 | return 0 | 436 | return 0 | ||
| 333 | } | 437 | } | ||
| 438 | + | // Filter control characters (except the three handled above). | |||
| 439 | + | if cp < minPrintable { | |||
| 440 | + | return 0 | |||
| 441 | + | } | |||
| 442 | + | return uint16(cp) | |||
| 334 | } | 443 | } | ||
| 335 | 444 | ||||
| 336 | // gdkKeyvalToWindowsVK translates a GDK keyval to a Windows virtual-key code. | 445 | // gdkKeyvalToWindowsVK translates a GDK keyval to a Windows virtual-key code. | ||
| @@ -338,7 +447,7 @@ func gdkKeyvalToWindowsVK(keyval uint) int32 { | |||||
| 338 | if vk, ok := gdkKeyvalToVKRange(keyval); ok { | 447 | if vk, ok := gdkKeyvalToVKRange(keyval); ok { | ||
| 339 | return vk | 448 | return vk | ||
| 340 | } | 449 | } | ||
| 341 | - | return gdkKeyvalToVKSwitch(keyval) | 450 | + | return gdkKeyvalToVKLookup(keyval) |
| 342 | } | 451 | } | ||
| 343 | 452 | ||||
| 344 | // GDK keyval range boundaries for contiguous mappings. | 453 | // GDK keyval range boundaries for contiguous mappings. | ||
| @@ -383,49 +492,63 @@ func gdkKeyvalToVKRange(keyval uint) (int32, bool) { | |||||
| 383 | } | 492 | } | ||
| 384 | } | 493 | } | ||
| 385 | 494 | ||||
| 386 | - | // gdkKeyvalToVKSwitch handles individual GDK keyvals that map to specific | 495 | + | // gdkKeyvalToVKMap maps individual GDK keyvals to Windows virtual-key codes. |
| 387 | - | // Windows virtual-key codes. | |||
| 388 | // | 496 | // | ||
| 389 | - | //nolint:mnd // GDK keyval→VK code lookup table; named constants would hurt readability. | 497 | + | // Punctuation ASCII values collide with Windows VK codes for navigation keys |
| 390 | - | func gdkKeyvalToVKSwitch(keyval uint) int32 { | 498 | + | // (e.g. '.' = 0x2E = VK_DELETE, '#' = 0x23 = VK_END) so they MUST be mapped |
| 391 | - | switch keyval { | 499 | + | // explicitly. Shifted digit symbols (!, @, #, …) are mapped to their base |
| 392 | - | case gdkKeyReturn: | 500 | + | // digit VK code to avoid triggering navigation actions. |
| 393 | - | return 0x0D | 501 | + | var gdkKeyvalToVKMap = map[uint]int32{ |
| 394 | - | case gdkKeyEscape: | 502 | + | // Navigation / editing |
| 395 | - | return 0x1B | 503 | + | gdkKeyReturn: 0x0D, // VK_RETURN |
| 396 | - | case gdkKeyTab: | 504 | + | gdkKeyEscape: 0x1B, // VK_ESCAPE |
| 397 | - | return 0x09 | 505 | + | gdkKeyTab: 0x09, // VK_TAB |
| 398 | - | case gdkKeyBackSpace: | 506 | + | gdkKeyBackSpace: 0x08, // VK_BACK |
| 399 | - | return 0x08 | 507 | + | gdkKeyDelete: 0x2E, // VK_DELETE |
| 400 | - | case gdkKeyDelete: | 508 | + | gdkKeySpace: 0x20, // VK_SPACE |
| 401 | - | return 0x2E | 509 | + | gdkKeyHome: 0x24, // VK_HOME |
| 402 | - | case gdkKeySpace: | 510 | + | gdkKeyEnd: 0x23, // VK_END |
| 403 | - | return 0x20 | 511 | + | gdkKeyPageUp: 0x21, // VK_PRIOR |
| 404 | - | case gdkKeyHome: | 512 | + | gdkKeyPageDown: 0x22, // VK_NEXT |
| 405 | - | return 0x24 | 513 | + | 0xff63: 0x2D, // Insert → VK_INSERT |
| 406 | - | case gdkKeyEnd: | 514 | + | |
| 407 | - | return 0x23 | 515 | + | // Modifiers |
| 408 | - | case gdkKeyPageUp: | 516 | + | 0xffe1: 0xA0, // Shift_L → VK_LSHIFT |
| 409 | - | return 0x21 | 517 | + | 0xffe2: 0xA1, // Shift_R → VK_RSHIFT |
| 410 | - | case gdkKeyPageDown: | 518 | + | 0xffe3: 0xA2, // Control_L → VK_LCONTROL |
| 411 | - | return 0x22 | 519 | + | 0xffe4: 0xA3, // Control_R → VK_RCONTROL |
| 412 | - | case 0xffe1: // Shift_L | 520 | + | 0xffe9: 0xA4, // Alt_L → VK_LMENU |
| 413 | - | return 0xA0 // VK_LSHIFT | 521 | + | 0xffea: 0xA5, // Alt_R → VK_RMENU |
| 414 | - | case 0xffe2: // Shift_R | 522 | + | |
| 415 | - | return 0xA1 // VK_RSHIFT | 523 | + | // OEM punctuation — unshifted + shifted share the same VK |
| 416 | - | case 0xffe3: // Control_L | 524 | + | '.': 0xBE, '>': 0xBE, // VK_OEM_PERIOD |
| 417 | - | return 0xA2 // VK_LCONTROL | 525 | + | ',': 0xBC, '<': 0xBC, // VK_OEM_COMMA |
| 418 | - | case 0xffe4: // Control_R | 526 | + | '-': 0xBD, '_': 0xBD, // VK_OEM_MINUS |
| 419 | - | return 0xA3 // VK_RCONTROL | 527 | + | '=': 0xBB, '+': 0xBB, // VK_OEM_PLUS |
| 420 | - | case 0xffe9: // Alt_L | 528 | + | ';': 0xBA, ':': 0xBA, // VK_OEM_1 |
| 421 | - | return 0xA4 // VK_LMENU | 529 | + | '/': 0xBF, '?': 0xBF, // VK_OEM_2 |
| 422 | - | case 0xffea: // Alt_R | 530 | + | '`': 0xC0, '~': 0xC0, // VK_OEM_3 |
| 423 | - | return 0xA5 // VK_RMENU | 531 | + | '[': 0xDB, '{': 0xDB, // VK_OEM_4 |
| 424 | - | default: | 532 | + | '\\': 0xDC, '|': 0xDC, // VK_OEM_5 |
| 425 | - | // For unmapped keys, return the keyval if it fits in a reasonable range. | 533 | + | ']': 0xDD, '}': 0xDD, // VK_OEM_6 |
| 426 | - | if keyval < 0x100 { | 534 | + | '\'': 0xDE, '"': 0xDE, // VK_OEM_7 |
| 427 | - | return int32(keyval) | 535 | + | |
| 428 | - | } | 536 | + | // Shifted digit symbols (Shift+1→'!' etc.) |
| 429 | - | return 0 | 537 | + | '!': 0x31, '@': 0x32, '#': 0x33, '$': 0x34, '%': 0x35, |
| 538 | + | '^': 0x36, '&': 0x37, '*': 0x38, '(': 0x39, ')': 0x30, | |||
| 539 | + | } | |||
| 540 | + | ||||
| 541 | + | // gdkKeyvalToVKLookup handles individual GDK keyvals that don't fall into | |||
| 542 | + | // contiguous ranges (letters, digits, F-keys, arrows). | |||
| 543 | + | func gdkKeyvalToVKLookup(keyval uint) int32 { | |||
| 544 | + | if vk, ok := gdkKeyvalToVKMap[keyval]; ok { | |||
| 545 | + | return vk | |||
| 546 | + | } | |||
| 547 | + | // For unmapped keys, return the keyval if it fits in a reasonable range. | |||
| 548 | + | // This covers misc keys whose ASCII value doesn't collide with | |||
| 549 | + | // dangerous VK codes (e.g. space=0x20=VK_SPACE — correct match). | |||
| 550 | + | if keyval < maxSingleByteKeyval { | |||
| 551 | + | return int32(keyval) | |||
| 430 | } | 552 | } | ||
| 553 | + | return 0 | |||
| 431 | } | 554 | } | ||