fix(terminal): execute Return after Korean IME commit (#1671)

* test(terminal): cover Return after Korean IME commit

* fix(terminal): execute Return after Korean IME commit

---------

Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
This commit is contained in:
Lawrence Chen 2026-03-17 22:07:45 -07:00 committed by GitHub
parent 58de044f4f
commit b64fb301c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 173 additions and 0 deletions

View file

@ -5227,6 +5227,26 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
#endif
}
}
if shouldSendCommittedIMEConfirmKey(
event: translationEvent,
markedTextBefore: markedTextBefore
) {
keyEvent.consumed_mods = GHOSTTY_MODS_NONE
keyEvent.text = nil
#if DEBUG
let ghosttySendStart = ProcessInfo.processInfo.systemUptime
_ = sendTimedGhosttyKey(
surface,
keyEvent,
path: "terminal.keyDown.accumulatedConfirmGhosttySend",
event: event
)
ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0
#else
_ = ghostty_surface_key(surface, keyEvent)
#endif
}
} else {
// Get the appropriate text for this key event
// For control characters, this returns the unmodified character
@ -5487,6 +5507,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
return true
}
private func shouldSendCommittedIMEConfirmKey(event: NSEvent, markedTextBefore: Bool) -> Bool {
guard markedTextBefore, markedText.length == 0 else { return false }
return event.keyCode == 36 || event.keyCode == 76
}
private func ghosttyKeyEvent(for event: NSEvent, surface: ghostty_surface_t) -> ghostty_input_key_s {
var keyEvent = ghostty_input_key_s()
keyEvent.action = GHOSTTY_ACTION_PRESS

View file

@ -1,5 +1,6 @@
import XCTest
import AppKit
import ObjectiveC.runtime
#if canImport(cmux_DEV)
@testable import cmux_DEV
@ -7,6 +8,64 @@ import AppKit
@testable import cmux
#endif
private var cjkIMEInterpretKeyEventsSwizzled = false
private var cjkIMEInterpretKeyEventsHook: ((GhosttyNSView, [NSEvent]) -> Bool)?
private extension GhosttyNSView {
@objc func cmuxUnitTest_interpretKeyEvents(_ eventArray: [NSEvent]) {
if let hook = cjkIMEInterpretKeyEventsHook, hook(self, eventArray) {
return
}
cmuxUnitTest_interpretKeyEvents(eventArray)
}
}
private func installCJKIMEInterpretKeyEventsSwizzle() {
guard !cjkIMEInterpretKeyEventsSwizzled else { return }
let originalSelector = #selector(GhosttyNSView.interpretKeyEvents(_:))
let swizzledSelector = #selector(GhosttyNSView.cmuxUnitTest_interpretKeyEvents(_:))
guard let originalMethod = class_getInstanceMethod(GhosttyNSView.self, originalSelector),
let swizzledMethod = class_getInstanceMethod(GhosttyNSView.self, swizzledSelector) else {
fatalError("Unable to locate GhosttyNSView interpretKeyEvents methods for swizzling")
}
let didAddMethod = class_addMethod(
GhosttyNSView.self,
originalSelector,
method_getImplementation(swizzledMethod),
method_getTypeEncoding(swizzledMethod)
)
if didAddMethod {
class_replaceMethod(
GhosttyNSView.self,
swizzledSelector,
method_getImplementation(originalMethod),
method_getTypeEncoding(originalMethod)
)
} else {
method_exchangeImplementations(originalMethod, swizzledMethod)
}
cjkIMEInterpretKeyEventsSwizzled = true
}
private func findGhosttyNSView(in view: NSView) -> GhosttyNSView? {
if let view = view as? GhosttyNSView {
return view
}
for subview in view.subviews {
if let match = findGhosttyNSView(in: subview) {
return match
}
}
return nil
}
// MARK: - NSTextInputClient protocol: marked text (preedit) lifecycle
/// Tests that the GhosttyNSView NSTextInputClient implementation correctly
@ -932,6 +991,95 @@ final class GhosttySpaceReleaseRegressionTests: XCTestCase {
}
}
@MainActor
final class KoreanIMEReturnCommitRegressionTests: XCTestCase {
func testReturnAfterKoreanCommitAlsoSendsReturnToSurface() {
_ = NSApplication.shared
let surface = TerminalSurface(
tabId: UUID(),
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
configTemplate: nil,
workingDirectory: nil
)
let hostedView = surface.hostedView
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
defer {
GhosttyNSView.debugGhosttySurfaceKeyEventObserver = nil
window.orderOut(nil)
}
guard let contentView = window.contentView else {
XCTFail("Expected content view")
return
}
hostedView.frame = contentView.bounds
hostedView.autoresizingMask = [.width, .height]
contentView.addSubview(hostedView)
window.makeKeyAndOrderFront(nil)
window.displayIfNeeded()
contentView.layoutSubtreeIfNeeded()
hostedView.setVisibleInUI(true)
hostedView.setActive(true)
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
guard let view = findGhosttyNSView(in: hostedView) else {
XCTFail("Expected hosted GhosttyNSView")
return
}
view.setMarkedText("", selectedRange: NSRange(location: 0, length: 1), replacementRange: NSRange(location: NSNotFound, length: 0))
installCJKIMEInterpretKeyEventsSwizzle()
cjkIMEInterpretKeyEventsHook = { candidateView, _ in
guard candidateView === view else { return false }
candidateView.insertText("", replacementRange: NSRange(location: NSNotFound, length: 0))
return true
}
defer {
cjkIMEInterpretKeyEventsHook = nil
}
var sawReturnPress = false
GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in
guard keyEvent.action == GHOSTTY_ACTION_PRESS,
keyEvent.keycode == 36,
keyEvent.text == nil else { return }
sawReturnPress = true
}
guard let event = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: [],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber,
context: nil,
characters: "\r",
charactersIgnoringModifiers: "\r",
isARepeat: false,
keyCode: 36
) else {
XCTFail("Failed to create Return event")
return
}
window.makeFirstResponder(view)
view.keyDown(with: event)
XCTAssertFalse(view.hasMarkedText(), "Return should commit the active Hangul composition")
XCTAssertTrue(sawReturnPress, "Return should still be forwarded after IME commit so the command executes once")
}
}
final class GhosttyBackquoteRegressionTests: XCTestCase {
func testShiftBackquoteEscFallbackSendsLiteralTilde() {
_ = NSApplication.shared