diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index fec9dc4b..99d70b2c 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -2115,6 +2115,17 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { var keyTextAccumulatorForTesting: [String]? { keyTextAccumulator } + + // Test-only IME point override so firstRect behavior can be regression tested. + private var imePointOverrideForTesting: (x: Double, y: Double, width: Double, height: Double)? + + func setIMEPointForTesting(x: Double, y: Double, width: Double, height: Double) { + imePointOverrideForTesting = (x, y, width, height) + } + + func clearIMEPointForTesting() { + imePointOverrideForTesting = nil + } #endif #if DEBUG @@ -4204,17 +4215,28 @@ extension GhosttyNSView: NSTextInputClient { // Use Ghostty's IME point API for accurate cursor position if available. var x: Double = 0 var y: Double = 0 - var w: Double = 0 - var h: Double = 0 + var w: Double = cellSize.width + var h: Double = cellSize.height +#if DEBUG + if let override = imePointOverrideForTesting { + x = override.x + y = override.y + w = override.width + h = override.height + } else if let surface = surface { + ghostty_surface_ime_point(surface, &x, &y, &w, &h) + } +#else if let surface = surface { ghostty_surface_ime_point(surface, &x, &y, &w, &h) } +#endif // Ghostty coordinates are top-left origin; AppKit expects bottom-left. let viewRect = NSRect( x: x, y: frame.size.height - y, - width: 0, + width: w, height: max(h, cellSize.height) ) let winRect = convert(viewRect, to: nil) diff --git a/cmuxTests/CJKIMEInputTests.swift b/cmuxTests/CJKIMEInputTests.swift index 4b391978..4191c7fc 100644 --- a/cmuxTests/CJKIMEInputTests.swift +++ b/cmuxTests/CJKIMEInputTests.swift @@ -642,6 +642,73 @@ final class CJKIMECompositionSequenceTests: XCTestCase { } } +// MARK: - IME firstRect placement and sizing + +/// Regression tests for IME candidate/preedit anchor rectangle reporting. +/// If width/height are discarded here, macOS can place preedit UI incorrectly. +final class CJKIMEFirstRectTests: XCTestCase { + + func testFirstRectUsesIMEProvidedWidthAndHeight() { + let frame = NSRect(x: 0, y: 0, width: 800, height: 600) + let view = GhosttyNSView(frame: frame) + view.cellSize = CGSize(width: 10, height: 20) + view.setIMEPointForTesting(x: 120, y: 240, width: 64, height: 26) + + let window = NSWindow( + contentRect: NSRect(x: 100, y: 100, width: 800, height: 600), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + let content = NSView(frame: frame) + window.contentView = content + content.addSubview(view) + view.frame = frame + + defer { + view.clearIMEPointForTesting() + window.orderOut(nil) + } + + let rect = view.firstRect(forCharacterRange: NSRange(location: 0, length: 1), actualRange: nil) + + let expectedViewRect = NSRect(x: 120, y: frame.height - 240, width: 64, height: 26) + let expectedScreenRect = window.convertToScreen(view.convert(expectedViewRect, to: nil)) + + XCTAssertEqual(rect.origin.x, expectedScreenRect.origin.x, accuracy: 0.001) + XCTAssertEqual(rect.origin.y, expectedScreenRect.origin.y, accuracy: 0.001) + XCTAssertEqual(rect.width, 64, accuracy: 0.001) + XCTAssertEqual(rect.height, 26, accuracy: 0.001) + } + + func testFirstRectFallsBackToCellHeightWhenIMEHeightIsZero() { + let frame = NSRect(x: 0, y: 0, width: 640, height: 480) + let view = GhosttyNSView(frame: frame) + view.cellSize = CGSize(width: 9, height: 18) + view.setIMEPointForTesting(x: 80, y: 120, width: 36, height: 0) + + let window = NSWindow( + contentRect: NSRect(x: 40, y: 40, width: 640, height: 480), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + let content = NSView(frame: frame) + window.contentView = content + content.addSubview(view) + view.frame = frame + + defer { + view.clearIMEPointForTesting() + window.orderOut(nil) + } + + let rect = view.firstRect(forCharacterRange: NSRange(location: 0, length: 1), actualRange: nil) + XCTAssertEqual(rect.width, 36, accuracy: 0.001) + XCTAssertEqual(rect.height, 18, accuracy: 0.001) + } +} + // MARK: - Key text accumulator during CJK IME composition /// Tests that the keyTextAccumulator correctly manages text during the keyDown