diff --git a/supacode/Features/Settings/BusinessLogic/ShortcutKeyTokenResolver.swift b/supacode/Features/Settings/BusinessLogic/ShortcutKeyTokenResolver.swift new file mode 100644 index 00000000..9f64991b --- /dev/null +++ b/supacode/Features/Settings/BusinessLogic/ShortcutKeyTokenResolver.swift @@ -0,0 +1,146 @@ +import Carbon +import Foundation + +@MainActor +struct ShortcutKeyTokenResolver { + struct KeyboardLayoutProvider { + let baseScalarForKeyCode: @MainActor @Sendable (UInt16) -> UnicodeScalar? + + @MainActor + static var live: KeyboardLayoutProvider { + KeyboardLayoutProvider { keyCode in + ShortcutKeyTokenResolver.baseScalarFromCurrentKeyboardLayout(for: keyCode) + } + } + } + + let keyboardLayoutProvider: KeyboardLayoutProvider + + init(keyboardLayoutProvider: KeyboardLayoutProvider = .live) { + self.keyboardLayoutProvider = keyboardLayoutProvider + } + + func resolveKeyToken( + keyCode: UInt16, + charactersIgnoringModifiers: String? + ) -> String? { + if let token = specialKeyToken(for: keyCode) { + return token + } + + if let token = physicalDigitToken(for: keyCode) { + return token + } + + if let scalar = keyboardLayoutProvider.baseScalarForKeyCode(keyCode), + let token = normalizedToken(from: scalar) + { + return token + } + + guard let fallbackScalar = charactersIgnoringModifiers?.trimmingCharacters(in: .whitespacesAndNewlines).first else { + return nil + } + + return String(fallbackScalar).lowercased() + } + + private func specialKeyToken(for keyCode: UInt16) -> String? { + switch keyCode { + case 36, 76: + return "return" + case 123: + return "arrow_left" + case 124: + return "arrow_right" + case 125: + return "arrow_down" + case 126: + return "arrow_up" + default: + return nil + } + } + + private func physicalDigitToken(for keyCode: UInt16) -> String? { + switch keyCode { + case 29, 82: + return "digit_0" + case 18, 83: + return "digit_1" + case 19, 84: + return "digit_2" + case 20, 85: + return "digit_3" + case 21, 86: + return "digit_4" + case 23, 87: + return "digit_5" + case 22, 88: + return "digit_6" + case 26, 89: + return "digit_7" + case 28, 91: + return "digit_8" + case 25, 92: + return "digit_9" + default: + return nil + } + } + + private func normalizedToken(from scalar: UnicodeScalar) -> String? { + let raw = String(scalar).trimmingCharacters(in: .whitespacesAndNewlines) + guard let token = raw.first else { return nil } + return String(token).lowercased() + } + + private static func baseScalarFromCurrentKeyboardLayout(for keyCode: UInt16) -> UnicodeScalar? { + guard let layoutData = currentKeyboardLayoutData(), + let bytes = CFDataGetBytePtr(layoutData) + else { + return nil + } + + let keyboardLayout = UnsafeRawPointer(bytes).assumingMemoryBound(to: UCKeyboardLayout.self) + var deadKeyState: UInt32 = 0 + let maxLength: Int = 4 + var actualLength: Int = 0 + var unicodeChars = [UniChar](repeating: 0, count: maxLength) + + let status = UCKeyTranslate( + keyboardLayout, + keyCode, + UInt16(kUCKeyActionDisplay), + 0, + UInt32(LMGetKbdType()), + OptionBits(kUCKeyTranslateNoDeadKeysBit), + &deadKeyState, + maxLength, + &actualLength, + &unicodeChars + ) + + guard status == noErr, actualLength > 0 else { + return nil + } + + return UnicodeScalar(unicodeChars[0]) + } + + private static func currentKeyboardLayoutData() -> CFData? { + if let source = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue(), + let rawLayoutData = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) + { + return unsafeBitCast(rawLayoutData, to: CFData.self) + } + + if let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue(), + let rawLayoutData = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) + { + return unsafeBitCast(rawLayoutData, to: CFData.self) + } + + return nil + } +} diff --git a/supacode/Features/Settings/Views/ShortcutsSettingsView.swift b/supacode/Features/Settings/Views/ShortcutsSettingsView.swift index c8dd65cd..91246245 100644 --- a/supacode/Features/Settings/Views/ShortcutsSettingsView.swift +++ b/supacode/Features/Settings/Views/ShortcutsSettingsView.swift @@ -24,6 +24,7 @@ struct ShortcutsSettingsView: View { @State private var pendingOverride: PendingOverride? @State private var focusedConflictCommandID: String? @State private var hoveredRecorderCommandID: String? + private let keyTokenResolver = ShortcutKeyTokenResolver() private var schema: KeybindingSchemaDocument { .appDefaultsV1 @@ -496,57 +497,10 @@ struct ShortcutsSettingsView: View { } private func keyToken(for event: NSEvent) -> String? { - switch event.keyCode { - case 36, 76: - return "return" - case 123: - return "arrow_left" - case 124: - return "arrow_right" - case 125: - return "arrow_down" - case 126: - return "arrow_up" - default: - break - } - - if let physicalDigit = physicalDigitToken(for: event.keyCode) { - return physicalDigit - } - - guard let scalar = event.charactersIgnoringModifiers?.trimmingCharacters(in: .whitespacesAndNewlines).first else { - return nil - } - - return String(scalar).lowercased() - } - - private func physicalDigitToken(for keyCode: UInt16) -> String? { - switch keyCode { - case 29, 82: - return "digit_0" - case 18, 83: - return "digit_1" - case 19, 84: - return "digit_2" - case 20, 85: - return "digit_3" - case 21, 86: - return "digit_4" - case 23, 87: - return "digit_5" - case 22, 88: - return "digit_6" - case 26, 89: - return "digit_7" - case 28, 91: - return "digit_8" - case 25, 92: - return "digit_9" - default: - return nil - } + keyTokenResolver.resolveKeyToken( + keyCode: event.keyCode, + charactersIgnoringModifiers: event.charactersIgnoringModifiers + ) } private func applyRecordedBinding(_ binding: Keybinding, to commandID: String) { diff --git a/supacodeTests/ShortcutKeyTokenResolverTests.swift b/supacodeTests/ShortcutKeyTokenResolverTests.swift new file mode 100644 index 00000000..362e0247 --- /dev/null +++ b/supacodeTests/ShortcutKeyTokenResolverTests.swift @@ -0,0 +1,68 @@ +import Testing + +@testable import supacode + +@MainActor +struct ShortcutKeyTokenResolverTests { + @Test func prefersLayoutBaseKeyOverShiftedCharacter() { + let resolver = ShortcutKeyTokenResolver( + keyboardLayoutProvider: .init(baseScalarForKeyCode: { _ in "[".unicodeScalars.first }) + ) + + let token = resolver.resolveKeyToken( + keyCode: 33, + charactersIgnoringModifiers: "{" + ) + + #expect(token == "[") + } + + @Test func fallsBackToCharactersIgnoringModifiersWhenLayoutUnavailable() { + let resolver = ShortcutKeyTokenResolver( + keyboardLayoutProvider: .init(baseScalarForKeyCode: { _ in nil }) + ) + + let token = resolver.resolveKeyToken( + keyCode: 33, + charactersIgnoringModifiers: "{" + ) + + #expect(token == "{") + } + + @Test func specialArrowAndReturnKeysUseStableTokens() { + let resolver = ShortcutKeyTokenResolver( + keyboardLayoutProvider: .init(baseScalarForKeyCode: { _ in "x".unicodeScalars.first }) + ) + + #expect(resolver.resolveKeyToken(keyCode: 36, charactersIgnoringModifiers: nil) == "return") + #expect(resolver.resolveKeyToken(keyCode: 123, charactersIgnoringModifiers: nil) == "arrow_left") + #expect(resolver.resolveKeyToken(keyCode: 124, charactersIgnoringModifiers: nil) == "arrow_right") + #expect(resolver.resolveKeyToken(keyCode: 125, charactersIgnoringModifiers: nil) == "arrow_down") + #expect(resolver.resolveKeyToken(keyCode: 126, charactersIgnoringModifiers: nil) == "arrow_up") + } + + @Test func numberRowAndNumpadUsePhysicalDigitTokens() { + let resolver = ShortcutKeyTokenResolver( + keyboardLayoutProvider: .init(baseScalarForKeyCode: { _ in nil }) + ) + + #expect(resolver.resolveKeyToken(keyCode: 18, charactersIgnoringModifiers: "!") == "digit_1") + #expect(resolver.resolveKeyToken(keyCode: 19, charactersIgnoringModifiers: "@") == "digit_2") + #expect(resolver.resolveKeyToken(keyCode: 82, charactersIgnoringModifiers: "0") == "digit_0") + #expect(resolver.resolveKeyToken(keyCode: 92, charactersIgnoringModifiers: "9") == "digit_9") + } + + @Test func lowercasesLetterTokens() { + let resolver = ShortcutKeyTokenResolver( + keyboardLayoutProvider: .init(baseScalarForKeyCode: { _ in "A".unicodeScalars.first }) + ) + + let token = resolver.resolveKeyToken( + keyCode: 0, + charactersIgnoringModifiers: "A" + ) + + #expect(token == "a") + } +}