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:
parent
58de044f4f
commit
b64fb301c1
2 changed files with 173 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue