cmux/Sources/KeyboardLayout.swift
최준영 8cd9cd96c1 Fix keyboard shortcuts not working with Korean (한글) input mode
When a non-Latin input source like Korean 두벌식 is active,
event.charactersIgnoringModifiers returns Hangul characters (e.g. ㅅ
for T key) instead of Latin letters. This caused all character-based
shortcut matching to fail — Cmd+T, Cmd+D, Cmd+1-9, Ctrl+N/P, etc.

Root cause: KeyboardLayout.character(forKeyCode:modifierFlags:) assumed
CJK input sources lack kTISPropertyUnicodeKeyLayoutData, but Korean
두벌식 has it. UCKeyTranslate returned Korean characters and the ASCII
fallback was never reached.

Fix:
- KeyboardLayout.character(): check result is ASCII before accepting;
  fall through to TISCopyCurrentASCIICapableKeyboardInputSource() when
  the current source returns non-ASCII characters
- Add KeyboardLayout.normalizedCharacters(for:) helper that normalizes
  event.charactersIgnoringModifiers for shortcut comparison
- Apply normalization in handleCustomShortcut (AppDelegate),
  BrowserPanelView omnibar key handler, and BrowserPopupWindowController
  Cmd+W handler

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 21:16:10 +09:00

102 lines
4.1 KiB
Swift

import AppKit
import Carbon
class KeyboardLayout {
/// Return a string ID of the current keyboard input source.
static var id: String? {
if let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue(),
let sourceIdPointer = TISGetInputSourceProperty(source, kTISPropertyInputSourceID) {
let sourceId = Unmanaged<CFString>.fromOpaque(sourceIdPointer).takeUnretainedValue()
return sourceId as String
}
return nil
}
/// Translate a physical keyCode to the character AppKit would use for shortcut matching,
/// preserving command-aware layouts such as "Dvorak - QWERTY Command".
/// Some CJK input sources lack kTISPropertyUnicodeKeyLayoutData, and others (Korean
/// ) have it but UCKeyTranslate still returns non-ASCII characters. In either
/// case we fall back to TISCopyCurrentASCIICapableKeyboardInputSource().
static func character(
forKeyCode keyCode: UInt16,
modifierFlags: NSEvent.ModifierFlags = []
) -> String? {
if let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue(),
let result = characterFromInputSource(source, forKeyCode: keyCode, modifierFlags: modifierFlags),
result.allSatisfy(\.isASCII) {
return result
}
// Current input source has no Unicode layout data or returned a non-ASCII
// character (e.g. Korean has layout data but UCKeyTranslate still
// produces Hangul). Fall back to the ASCII-capable source so shortcut
// matching still works.
if let asciiSource = TISCopyCurrentASCIICapableKeyboardInputSource()?.takeRetainedValue(),
let result = characterFromInputSource(asciiSource, forKeyCode: keyCode, modifierFlags: modifierFlags) {
return result
}
return nil
}
/// Return the ASCII-normalized equivalent of `event.charactersIgnoringModifiers`,
/// falling back through the ASCII-capable input source for non-Latin input methods.
/// Use this wherever code compares raw event characters against Latin shortcut keys.
static func normalizedCharacters(for event: NSEvent) -> String {
let raw = (event.charactersIgnoringModifiers ?? "").lowercased()
if raw.allSatisfy(\.isASCII) { return raw }
if let layoutChar = character(forKeyCode: event.keyCode, modifierFlags: []) {
return layoutChar
}
return raw
}
private static func characterFromInputSource(
_ source: TISInputSource,
forKeyCode keyCode: UInt16,
modifierFlags: NSEvent.ModifierFlags
) -> String? {
guard let layoutDataPointer = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else {
return nil
}
let layoutData = unsafeBitCast(layoutDataPointer, to: CFData.self)
guard let bytes = CFDataGetBytePtr(layoutData) else { return nil }
let keyboardLayout = UnsafeRawPointer(bytes).assumingMemoryBound(to: UCKeyboardLayout.self)
var deadKeyState: UInt32 = 0
var chars = [UniChar](repeating: 0, count: 4)
var length = 0
let status = UCKeyTranslate(
keyboardLayout,
keyCode,
UInt16(kUCKeyActionDisplay),
translationModifierKeyState(for: modifierFlags),
UInt32(LMGetKbdType()),
UInt32(kUCKeyTranslateNoDeadKeysBit),
&deadKeyState,
chars.count,
&length,
&chars
)
guard status == noErr, length > 0 else { return nil }
return String(utf16CodeUnits: chars, count: length).lowercased()
}
private static func translationModifierKeyState(for modifierFlags: NSEvent.ModifierFlags) -> UInt32 {
let normalized = modifierFlags
.intersection(.deviceIndependentFlagsMask)
.intersection([.shift, .command])
var carbonModifiers: Int = 0
if normalized.contains(.shift) {
carbonModifiers |= shiftKey
}
if normalized.contains(.command) {
carbonModifiers |= cmdKey
}
return UInt32((carbonModifiers >> 8) & 0xFF)
}
}