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>
102 lines
4.1 KiB
Swift
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)
|
|
}
|
|
}
|