fix: sync IME preedit state with libghostty#458
Conversation
On US-English keyboards, pressing a dead-key sequence such as Option+E followed by a vowel would leak the intermediate keystroke — e.g. typing `"` could produce `'"` because the initial `'` compose key was sent as real input. CJK input methods (Korean, Japanese) had similar issues where compose keystrokes were not suppressed. Port the preedit synchronisation pattern from Ghostty's own `SurfaceView_AppKit`: track marked-text state before and after `interpretKeyEvents`, call `ghostty_surface_preedit` to push the compose string (or clear it), and set the `composing` flag on key events so libghostty suppresses encoding while a dead-key or IME sequence is active.
|
Was the omission of |
|
@davidpoblador oversight, has been added now. |
Sync preedit state from `setMarkedText` and `unmarkText` to cover externally-triggered changes that bypass `keyDown` — e.g. switching keyboard layout mid-compose or an input method calling `commitComposition` during an app-switch, which would otherwise leave a stale compose indicator. Also clear preedit defensively in `insertText`, matching upstream: if an input method (e.g. voice dictation) calls `insertText` while marked text is active, the preedit indicator is now cleared immediately rather than lingering until the next `keyDown` cycle. Port the full upstream `flagsChanged` logic: correctly distinguish press from release using device-specific right-side modifier masks (`NX_DEVICE*`), and suppress modifier events entirely during active IME composition via a `hasMarkedText()` guard. Previously all modifier events were unconditionally sent as `GHOSTTY_ACTION_PRESS`.
b96548a to
6516054
Compare
| switch event.keyCode { | ||
| case 0x3C: | ||
| sidePressed = event.modifierFlags.rawValue | ||
| & UInt(NX_DEVICERSHIFTKEYMASK) != 0 | ||
| case 0x3E: | ||
| sidePressed = event.modifierFlags.rawValue | ||
| & UInt(NX_DEVICERCTLKEYMASK) != 0 | ||
| case 0x3D: | ||
| sidePressed = event.modifierFlags.rawValue | ||
| & UInt(NX_DEVICERALTKEYMASK) != 0 | ||
| case 0x36: | ||
| sidePressed = event.modifierFlags.rawValue | ||
| & UInt(NX_DEVICERCMDKEYMASK) != 0 | ||
| default: | ||
| sidePressed = true | ||
| } |
There was a problem hiding this comment.
Left-side keys fall through to sidePressed = true, which misreports a release as a press when both sides of a modifier are held. Repro: hold L-Shift, then hold R-Shift, then release L-Shift — the L-Shift release event arrives with keyCode 0x38 and the generic .shift bit still set (R-Shift is held), so sidePressed defaults to true and we send PRESS instead of RELEASE.
Fix is symmetric: check NX_DEVICEL*KEYMASK for left-side keyCodes the same way the right side already does. CapsLock has no L/R pair so the existing default works for it.
| switch event.keyCode { | |
| case 0x3C: | |
| sidePressed = event.modifierFlags.rawValue | |
| & UInt(NX_DEVICERSHIFTKEYMASK) != 0 | |
| case 0x3E: | |
| sidePressed = event.modifierFlags.rawValue | |
| & UInt(NX_DEVICERCTLKEYMASK) != 0 | |
| case 0x3D: | |
| sidePressed = event.modifierFlags.rawValue | |
| & UInt(NX_DEVICERALTKEYMASK) != 0 | |
| case 0x36: | |
| sidePressed = event.modifierFlags.rawValue | |
| & UInt(NX_DEVICERCMDKEYMASK) != 0 | |
| default: | |
| sidePressed = true | |
| } | |
| switch event.keyCode { | |
| case 0x38: | |
| sidePressed = event.modifierFlags.rawValue | |
| & UInt(NX_DEVICELSHIFTKEYMASK) != 0 | |
| case 0x3C: | |
| sidePressed = event.modifierFlags.rawValue | |
| & UInt(NX_DEVICERSHIFTKEYMASK) != 0 | |
| case 0x3B: | |
| sidePressed = event.modifierFlags.rawValue | |
| & UInt(NX_DEVICELCTLKEYMASK) != 0 | |
| case 0x3E: | |
| sidePressed = event.modifierFlags.rawValue | |
| & UInt(NX_DEVICERCTLKEYMASK) != 0 | |
| case 0x3A: | |
| sidePressed = event.modifierFlags.rawValue | |
| & UInt(NX_DEVICELALTKEYMASK) != 0 | |
| case 0x3D: | |
| sidePressed = event.modifierFlags.rawValue | |
| & UInt(NX_DEVICERALTKEYMASK) != 0 | |
| case 0x37: | |
| sidePressed = event.modifierFlags.rawValue | |
| & UInt(NX_DEVICELCMDKEYMASK) != 0 | |
| case 0x36: | |
| sidePressed = event.modifierFlags.rawValue | |
| & UInt(NX_DEVICERCMDKEYMASK) != 0 | |
| default: | |
| sidePressed = true | |
| } |
With this applied, the comment block above (lines 366–369) might want to be tweaked to drop the "for right-side keys" qualifier since both sides are now checked symmetrically.
The modifier key handler only checked device-specific masks for right-side key codes. Left-side codes fell through to `default: sidePressed = true`, which caused a released left modifier to be reported as still pressed whenever the corresponding right modifier was held. Add `NX_DEVICEL*KEYMASK` checks for the four left-side key codes (0x38, 0x3B, 0x3A, 0x37) so both sides are handled symmetrically.
On US-English keyboards, pressing a dead-key sequence such as Option+E followed by a vowel would leak the intermediate keystroke — e.g. typing
"could produce'"because the initial'compose key was sent as real input. CJK input methods (Korean, Japanese) had similar issues where compose keystrokes were not suppressed.Port the preedit synchronisation pattern from Ghostty's own
SurfaceView_AppKit: track marked-text state before and afterinterpretKeyEvents, callghostty_surface_preeditto push the compose string (or clear it), and set thecomposingflag on key events so libghostty suppresses encoding while a dead-key or IME sequence is active.Fixes #454