diff --git a/Sources/NagaController/ButtonMapping/ActionTypes.swift b/Sources/NagaController/ButtonMapping/ActionTypes.swift index 80112ce..17af2fc 100644 --- a/Sources/NagaController/ButtonMapping/ActionTypes.swift +++ b/Sources/NagaController/ButtonMapping/ActionTypes.swift @@ -65,6 +65,14 @@ extension KeyStroke { } static func canonicalKeyString(for keyCode: UInt16?, characters: String?) -> String { + // Printable keys are recorded by the character they produced, which is what the + // user meant and survives keyboard-layout changes. The key-code table is only for + // special keys (Return, arrows, F-keys, ...), whose characters are controls or + // private-use code points. + if let chars = characters, chars.unicodeScalars.count == 1, + let scalar = chars.unicodeScalars.first, KeyboardLayout.isPrintable(scalar) { + return normalizeIdentifier(chars) + } if let code = keyCode, let primary = primaryKeyNames[code] { return primary } diff --git a/Sources/NagaController/ButtonMapping/ButtonMapper.swift b/Sources/NagaController/ButtonMapping/ButtonMapper.swift index d995bb2..f912037 100644 --- a/Sources/NagaController/ButtonMapping/ButtonMapper.swift +++ b/Sources/NagaController/ButtonMapping/ButtonMapper.swift @@ -109,8 +109,9 @@ final class ButtonMapper { return } - let flags = modifierFlags(from: stroke.modifiers) - if let code = keyCode, let eventDown = CGEvent(keyboardEventSource: nil, virtualKey: code, keyDown: true) { + let resolved = resolve(stroke) + let flags = modifierFlags(from: stroke.modifiers).union(resolved?.extraFlags ?? []) + if let code = resolved?.code, let eventDown = CGEvent(keyboardEventSource: nil, virtualKey: code, keyDown: true) { eventDown.flags = flags post(eventDown) activeHolds[buttonIndex] = (code, flags) @@ -243,9 +244,9 @@ final class ButtonMapper { private func sendKeyStroke(_ stroke: KeyStroke) { // Map simple keys (letters) to key codes; limited for Phase 1 - guard let keyCode = effectiveKeyCode(for: stroke) else { return } - - let flags = modifierFlags(from: stroke.modifiers) + guard let resolved = resolve(stroke) else { return } + let keyCode = resolved.code + let flags = modifierFlags(from: stroke.modifiers).union(resolved.extraFlags) // Key down if let eventDown = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: true) { @@ -259,11 +260,22 @@ final class ButtonMapper { } } - private func effectiveKeyCode(for stroke: KeyStroke) -> CGKeyCode? { + /// Key code for a stroke plus any flags the current layout needs to produce it. + /// Single printable characters are resolved against the active keyboard layout so a + /// mapping recorded as "x" types x on Dvorak too (issue #10); everything else uses the + /// recorded key code or the static table. + private func resolve(_ stroke: KeyStroke) -> (code: CGKeyCode, extraFlags: CGEventFlags)? { + if stroke.key.count == 1, let key = KeyboardLayout.shared.key(for: stroke.key) { + return (key.code, key.needsShift ? .maskShift : []) + } if let code = stroke.keyCode { - return CGKeyCode(code) + return (CGKeyCode(code), []) } - return KeyStroke.keyCode(for: stroke.key).map { CGKeyCode($0) } + return KeyStroke.keyCode(for: stroke.key).map { (CGKeyCode($0), []) } + } + + private func effectiveKeyCode(for stroke: KeyStroke) -> CGKeyCode? { + resolve(stroke)?.code } private func modifierFlags(from modifiers: [String]) -> CGEventFlags { diff --git a/Sources/NagaController/ButtonMapping/KeyboardLayout.swift b/Sources/NagaController/ButtonMapping/KeyboardLayout.swift new file mode 100644 index 0000000..431d39e --- /dev/null +++ b/Sources/NagaController/ButtonMapping/KeyboardLayout.swift @@ -0,0 +1,81 @@ +import Foundation +import Carbon.HIToolbox + +/// Resolves a printable character to the key that produces it on the *current* keyboard +/// layout. Mappings store the character the user recorded ("x"), not a physical key code, +/// because key codes are physical positions: code 7 types "x" on QWERTY and "q" on Dvorak. +/// Results are cached per input source and rebuilt when the user switches layouts. +final class KeyboardLayout { + static let shared = KeyboardLayout() + + struct Key: Equatable { + let code: CGKeyCode + let needsShift: Bool + } + + private var cache: [String: Key] = [:] + private var cachedSourceID: String? + + /// Key producing `character` (a single printable character, case-insensitive) on the + /// current layout, or nil if the layout cannot type it directly. + func key(for character: String) -> Key? { + let wanted = character.lowercased() + guard wanted.count == 1 else { return nil } + refreshIfNeeded() + return cache[wanted] + } + + private func refreshIfNeeded() { + guard let source = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue() else { return } + let sourceID: String = { + guard let ptr = TISGetInputSourceProperty(source, kTISPropertyInputSourceID) else { return "" } + return Unmanaged.fromOpaque(ptr).takeUnretainedValue() as String + }() + if sourceID == cachedSourceID, !cache.isEmpty { return } + + guard let layoutPtr = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else { return } + let data = Unmanaged.fromOpaque(layoutPtr).takeUnretainedValue() as Data + + var map: [String: Key] = [:] + let keyboardType = UInt32(LMGetKbdType()) + // Unshifted pass first so lowercase letters win; shifted pass then adds the + // characters only reachable with Shift (digits on AZERTY, symbols everywhere). + let passes: [(modifiers: UInt32, shift: Bool)] = [ + (0, false), + (UInt32((shiftKey >> 8) & 0xFF), true) + ] + data.withUnsafeBytes { raw in + guard let layout = raw.baseAddress?.assumingMemoryBound(to: UCKeyboardLayout.self) else { return } + for pass in passes { + for code in 0..<128 { + var deadKeyState: UInt32 = 0 + var length = 0 + var chars = [UniChar](repeating: 0, count: 4) + let status = UCKeyTranslate(layout, UInt16(code), UInt16(kUCKeyActionDown), pass.modifiers, + keyboardType, UInt32(kUCKeyTranslateNoDeadKeysBit), + &deadKeyState, chars.count, &length, &chars) + guard status == noErr, length == 1 else { continue } + let s = String(utf16CodeUnits: chars, count: 1) + guard let scalar = s.unicodeScalars.first, + KeyboardLayout.isPrintable(scalar) else { continue } + let lowered = s.lowercased() + if map[lowered] == nil { + map[lowered] = Key(code: CGKeyCode(code), needsShift: pass.shift) + } + } + } + } + cache = map + cachedSourceID = sourceID + } + + /// A character worth recording by value rather than by key code: letters, digits, + /// punctuation and symbols. Excludes controls, whitespace and the private-use range + /// AppKit uses for function/arrow keys (U+F700–U+F8FF). + static func isPrintable(_ scalar: UnicodeScalar) -> Bool { + if scalar.value >= 0xF700 && scalar.value <= 0xF8FF { return false } + let c = Character(scalar) + if c.isWhitespace || c.isNewline { return false } + return c.isLetter || c.isNumber || c.isPunctuation || c.isSymbol + } +} diff --git a/Tests/NagaControllerTests/KeyboardLayoutTests.swift b/Tests/NagaControllerTests/KeyboardLayoutTests.swift new file mode 100644 index 0000000..f3a41d9 --- /dev/null +++ b/Tests/NagaControllerTests/KeyboardLayoutTests.swift @@ -0,0 +1,32 @@ +import XCTest +import Carbon.HIToolbox +@testable import NagaController + +final class KeyboardLayoutTests: XCTestCase { + func testPrintableClassification() { + XCTAssertTrue(KeyboardLayout.isPrintable("x")) + XCTAssertTrue(KeyboardLayout.isPrintable("7")) + XCTAssertTrue(KeyboardLayout.isPrintable("-")) + XCTAssertTrue(KeyboardLayout.isPrintable("=")) + XCTAssertFalse(KeyboardLayout.isPrintable(" ")) + XCTAssertFalse(KeyboardLayout.isPrintable("\r")) + XCTAssertFalse(KeyboardLayout.isPrintable("\t")) + XCTAssertFalse(KeyboardLayout.isPrintable(UnicodeScalar(0xF702)!)) // NSLeftArrowFunctionKey + } + + func testCanonicalKeyStringPrefersCharacterForPrintableKeys() { + // Physical key code 7 is "x" on QWERTY; on Dvorak the same position types "q". + XCTAssertEqual(KeyStroke.canonicalKeyString(for: 7, characters: "q"), "q") + XCTAssertEqual(KeyStroke.canonicalKeyString(for: 7, characters: "x"), "x") + // Special keys still come from the table. + XCTAssertEqual(KeyStroke.canonicalKeyString(for: UInt16(kVK_Return), characters: "\r"), "return") + XCTAssertEqual(KeyStroke.canonicalKeyString(for: UInt16(kVK_LeftArrow), characters: "\u{F702}"), "left") + } + + func testCurrentLayoutResolvesLetters() throws { + // Layout-independent sanity: every layout can type "a" somewhere without Shift. + let key = try XCTUnwrap(KeyboardLayout.shared.key(for: "a")) + XCTAssertFalse(key.needsShift) + XCTAssertEqual(KeyboardLayout.shared.key(for: "A"), key) + } +}