detached 3d5fac2

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 (
55 "unsafe"
66
77 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
1028 )
1129
1230 // inputBridge translates GDK events from GTK event controllers into CEF input
1331 // events and forwards them to the CEF BrowserHost. The host field is nil until
1432 // the browser is created asynchronously (OnAfterCreated), so all dispatch
1533 // 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.
1640 type inputBridge struct {
1741 host purecef.BrowserHost
1842 scale int32
@@ -21,6 +45,12 @@ type inputBridge struct {
2145 // Last known pointer position, used for scroll events which don't
2246 // carry their own coordinates from GDK.
2347 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)
2454 }
2555
2656 // newInputBridge creates an input bridge with the given HiDPI scale factor.
@@ -79,21 +109,56 @@ func (ib *inputBridge) attachTo(glArea *gtk.GLArea) {
79109
80110 glArea.AddController(&scroll.EventController)
81111
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.
83120 focus := gtk.NewEventControllerFocus()
84121 focusEnterCb := func(_ gtk.EventControllerFocus) {
122+ if ib.imContext != nil {
123+ ib.imContext.FocusIn()
124+ }
85125 ib.onFocusIn()
86126 }
87127 focus.ConnectEnter(&focusEnterCb)
88128 focusLeaveCb := func(_ gtk.EventControllerFocus) {
129+ if ib.imContext != nil {
130+ ib.imContext.Reset()
131+ ib.imContext.FocusOut()
132+ }
89133 ib.onFocusOut()
90134 }
91135 focus.ConnectLeave(&focusLeaveCb)
92136
93137 glArea.AddController(&focus.EventController)
94138
95- // Keyboard
139+ // Keyboard + IMContext for dead key / compose support.
96140 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+
97162 keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool {
98163 ib.onKeyPress(keyval, keycode, uint(state))
99164 return true
@@ -204,6 +269,13 @@ func (ib *inputBridge) onKeyPress(keyval, keycode, mods uint) {
204269 return
205270 }
206271
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+
207279 // Send RAWKEYDOWN first, then CHAR if printable.
208280 evt := buildKeyEvent(keyval, keycode, mods, purecef.KeyEventTypeKeyeventRawkeydown)
209281 host.SendKeyEvent(&evt)
@@ -211,12 +283,43 @@ func (ib *inputBridge) onKeyPress(keyval, keycode, mods uint) {
211283 // Follow up with a CHAR event for printable characters.
212284 if ch := keyvalToChar(keyval); ch != 0 {
213285 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)
214288 charEvt.Character = ch
215289 charEvt.UnmodifiedCharacter = ch
216290 host.SendKeyEvent(&charEvt)
217291 }
218292 }
219293
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+
220323 func (ib *inputBridge) onKeyRelease(keyval, keycode, mods uint) {
221324 ib.mu.Lock()
222325 host := ib.host
@@ -315,12 +418,10 @@ const (
315418 gdkKeyPageDown = 0xff56
316419 )
317420
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, …).
319424 func keyvalToChar(keyval uint) uint16 {
320- // Printable ASCII range.
321- if keyval >= 0x020 && keyval <= 0x07e {
322- return uint16(keyval)
323- }
324425 switch keyval {
325426 case gdkKeyReturn:
326427 return '\r'
@@ -328,9 +429,17 @@ func keyvalToChar(keyval uint) uint16 {
328429 return '\t'
329430 case gdkKeyBackSpace:
330431 return '\b'
331- default:
432+ }
433+
434+ cp := gdk.KeyvalToUnicode(keyval)
435+ if cp == 0 || cp > maxBMPCodepoint {
332436 return 0
333437 }
438+ // Filter control characters (except the three handled above).
439+ if cp < minPrintable {
440+ return 0
441+ }
442+ return uint16(cp)
334443 }
335444
336445 // gdkKeyvalToWindowsVK translates a GDK keyval to a Windows virtual-key code.
@@ -338,7 +447,7 @@ func gdkKeyvalToWindowsVK(keyval uint) int32 {
338447 if vk, ok := gdkKeyvalToVKRange(keyval); ok {
339448 return vk
340449 }
341- return gdkKeyvalToVKSwitch(keyval)
450+ return gdkKeyvalToVKLookup(keyval)
342451 }
343452
344453 // GDK keyval range boundaries for contiguous mappings.
@@ -383,49 +492,63 @@ func gdkKeyvalToVKRange(keyval uint) (int32, bool) {
383492 }
384493 }
385494
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.
388496 //
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)
430552 }
553+ return 0
431554 }
@@ -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 input30 // 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 until31 // events and forwards them to the CEF BrowserHost. The host field is nil until
14 // the browser is created asynchronously (OnAfterCreated), so all dispatch32 // 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.BrowserHost41 host purecef.BrowserHost
18 scale int3242 scale int32
@@ -21,6 +45,12 @@ type inputBridge struct {
21 // Last known pointer position, used for scroll events which don't45 // 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 float6447 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- // Keyboard139+ // 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 true164 return true
@@ -204,6 +269,13 @@ func (ib *inputBridge) onKeyPress(keyval, keycode, mods uint) {
204 return269 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 = ch288 charEvt.Character = ch
215 charEvt.UnmodifiedCharacter = ch289 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.host325 host := ib.host
@@ -315,12 +418,10 @@ const (
315 gdkKeyPageDown = 0xff56418 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 0436 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 vk448 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 specific495+// 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 0x0D501+var gdkKeyvalToVKMap = map[uint]int32{
394- case gdkKeyEscape:502+ // Navigation / editing
395- return 0x1B503+ gdkKeyReturn: 0x0D, // VK_RETURN
396- case gdkKeyTab:504+ gdkKeyEscape: 0x1B, // VK_ESCAPE
397- return 0x09505+ gdkKeyTab: 0x09, // VK_TAB
398- case gdkKeyBackSpace:506+ gdkKeyBackSpace: 0x08, // VK_BACK
399- return 0x08507+ gdkKeyDelete: 0x2E, // VK_DELETE
400- case gdkKeyDelete:508+ gdkKeySpace: 0x20, // VK_SPACE
401- return 0x2E509+ gdkKeyHome: 0x24, // VK_HOME
402- case gdkKeySpace:510+ gdkKeyEnd: 0x23, // VK_END
403- return 0x20511+ gdkKeyPageUp: 0x21, // VK_PRIOR
404- case gdkKeyHome:512+ gdkKeyPageDown: 0x22, // VK_NEXT
405- return 0x24513+ 0xff63: 0x2D, // Insert → VK_INSERT
406- case gdkKeyEnd:514+
407- return 0x23515+ // Modifiers
408- case gdkKeyPageUp:516+ 0xffe1: 0xA0, // Shift_L → VK_LSHIFT
409- return 0x21517+ 0xffe2: 0xA1, // Shift_R → VK_RSHIFT
410- case gdkKeyPageDown:518+ 0xffe3: 0xA2, // Control_L → VK_LCONTROL
411- return 0x22519+ 0xffe4: 0xA3, // Control_R → VK_RCONTROL
412- case 0xffe1: // Shift_L520+ 0xffe9: 0xA4, // Alt_L → VK_LMENU
413- return 0xA0 // VK_LSHIFT521+ 0xffea: 0xA5, // Alt_R → VK_RMENU
414- case 0xffe2: // Shift_R522+
415- return 0xA1 // VK_RSHIFT523+ // OEM punctuation — unshifted + shifted share the same VK
416- case 0xffe3: // Control_L524+ '.': 0xBE, '>': 0xBE, // VK_OEM_PERIOD
417- return 0xA2 // VK_LCONTROL525+ ',': 0xBC, '<': 0xBC, // VK_OEM_COMMA
418- case 0xffe4: // Control_R526+ '-': 0xBD, '_': 0xBD, // VK_OEM_MINUS
419- return 0xA3 // VK_RCONTROL527+ '=': 0xBB, '+': 0xBB, // VK_OEM_PLUS
420- case 0xffe9: // Alt_L528+ ';': 0xBA, ':': 0xBA, // VK_OEM_1
421- return 0xA4 // VK_LMENU529+ '/': 0xBF, '?': 0xBF, // VK_OEM_2
422- case 0xffea: // Alt_R530+ '`': 0xC0, '~': 0xC0, // VK_OEM_3
423- return 0xA5 // VK_RMENU531+ '[': 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 0537+ '!': 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 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close