diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigDiscovery/GhosttyConfigDiscovery.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigDiscovery/GhosttyConfigDiscovery.swift index 12299081a5c0..47b6977a7ed5 100644 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigDiscovery/GhosttyConfigDiscovery.swift +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigDiscovery/GhosttyConfigDiscovery.swift @@ -500,8 +500,8 @@ public struct GhosttyConfigDiscovery { } /// Whether cmux should load the legacy `config` file because the new - /// `config.ghostty` is empty (size 0). Only true when the legacy file is - /// non-empty and the new file is exactly empty. + /// `config.ghostty` is missing or empty. Only true when the legacy file is + /// non-empty and the new file is absent or exactly empty. /// /// Pure with respect to its inputs; an instance method (not a static /// namespace member) so call sites invoke it on the held discovery value @@ -511,6 +511,7 @@ public struct GhosttyConfigDiscovery { legacyConfigFileSize: Int? ) -> Bool { guard let legacyConfigFileSize, legacyConfigFileSize > 0 else { return false } + guard let newConfigFileSize else { return true } return newConfigFileSize == 0 } diff --git a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/GhosttyConfigDiscoveryTests.swift b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/GhosttyConfigDiscoveryTests.swift index 4009562a1cf7..a02926ea87d4 100644 --- a/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/GhosttyConfigDiscoveryTests.swift +++ b/Packages/macOS/CmuxTerminalCore/Tests/CmuxTerminalCoreTests/GhosttyConfigDiscoveryTests.swift @@ -117,11 +117,11 @@ private struct NoFontProbe: GhosttyFontProbing { @Suite struct GhosttyConfigDiscoveryLegacyTests { private let discovery = GhosttyConfigDiscovery(fontProbe: NoFontProbe()) - @Test func loadLegacyOnlyWhenNewIsEmptyAndLegacyNonEmpty() { + @Test func loadLegacyWhenNewIsMissingOrEmptyAndLegacyNonEmpty() { #expect(discovery.shouldLoadLegacyGhosttyConfig(newConfigFileSize: 0, legacyConfigFileSize: 10)) + #expect(discovery.shouldLoadLegacyGhosttyConfig(newConfigFileSize: nil, legacyConfigFileSize: 10)) #expect(!discovery.shouldLoadLegacyGhosttyConfig(newConfigFileSize: 5, legacyConfigFileSize: 10)) #expect(!discovery.shouldLoadLegacyGhosttyConfig(newConfigFileSize: 0, legacyConfigFileSize: 0)) - #expect(!discovery.shouldLoadLegacyGhosttyConfig(newConfigFileSize: nil, legacyConfigFileSize: 10)) } @Test func includeLegacyInScanPathsWhenNewMissingOrEmpty() { diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 20bf2723e463..0eceaf52b3d7 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5900,6 +5900,11 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { keyEvent.composing = false for text in accumulatedText { if shouldSendText(text) { + if markedTextBefore { + imeConsumedKeyUps.insert(event.keyCode) + sendCommittedIMETextToSurface(text, action: action, sourceEvent: event) + continue + } #if DEBUG let sendTimingStart = CmuxTypingTiming.start() let ghosttySendStart = ProcessInfo.processInfo.systemUptime @@ -5927,7 +5932,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { extra: "textBytes=\(text.utf8.count)" ) #endif - } else { + } else if !markedTextBefore { keyEvent.consumed_mods = GHOSTTY_MODS_NONE keyEvent.text = nil #if DEBUG @@ -6046,6 +6051,38 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { // Rendering is driven by Ghostty's wakeups/renderer. } + @discardableResult + private func sendCommittedIMETextToSurface( + _ text: String, + action: ghostty_input_action_e, + sourceEvent: NSEvent + ) -> Bool { + guard let surface else { return false } + + var keyEvent = ghostty_input_key_s() + keyEvent.action = action + keyEvent.keycode = 0 + keyEvent.mods = GHOSTTY_MODS_NONE + keyEvent.consumed_mods = GHOSTTY_MODS_NONE + keyEvent.unshifted_codepoint = 0 + keyEvent.composing = false + + return text.withCString { ptr in + keyEvent.text = ptr +#if DEBUG + return sendTimedGhosttyKey( + surface, + keyEvent, + path: "terminal.keyDown.committedIMETextGhosttySend", + event: sourceEvent, + extra: "textBytes=\(text.utf8.count)" + ) +#else + return sendGhosttyKey(surface, keyEvent) +#endif + } + } + @discardableResult private func sendGhosttyKey(_ surface: ghostty_surface_t, _ keyEvent: ghostty_input_key_s) -> Bool { #if DEBUG diff --git a/cmuxTests/CJKIMEMarkedSelectionTests.swift b/cmuxTests/CJKIMEMarkedSelectionTests.swift index d0af6f06a2f6..8051d72ddeb8 100644 --- a/cmuxTests/CJKIMEMarkedSelectionTests.swift +++ b/cmuxTests/CJKIMEMarkedSelectionTests.swift @@ -214,6 +214,97 @@ final class CJKIMEMarkedSelectionTests: XCTestCase { ) } + func testControlJCommitWhileMarkedTextIsActiveSendsOnlyCommittedText() throws { + let hostedTerminal = try makeHostedTerminalWindow() + let terminalSurface = hostedTerminal.surface + let window = hostedTerminal.window + let surfaceView = hostedTerminal.surfaceView + let previousKeyEventObserver = GhosttyNSView.debugGhosttySurfaceKeyEventObserver + let previousInputSourceOverride = KeyboardLayout.debugInputSourceIdOverride + let previousInterpretHook = cjkIMEInterpretKeyEventsHook + defer { + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = previousKeyEventObserver + KeyboardLayout.debugInputSourceIdOverride = previousInputSourceOverride + cjkIMEInterpretKeyEventsHook = previousInterpretHook + window.orderOut(nil) + withExtendedLifetime(terminalSurface) {} + } + + KeyboardLayout.debugInputSourceIdOverride = "com.apple.inputmethod.Kotoeri.RomajiTyping.Japanese" + installCJKIMEInterpretKeyEventsSwizzle() + cjkIMEInterpretKeyEventsHook = { candidateView, _ in + guard candidateView === surfaceView else { return false } + candidateView.insertText("日本", replacementRange: NSRange(location: 0, length: 2)) + candidateView.setMarkedText( + "語", + selectedRange: NSRange(location: 1, length: 0), + replacementRange: NSRange(location: NSNotFound, length: 0) + ) + return true + } + + var pressedText: [(text: String, keyCode: UInt32, mods: UInt32)] = [] + var pressedControlKeyCodes: [UInt32] = [] + var releasedControlKeyCodes: [UInt32] = [] + GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in + previousKeyEventObserver?(keyEvent) + if keyEvent.action == GHOSTTY_ACTION_PRESS, let text = keyEvent.text { + pressedText.append((String(cString: text), keyEvent.keycode, keyEvent.mods.rawValue)) + } else if keyEvent.action == GHOSTTY_ACTION_PRESS, + keyEvent.mods.rawValue & GHOSTTY_MODS_CTRL.rawValue != 0 { + pressedControlKeyCodes.append(keyEvent.keycode) + } else if keyEvent.action == GHOSTTY_ACTION_RELEASE, + keyEvent.mods.rawValue & GHOSTTY_MODS_CTRL.rawValue != 0 { + releasedControlKeyCodes.append(keyEvent.keycode) + } + } + + surfaceView.setMarkedText( + "にほんご", + selectedRange: NSRange(location: 0, length: 2), + replacementRange: NSRange(location: NSNotFound, length: 0) + ) + + let event = try XCTUnwrap(NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.control], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "\n", + charactersIgnoringModifiers: "j", + isARepeat: false, + keyCode: UInt16(kVK_ANSI_J) + )) + let keyUp = try XCTUnwrap(NSEvent.keyEvent( + with: .keyUp, + location: .zero, + modifierFlags: [.control], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "\n", + charactersIgnoringModifiers: "j", + isARepeat: false, + keyCode: UInt16(kVK_ANSI_J) + )) + + window.makeFirstResponder(surfaceView) + withExtendedLifetime(terminalSurface) { + surfaceView.keyDown(with: event) + surfaceView.keyUp(with: keyUp) + } + + XCTAssertEqual(pressedText.map(\.text), ["日本"]) + XCTAssertEqual(pressedText.map(\.keyCode), [0]) + XCTAssertEqual(pressedText.map(\.mods), [GHOSTTY_MODS_NONE.rawValue]) + XCTAssertEqual(pressedControlKeyCodes, []) + XCTAssertEqual(releasedControlKeyCodes, []) + XCTAssertTrue(surfaceView.hasMarkedText(), "Remaining IME segments should stay in preedit") + XCTAssertEqual(surfaceView.attributedString().string, "語") + } + func testKeyDownForKoreanPostCompositionHorizontalArrowsForwardsToTerminal() throws { let hostedTerminal = try makeHostedTerminalWindow() let terminalSurface = hostedTerminal.surface diff --git a/cmuxTests/GhosttyConfigTests.swift b/cmuxTests/GhosttyConfigTests.swift index 12c40f7ee472..64b050cff5b0 100644 --- a/cmuxTests/GhosttyConfigTests.swift +++ b/cmuxTests/GhosttyConfigTests.swift @@ -890,8 +890,8 @@ final class GhosttyConfigTests: XCTestCase { ) } - func testLegacyConfigFallbackDoesNotReloadLegacyFileWhenConfigGhosttyIsMissing() { - XCTAssertFalse( + func testLegacyConfigFallbackUsesLegacyFileWhenConfigGhosttyIsMissing() { + XCTAssertTrue( GhosttyApp.shouldLoadLegacyGhosttyConfig( newConfigFileSize: nil, legacyConfigFileSize: 42