Repository navigation
fix(browser): keep IME Enter on composition path #2108
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -56,6 +56,21 @@ func installCmuxUnitTestInspectorOverride() { | |
| cmuxUnitTestInspectorOverrideInstalled = true | ||
| } | ||
|
|
||
| private final class BrowserMarkedTextProbeTextView: NSTextView { | ||
| var hasMarkedTextForTesting = false | ||
| private(set) var keyDownEvents: [NSEvent] = [] | ||
|
|
||
| override var acceptsFirstResponder: Bool { true } | ||
|
|
||
| override func hasMarkedText() -> Bool { | ||
| hasMarkedTextForTesting | ||
| } | ||
|
|
||
| override func keyDown(with event: NSEvent) { | ||
| keyDownEvents.append(event) | ||
| } | ||
| } | ||
|
|
||
| final class CmuxWebViewKeyEquivalentTests: XCTestCase { | ||
| private final class ActionSpy: NSObject { | ||
| private(set) var invoked: Bool = false | ||
|
|
@@ -2455,6 +2470,107 @@ final class BrowserOmnibarCommandNavigationTests: XCTestCase { | |
| } | ||
|
|
||
|
|
||
| final class BrowserIMEKeyDownRoutingTests: XCTestCase { | ||
| @MainActor | ||
| func testWindowPerformKeyEquivalentDoesNotForwardReturnDuringMarkedTextComposition() { | ||
| _ = NSApplication.shared | ||
| AppDelegate.installWindowResponderSwizzlesForTesting() | ||
|
|
||
| let window = NSWindow( | ||
| contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), | ||
| styleMask: [.titled, .closable], | ||
| backing: .buffered, | ||
| defer: false | ||
| ) | ||
| let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) | ||
| window.contentView = container | ||
|
|
||
| let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) | ||
| webView.autoresizingMask = [.width, .height] | ||
| container.addSubview(webView) | ||
|
|
||
| let responder = BrowserMarkedTextProbeTextView(frame: NSRect(x: 0, y: 0, width: 32, height: 20)) | ||
| responder.hasMarkedTextForTesting = true | ||
| webView.addSubview(responder) | ||
|
|
||
| window.makeKeyAndOrderFront(nil) | ||
| defer { window.orderOut(nil) } | ||
|
|
||
| XCTAssertTrue(window.makeFirstResponder(responder)) | ||
| 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 construct Return event") | ||
| return | ||
| } | ||
|
|
||
| let consumed = window.performKeyEquivalent(with: event) | ||
|
|
||
| XCTAssertFalse(consumed, "Return should stay in the IME path while marked text is active") | ||
| XCTAssertTrue(responder.hasMarkedText(), "Marked text should still be active until the input method commits it") | ||
| XCTAssertEqual(responder.keyDownEvents.count, 0, "Return should not be force-forwarded to the browser responder during IME composition") | ||
| } | ||
|
|
||
| @MainActor | ||
| func testWindowPerformKeyEquivalentDoesNotForwardKeypadEnterDuringMarkedTextComposition() { | ||
| _ = NSApplication.shared | ||
| AppDelegate.installWindowResponderSwizzlesForTesting() | ||
|
|
||
| let window = NSWindow( | ||
| contentRect: NSRect(x: 0, y: 0, width: 640, height: 420), | ||
| styleMask: [.titled, .closable], | ||
| backing: .buffered, | ||
| defer: false | ||
| ) | ||
| let container = NSView(frame: window.contentRect(forFrameRect: window.frame)) | ||
| window.contentView = container | ||
|
|
||
| let webView = CmuxWebView(frame: container.bounds, configuration: WKWebViewConfiguration()) | ||
| webView.autoresizingMask = [.width, .height] | ||
| container.addSubview(webView) | ||
|
|
||
| let responder = BrowserMarkedTextProbeTextView(frame: NSRect(x: 0, y: 0, width: 32, height: 20)) | ||
| responder.hasMarkedTextForTesting = true | ||
| webView.addSubview(responder) | ||
|
|
||
| window.makeKeyAndOrderFront(nil) | ||
| defer { window.orderOut(nil) } | ||
|
|
||
| XCTAssertTrue(window.makeFirstResponder(responder)) | ||
| 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: 76 | ||
| ) else { | ||
| XCTFail("Failed to construct keypad Enter event") | ||
| return | ||
| } | ||
|
|
||
| let consumed = window.performKeyEquivalent(with: event) | ||
|
|
||
| XCTAssertFalse(consumed, "Keypad Enter should stay in the IME path while marked text is active") | ||
| XCTAssertTrue(responder.hasMarkedText(), "Marked text should still be active until the input method commits it") | ||
| XCTAssertEqual(responder.keyDownEvents.count, 0, "Keypad Enter should not be force-forwarded to the browser responder during IME composition") | ||
| } | ||
| } | ||
|
|
||
|
|
||
| final class BrowserReturnKeyDownRoutingTests: XCTestCase { | ||
| func testRoutesForReturnWhenBrowserFirstResponder() { | ||
| XCTAssertTrue( | ||
|
Comment on lines
+2475
to
2576
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
private func runIMEEnterTest(keyCode: UInt16, label: String) { … }Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||
|
|
@@ -2496,6 +2612,28 @@ final class BrowserReturnKeyDownRoutingTests: XCTestCase { | |
| ) | ||
| } | ||
|
|
||
| func testDoesNotRouteReturnWhenBrowserFirstResponderHasMarkedText() { | ||
| XCTAssertFalse( | ||
| shouldDispatchBrowserReturnViaFirstResponderKeyDown( | ||
| keyCode: 36, | ||
| firstResponderIsBrowser: true, | ||
| firstResponderHasMarkedText: true, | ||
| flags: [] | ||
| ) | ||
| ) | ||
| } | ||
|
|
||
| func testDoesNotRouteKeypadEnterWhenBrowserFirstResponderHasMarkedText() { | ||
| XCTAssertFalse( | ||
| shouldDispatchBrowserReturnViaFirstResponderKeyDown( | ||
| keyCode: 76, | ||
| firstResponderIsBrowser: true, | ||
| firstResponderHasMarkedText: true, | ||
| flags: [] | ||
| ) | ||
| ) | ||
| } | ||
|
|
||
| func testRoutesForShiftReturnWhenBrowserFirstResponder() { | ||
| XCTAssertTrue( | ||
| shouldDispatchBrowserReturnViaFirstResponderKeyDown( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NSTextFieldbranch appears unreachable in normal AppKit flowIn standard AppKit event dispatch, when a user is actively editing an
NSTextField, the field editor (NSTextView) — not theNSTextFielditself — becomes the window's first responder. SinceNSTextViewconforms toNSTextInputClient, it is already handled by the first branch above. TheNSTextFieldbranch here would only be reached ifNSTextFieldis the first responder whilecurrentEditor()is simultaneously non-nil, which does not occur in the normal AppKit responder promotion sequence.This is harmless dead code, but if the intent is to guard against some custom
NSTextFieldsubclass scenario, a brief comment explaining the edge case would help future readers understand why both paths exist.