Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
39 changes: 38 additions & 1 deletion Sources/GhosttyTerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
}
Comment thread
knu marked this conversation as resolved.
#if DEBUG
let sendTimingStart = CmuxTypingTiming.start()
let ghosttySendStart = ProcessInfo.processInfo.systemUptime
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
91 changes: 91 additions & 0 deletions cmuxTests/CJKIMEMarkedSelectionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions cmuxTests/GhosttyConfigTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -890,8 +890,8 @@ final class GhosttyConfigTests: XCTestCase {
)
}

func testLegacyConfigFallbackDoesNotReloadLegacyFileWhenConfigGhosttyIsMissing() {
XCTAssertFalse(
func testLegacyConfigFallbackUsesLegacyFileWhenConfigGhosttyIsMissing() {
XCTAssertTrue(
GhosttyApp.shouldLoadLegacyGhosttyConfig(
newConfigFileSize: nil,
legacyConfigFileSize: 42
Expand Down