cmux/Sources/KeyboardLayout.swift
Hiroki Kajiwara a395e8c343
fix: prevent Japanese IME confirmation Enter from executing command (#2075)
* fix: prevent Japanese IME confirmation Enter from executing command

Korean IME commits a syllable and executes on a single Enter, but
Japanese/Chinese IME use Enter only to confirm conversion — a second
Enter is needed to execute. Restrict the extra Return forwarding in
shouldSendCommittedIMEConfirmKey to Korean input sources only.

* refactor: use case-insensitive check for Korean input source ID
2026-03-24 22:24:13 -07:00

110 lines
4.4 KiB
Swift

import AppKit
import Carbon
class KeyboardLayout {
/// Test-only override for the current input source ID.
#if DEBUG
static var debugInputSourceIdOverride: String?
#endif
/// Return a string ID of the current keyboard input source.
static var id: String? {
#if DEBUG
if let override = debugInputSourceIdOverride { return override }
#endif
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)
}
}