diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 938c54f7..cd623863 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -10674,7 +10674,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // For command-based shortcuts, trust AppKit's layout-aware characters when present. // Keep this strict for letter shortcuts to avoid physical-key collisions across layouts, // while still allowing keyCode fallback for digit/punctuation shortcuts on non-US layouts. - // When a non-Latin input source is active (Korean, Chinese, Japanese, etc.), + // When a non-Latin input source is active (Russian, Korean, Chinese, Japanese, etc.), // charactersIgnoringModifiers returns non-ASCII characters that can never match // a Latin shortcut key — skip this guard and fall through to layout-based matching. let hasEventChars = !(eventCharsIgnoringModifiers?.isEmpty ?? true) @@ -10703,15 +10703,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // so keep ANSI keyCode fallback for control-modified shortcuts. Also allow fallback for // command punctuation shortcuts, since some non-US layouts report different characters // for the same physical key even when menu-equivalent semantics should still apply. - // When a non-Latin input source is active, treat non-ASCII event chars the same as - // absent chars — they carry no usable Latin key identity. + // When a non-Latin input source is active (Russian, Korean, Chinese, Japanese, etc.), + // event chars carry no usable Latin key identity. Always allow keyCode fallback as a + // safety net — even when the layout-based translation resolved a character, the + // physical key code is the definitive identifier for the intended shortcut. + // For empty-character events (synthetic/browser key equivalents), preserve the original + // behavior: only fall back when the layout translation also failed. let hasUsableEventChars = hasEventChars && eventCharsAreASCII let allowANSIKeyCodeFallback = flags.contains(.control) || (flags.contains(.command) && !flags.contains(.control) && ( !shouldRequireCharacterMatchForCommandShortcut(shortcutKey: shortcutKey) - || (!hasUsableEventChars && (layoutCharacter?.isEmpty ?? true)) + || (hasEventChars && !eventCharsAreASCII) + || (!hasEventChars && (layoutCharacter?.isEmpty ?? true)) )) if allowANSIKeyCodeFallback, let expectedKeyCode = keyCodeForShortcutKey(shortcutKey) { return event.keyCode == expectedKeyCode diff --git a/cmuxTests/AppDelegateShortcutRoutingTests.swift b/cmuxTests/AppDelegateShortcutRoutingTests.swift index a2b611bd..a759b098 100644 --- a/cmuxTests/AppDelegateShortcutRoutingTests.swift +++ b/cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -2997,6 +2997,112 @@ final class AppDelegateShortcutRoutingTests: XCTestCase { XCTAssertEqual(activateApplicationCallCount, 1) } + // MARK: - Non-Latin keyboard layout shortcut tests + + func testCmdTWorksWithRussianKeyboardLayout() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId), + let workspace = manager.selectedWorkspace else { + XCTFail("Expected test window context") + return + } + + let surfaceCountBefore = workspace.panels.count + + // Simulate Russian keyboard: layout provider returns "t" via ASCII fallback, + // but event.charactersIgnoringModifiers returns Cyrillic "е". + appDelegate.shortcutLayoutCharacterProvider = { keyCode, _ in + keyCode == 17 ? "t" : nil + } + defer { + appDelegate.shortcutLayoutCharacterProvider = KeyboardLayout.character(forKeyCode:modifierFlags:) + } + + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "t", + charactersIgnoringModifiers: "е", // Cyrillic е (Russian layout) + isARepeat: false, + keyCode: 17 // kVK_ANSI_T + ) else { + XCTFail("Failed to construct Russian-layout Cmd+T event") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event), "Cmd+T should be handled with Russian keyboard layout") +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + XCTAssertEqual(workspace.panels.count, surfaceCountBefore + 1, "Cmd+T should create a new surface with Russian keyboard layout") + } + + func testCmdTFallsBackToKeyCodeWithNonLatinLayoutWhenLayoutTranslationFails() { + guard let appDelegate = AppDelegate.shared else { + XCTFail("Expected AppDelegate.shared") + return + } + + let windowId = appDelegate.createMainWindow() + defer { closeWindow(withId: windowId) } + + guard let window = window(withId: windowId), + let manager = appDelegate.tabManagerFor(windowId: windowId), + let workspace = manager.selectedWorkspace else { + XCTFail("Expected test window context") + return + } + + let surfaceCountBefore = workspace.panels.count + + // Simulate non-Latin layout where layout translation also fails (returns nil). + // The ANSI keyCode fallback should still match the physical T key. + appDelegate.shortcutLayoutCharacterProvider = { _, _ in nil } + defer { + appDelegate.shortcutLayoutCharacterProvider = KeyboardLayout.character(forKeyCode:modifierFlags:) + } + + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: [.command], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + characters: "", + charactersIgnoringModifiers: "е", // Cyrillic е — non-ASCII + isARepeat: false, + keyCode: 17 // kVK_ANSI_T + ) else { + XCTFail("Failed to construct non-Latin Cmd+T event with failed layout translation") + return + } + +#if DEBUG + XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event), "Cmd+T should fall back to keyCode with non-Latin layout") +#else + XCTFail("debugHandleCustomShortcut is only available in DEBUG") +#endif + RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05)) + + XCTAssertEqual(workspace.panels.count, surfaceCountBefore + 1, "Cmd+T keyCode fallback should create a new surface") + } + private func makeKeyDownEvent( key: String, modifiers: NSEvent.ModifierFlags,