Skip to content

fix: sync IME preedit state with libghostty#458

Merged
davidpoblador merged 3 commits into
alltuner:mainfrom
hiddeco:fix/terminal-ime-preedit
May 4, 2026
Merged

fix: sync IME preedit state with libghostty#458
davidpoblador merged 3 commits into
alltuner:mainfrom
hiddeco:fix/terminal-ime-preedit

Conversation

@hiddeco
Copy link
Copy Markdown
Contributor

@hiddeco hiddeco commented Apr 24, 2026

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.

Fixes #454

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.
@davidpoblador
Copy link
Copy Markdown
Member

davidpoblador commented Apr 28, 2026

Was the omission of syncPreedit from setMarkedText/unmarkText intentional? Upstream Ghostty calls it from both when not inside a keyDown (keyTextAccumulator == nil) — see SurfaceView_AppKit.swift#L1844-L1857 (v1.3.1, the tag this repo's submodule is pinned to). The upstream comment specifically flags the "change keyboard layout mid-compose" case (set US Intl, type ', switch layout). Curious whether you scoped this down deliberately or just hadn't gotten to it.

@hiddeco
Copy link
Copy Markdown
Contributor Author

hiddeco commented May 1, 2026

@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`.
@hiddeco hiddeco force-pushed the fix/terminal-ime-preedit branch from b96548a to 6516054 Compare May 1, 2026 10:45
Comment on lines +373 to +388
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
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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.
@davidpoblador davidpoblador merged commit a582cc2 into alltuner:main May 4, 2026
1 of 2 checks passed
@hiddeco hiddeco deleted the fix/terminal-ime-preedit branch May 4, 2026 13:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CJK IME input: raw keystrokes leak to terminal during composition

2 participants