Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Sources/NagaController/ButtonMapping/ActionTypes.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
28 changes: 20 additions & 8 deletions Sources/NagaController/ButtonMapping/ButtonMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down
81 changes: 81 additions & 0 deletions Sources/NagaController/ButtonMapping/KeyboardLayout.swift
Original file line number Diff line number Diff line change
@@ -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<CFString>.fromOpaque(ptr).takeUnretainedValue() as String
}()
if sourceID == cachedSourceID, !cache.isEmpty { return }

guard let layoutPtr = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else { return }
let data = Unmanaged<CFData>.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
}
}
32 changes: 32 additions & 0 deletions Tests/NagaControllerTests/KeyboardLayoutTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}