Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 28 additions & 5 deletions Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6063,12 +6063,22 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
#endif
text.withCString { ptr in
keyEvent.text = ptr
_ = ghostty_surface_key(surface, keyEvent)
#if DEBUG
_ = sendTimedGhosttyKey(
surface,
keyEvent,
path: "terminal.keyDown.accumulatedGhosttySend",
event: event,
extra: "textBytes=\(text.utf8.count)"
)
#else
_ = sendGhosttyKey(surface, keyEvent)
#endif
}
#if DEBUG
ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0
CmuxTypingTiming.logDuration(
path: "terminal.keyDown.accumulatedGhosttySend",
path: "terminal.keyDown.accumulatedGhosttySend.total",
startedAt: sendTimingStart,
event: event,
extra: "textBytes=\(text.utf8.count)"
Expand Down Expand Up @@ -6120,21 +6130,34 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
event: translationEvent,
markedTextBefore: markedTextBefore
)
let suppressComposingFallbackText = keyEvent.composing
if let text = textForKeyEvent(translationEvent) {
if shouldSendText(text), !suppressShiftSpaceFallbackText {
if shouldSendText(text),
!suppressShiftSpaceFallbackText,
!suppressComposingFallbackText {
shouldRefreshAfterTextInput = true
#if DEBUG
let sendTimingStart = CmuxTypingTiming.start()
let ghosttySendStart = ProcessInfo.processInfo.systemUptime
#endif
text.withCString { ptr in
keyEvent.text = ptr
_ = ghostty_surface_key(surface, keyEvent)
#if DEBUG
_ = sendTimedGhosttyKey(
surface,
keyEvent,
path: "terminal.keyDown.ghosttySend",
event: event,
extra: "textBytes=\(text.utf8.count)"
)
#else
_ = sendGhosttyKey(surface, keyEvent)
#endif
}
#if DEBUG
ghosttySendMs += (ProcessInfo.processInfo.systemUptime - ghosttySendStart) * 1000.0
CmuxTypingTiming.logDuration(
path: "terminal.keyDown.ghosttySend",
path: "terminal.keyDown.ghosttySend.total",
startedAt: sendTimingStart,
event: event,
extra: "textBytes=\(text.utf8.count)"
Expand Down
99 changes: 98 additions & 1 deletion cmuxTests/CJKIMEInputTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ private func findGhosttyNSView(in view: NSView) -> GhosttyNSView? {

return nil
}

// MARK: - NSTextInputClient protocol: marked text (preedit) lifecycle

/// Tests that the GhosttyNSView NSTextInputClient implementation correctly
Expand Down Expand Up @@ -1126,6 +1125,104 @@ final class KoreanIMEReturnCommitRegressionTests: XCTestCase {
}
}

@MainActor
final class KoreanIMEMarkedTextLeakRegressionTests: XCTestCase {
func testKeyDownDoesNotLeakJamoWhileMarkedTextIsActive() {
_ = 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
KeyboardLayout.debugInputSourceIdOverride = nil
cjkIMEInterpretKeyEventsHook = 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)
)

KeyboardLayout.debugInputSourceIdOverride = "com.apple.inputmethod.Korean.2SetKorean"
installCJKIMEInterpretKeyEventsSwizzle()
cjkIMEInterpretKeyEventsHook = { candidateView, _ in
guard candidateView === view else { return false }
return true
}

var capturedEvent: ghostty_input_key_s?
GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in
guard keyEvent.action == GHOSTTY_ACTION_PRESS, keyEvent.keycode == 45 else { return }
capturedEvent = keyEvent
}

guard let event = NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: [],
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: window.windowNumber,
context: nil,
characters: "ㄴ",
charactersIgnoringModifiers: "ㄴ",
isARepeat: false,
keyCode: 45
) else {
XCTFail("Failed to create Hangul jamo event")
return
}

window.makeFirstResponder(view)
view.keyDown(with: event)

guard let capturedEvent else {
XCTFail(
"Expected a composing key event to be forwarded to Ghostty with text=nil; no event was received"
)
return
}

XCTAssertTrue(capturedEvent.composing, "Hangul composition keyDown should stay in composing mode")
XCTAssertNil(capturedEvent.text, "Uncommitted Hangul jamo must not be encoded into the terminal surface")
XCTAssertTrue(view.hasMarkedText(), "Composition should remain active until the IME commits or cancels")
}
}

@MainActor
final class AccessibilityInsertTextRegressionTests: XCTestCase {
func testDirectInsertTextUsesTypedInputSemantics() {
Expand Down
Loading