From 40939aa56b85270b9203648da6e462d5ca0c3f63 Mon Sep 17 00:00:00 2001 From: DParent10 <127067984+DParent10@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:17:35 -0400 Subject: [PATCH 1/2] fix: record and send printable keys by character, resolved against the active layout Mappings stored a physical key code, and recording used a QWERTY table keyed by key code, so on Dvorak (or any non-QWERTY layout) both halves were wrong: pressing the key that types "x" was recorded as "b", and a mapping recorded as Cmd+X on QWERTY sent Cmd+Q. Fixes the layout half of #10. - KeyboardLayout resolves a printable character to the key (and whether Shift is needed) on the current input source via UCKeyTranslate, cached per source. - canonicalKeyString records printable keys by the character produced; special keys (Return, arrows, F-keys) still use the key-code table. - ButtonMapper resolves single-character keys through KeyboardLayout at send time and adds Shift when the layout requires it (digits on AZERTY), falling back to the recorded key code or the static table. Co-Authored-By: Claude Fable 5.1 --- .../ButtonMapping/ActionTypes.swift | 8 ++ .../ButtonMapping/ButtonMapper.swift | 28 +++++-- .../ButtonMapping/KeyboardLayout.swift | 81 +++++++++++++++++++ .../KeyboardLayoutTests.swift | 32 ++++++++ 4 files changed, 141 insertions(+), 8 deletions(-) create mode 100644 Sources/NagaController/ButtonMapping/KeyboardLayout.swift create mode 100644 Tests/NagaControllerTests/KeyboardLayoutTests.swift 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 810b4f2..8e4599a 100644 --- a/Sources/NagaController/ButtonMapping/ButtonMapper.swift +++ b/Sources/NagaController/ButtonMapping/ButtonMapper.swift @@ -131,8 +131,9 @@ final class ButtonMapper { return } - let flags = modifierFlags(from: stroke.modifiers) - if let code = keyCode, let eventDown = CGEvent(keyboardEventSource: keyboardSource, 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: keyboardSource, virtualKey: code, keyDown: true) { eventDown.flags = flags post(eventDown) activeHolds[buttonIndex] = (code, flags) @@ -266,9 +267,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: keyboardSource, virtualKey: keyCode, keyDown: true) { @@ -283,11 +284,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) + } +} From 3a38ee7715196dcc070e1fb7c7ff73ffba0a32f3 Mon Sep 17 00:00:00 2001 From: DParent10 <127067984+DParent10@users.noreply.github.com> Date: Thu, 17 Sep 2026 06:51:54 -0400 Subject: [PATCH 2/2] test: don't assume which alias the special-key table picks for arrows primaryKeyNames is built from a dictionary, so "left" vs "left arrow" is nondeterministic. Assert the identifier resolves back to the key code instead. Co-Authored-By: Claude Fable 5.1 --- Tests/NagaControllerTests/KeyboardLayoutTests.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Tests/NagaControllerTests/KeyboardLayoutTests.swift b/Tests/NagaControllerTests/KeyboardLayoutTests.swift index f3a41d9..5913b4d 100644 --- a/Tests/NagaControllerTests/KeyboardLayoutTests.swift +++ b/Tests/NagaControllerTests/KeyboardLayoutTests.swift @@ -18,9 +18,11 @@ final class KeyboardLayoutTests: XCTestCase { // 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. + // Special keys still come from the table. Several aliases share a code + // ("left" / "left arrow"), so check the identifier resolves back to the code. XCTAssertEqual(KeyStroke.canonicalKeyString(for: UInt16(kVK_Return), characters: "\r"), "return") - XCTAssertEqual(KeyStroke.canonicalKeyString(for: UInt16(kVK_LeftArrow), characters: "\u{F702}"), "left") + let arrow = KeyStroke.canonicalKeyString(for: UInt16(kVK_LeftArrow), characters: "\u{F702}") + XCTAssertEqual(KeyStroke.keyCode(for: arrow), UInt16(kVK_LeftArrow), arrow) } func testCurrentLayoutResolvesLetters() throws {