Skip to content

Commit 05c7ae7

Browse files
committed
Support special keyboards like Dvorak - QWERTY ⌘ keyboard
Resolved #70
1 parent 9ed4ca4 commit 05c7ae7

File tree

10 files changed

+224
-71
lines changed

10 files changed

+224
-71
lines changed

Lib/Sauce.xcodeproj/project.pbxproj

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
09CDC68A210F044A007DDFE4 /* TISInputSource+Property.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09CDC689210F044A007DDFE4 /* TISInputSource+Property.swift */; };
1313
50582477261C6F1F00AD2DD8 /* NSMenuItem+Key.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50582476261C6F1F00AD2DD8 /* NSMenuItem+Key.swift */; };
1414
5058247B261C6FA400AD2DD8 /* NSMenuItem+KeyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5058247A261C6FA400AD2DD8 /* NSMenuItem+KeyTests.swift */; };
15+
C5FADB892D32779F00F038E3 /* KeyModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5FADB882D32779B00F038E3 /* KeyModifier.swift */; };
1516
FA1E407621107B0A0016D710 /* SpecialKeyCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA1E407521107B0A0016D710 /* SpecialKeyCode.swift */; };
1617
FAA4132A210E2C730097D522 /* Sauce.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FAA41320210E2C730097D522 /* Sauce.framework */; };
1718
FAA41331210E2C730097D522 /* Sauce.h in Headers */ = {isa = PBXBuildFile; fileRef = FAA41323210E2C730097D522 /* Sauce.h */; settings = {ATTRIBUTES = (Public, ); }; };
@@ -37,6 +38,7 @@
3738
09CDC689210F044A007DDFE4 /* TISInputSource+Property.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TISInputSource+Property.swift"; sourceTree = "<group>"; };
3839
50582476261C6F1F00AD2DD8 /* NSMenuItem+Key.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSMenuItem+Key.swift"; sourceTree = "<group>"; };
3940
5058247A261C6FA400AD2DD8 /* NSMenuItem+KeyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSMenuItem+KeyTests.swift"; sourceTree = "<group>"; };
41+
C5FADB882D32779B00F038E3 /* KeyModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyModifier.swift; sourceTree = "<group>"; };
4042
FA1E407521107B0A0016D710 /* SpecialKeyCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecialKeyCode.swift; sourceTree = "<group>"; };
4143
FAA41320210E2C730097D522 /* Sauce.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Sauce.framework; sourceTree = BUILT_PRODUCTS_DIR; };
4244
FAA41323210E2C730097D522 /* Sauce.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Sauce.h; sourceTree = "<group>"; };
@@ -68,6 +70,17 @@
6870
/* End PBXFrameworksBuildPhase section */
6971

7072
/* Begin PBXGroup section */
73+
C5FADB8C2D335E1300F038E3 /* Internal */ = {
74+
isa = PBXGroup;
75+
children = (
76+
FAA41368210E33060097D522 /* KeyboardLayout.swift */,
77+
C5FADB882D32779B00F038E3 /* KeyModifier.swift */,
78+
FA1E407521107B0A0016D710 /* SpecialKeyCode.swift */,
79+
09CDC689210F044A007DDFE4 /* TISInputSource+Property.swift */,
80+
);
81+
path = Internal;
82+
sourceTree = "<group>";
83+
};
7184
FAA41316210E2C730097D522 = {
7285
isa = PBXGroup;
7386
children = (
@@ -89,14 +102,12 @@
89102
FAA41322210E2C730097D522 /* Sauce */ = {
90103
isa = PBXGroup;
91104
children = (
105+
C5FADB8C2D335E1300F038E3 /* Internal */,
92106
FAA41323210E2C730097D522 /* Sauce.h */,
93107
FAA41324210E2C730097D522 /* Info.plist */,
94108
FAA41364210E2D9B0097D522 /* Sauce.swift */,
95109
FAA4136A210E33CB0097D522 /* InputSource.swift */,
96-
FAA41368210E33060097D522 /* KeyboardLayout.swift */,
97-
09CDC689210F044A007DDFE4 /* TISInputSource+Property.swift */,
98110
FAA41366210E2EFB0097D522 /* Key.swift */,
99-
FA1E407521107B0A0016D710 /* SpecialKeyCode.swift */,
100111
095EF0002456ED9A00174829 /* ModifierTransformer.swift */,
101112
50582476261C6F1F00AD2DD8 /* NSMenuItem+Key.swift */,
102113
);
@@ -230,6 +241,7 @@
230241
FAA41365210E2D9B0097D522 /* Sauce.swift in Sources */,
231242
FA1E407621107B0A0016D710 /* SpecialKeyCode.swift in Sources */,
232243
FAA41367210E2EFB0097D522 /* Key.swift in Sources */,
244+
C5FADB892D32779F00F038E3 /* KeyModifier.swift in Sources */,
233245
095EF0012456ED9A00174829 /* ModifierTransformer.swift in Sources */,
234246
);
235247
runOnlyForDeploymentPostprocessing = 0;

Lib/Sauce/InputSource.swift

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,10 @@
99
//
1010

1111
#if os(macOS)
12-
import Foundation
1312
import Carbon
13+
import Foundation
1414

1515
open class InputSource {
16-
1716
// MARK: - Properties
1817
public let id: String
1918
public let modeID: String?
@@ -39,7 +38,6 @@ open class InputSource {
3938
self.localizedName = source.value(forProperty: kTISPropertyLocalizedName, type: String.self)
4039
self.source = source
4140
}
42-
4341
}
4442

4543
// MARK: - Hashable

Lib/Sauce/Internal/KeyModifier.swift

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
//
2+
// KeyModifier.swift
3+
//
4+
// Sauce
5+
// GitHub: https://github.com/clipy
6+
// HP: https://clipy-app.com
7+
//
8+
// Copyright © 2015-2025 Clipy Project.
9+
//
10+
11+
import Carbon
12+
13+
internal enum KeyModifier: CaseIterable {
14+
case none
15+
/// State when the ⌘ key is pressed
16+
/// Supports keyboard that change key layout when ⌘ is pressd, such as `Dvorak - QWERTY ⌘`
17+
case withCommand
18+
19+
// MARK: - Initialize
20+
init(carbonModifiers: Int) {
21+
if (carbonModifiers & cmdKey) != 0 {
22+
self = .withCommand
23+
} else {
24+
self = .none
25+
}
26+
}
27+
28+
// MARK: - Properties
29+
var carbonModifier: Int {
30+
switch self {
31+
case .none:
32+
return 0
33+
case .withCommand:
34+
return cmdKey
35+
}
36+
}
37+
}

Lib/Sauce/KeyboardLayout.swift renamed to Lib/Sauce/Internal/KeyboardLayout.swift

Lines changed: 58 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,14 @@
99
//
1010

1111
#if os(macOS)
12-
import Foundation
1312
import Carbon
13+
import Foundation
1414

15-
final class KeyboardLayout {
16-
15+
internal final class KeyboardLayout {
1716
// MARK: - Properties
1817
private var currentKeyboardLayoutInputSource: InputSource
1918
private var currentASCIICapableInputSource: InputSource
20-
private var mappedKeyCodes = [InputSource: [Key: CGKeyCode]]()
19+
private var mappedKeyCodes = [InputSource: [KeyModifier: [Key: CGKeyCode]]]()
2120
private(set) var inputSources = [InputSource]()
2221

2322
private let distributedNotificationCenter: DistributedNotificationCenter
@@ -40,41 +39,40 @@ final class KeyboardLayout {
4039
distributedNotificationCenter.removeObserver(self)
4140
notificationCenter.removeObserver(self)
4241
}
43-
4442
}
4543

4644
// MARK: - KeyCodes
47-
extension KeyboardLayout {
48-
func currentKeyCodes() -> [Key: CGKeyCode]? {
49-
return keyCodes(with: currentKeyboardLayoutInputSource)
45+
internal extension KeyboardLayout {
46+
func currentKeyCodes(carbonModifiers: Int) -> [Key: CGKeyCode]? {
47+
return keyCodes(with: currentKeyboardLayoutInputSource, carbonModifiers: carbonModifiers)
5048
}
5149

52-
func currentKeyCode(for key: Key) -> CGKeyCode? {
53-
return keyCode(with: currentKeyboardLayoutInputSource, key: key)
50+
func currentKeyCode(for key: Key, carbonModifiers: Int) -> CGKeyCode? {
51+
return keyCode(with: currentKeyboardLayoutInputSource, key: key, carbonModifiers: carbonModifiers)
5452
}
5553

56-
func keyCodes(with source: InputSource) -> [Key: CGKeyCode]? {
57-
return mappedKeyCodes[source]
54+
func keyCodes(with source: InputSource, carbonModifiers: Int) -> [Key: CGKeyCode]? {
55+
return mappedKeyCodes[source]?[.init(carbonModifiers: carbonModifiers)]
5856
}
5957

60-
func keyCode(with source: InputSource, key: Key) -> CGKeyCode? {
61-
return mappedKeyCodes[source]?[key]
58+
func keyCode(with source: InputSource, key: Key, carbonModifiers: Int) -> CGKeyCode? {
59+
return mappedKeyCodes[source]?[.init(carbonModifiers: carbonModifiers)]?[key]
6260
}
6361
}
6462

6563
// MARK: - Key
66-
extension KeyboardLayout {
67-
func currentKey(for keyCode: Int) -> Key? {
68-
return key(with: currentKeyboardLayoutInputSource, keyCode: keyCode)
64+
internal extension KeyboardLayout {
65+
func currentKey(for keyCode: Int, carbonModifiers: Int) -> Key? {
66+
return key(with: currentKeyboardLayoutInputSource, keyCode: keyCode, carbonModifiers: carbonModifiers)
6967
}
7068

71-
func key(with source: InputSource, keyCode: Int) -> Key? {
72-
return mappedKeyCodes[source]?.first(where: { $0.value == CGKeyCode(keyCode) })?.key
69+
func key(with source: InputSource, keyCode: Int, carbonModifiers: Int) -> Key? {
70+
return mappedKeyCodes[source]?[.init(carbonModifiers: carbonModifiers)]?.first(where: { $0.value == CGKeyCode(keyCode) })?.key
7371
}
7472
}
7573

7674
// MARK: - Characters
77-
extension KeyboardLayout {
75+
internal extension KeyboardLayout {
7876
func currentCharacter(for keyCode: Int, carbonModifiers: Int) -> String? {
7977
return character(with: currentKeyboardLayoutInputSource, keyCode: keyCode, carbonModifiers: carbonModifiers)
8078
}
@@ -89,7 +87,7 @@ extension KeyboardLayout {
8987
}
9088

9189
// MARK: - Notifications
92-
extension KeyboardLayout {
90+
internal extension KeyboardLayout {
9391
private func observeNotifications() {
9492
distributedNotificationCenter.addObserver(self,
9593
selector: #selector(selectedKeyboardInputSourceChanged),
@@ -145,27 +143,57 @@ private extension KeyboardLayout {
145143
func mappingKeyCodes(with source: InputSource) {
146144
guard let layoutData = TISGetInputSourceProperty(source.source, kTISPropertyUnicodeKeyLayoutData) else { return }
147145
let data = Unmanaged<CFData>.fromOpaque(layoutData).takeUnretainedValue() as Data
148-
var keyCodes = [Key: CGKeyCode]()
149-
for i in 0..<128 {
150-
guard let character = character(with: data, keyCode: i, carbonModifiers: 0) else { continue }
151-
guard let key = Key(character: character, virtualKeyCode: i) else { continue }
152-
guard keyCodes[key] == nil else { continue }
153-
keyCodes[key] = CGKeyCode(i)
146+
var codes = [KeyModifier: [Key: CGKeyCode]]()
147+
KeyModifier.allCases.forEach { keyModifier in
148+
var keyCodes = [Key: CGKeyCode]()
149+
for i in 0..<128 {
150+
guard let character = character(with: data, keyCode: i, carbonModifiers: keyModifier.carbonModifier) else { continue }
151+
guard let key = Key(character: character, virtualKeyCode: i) else { continue }
152+
guard keyCodes[key] == nil else { continue }
153+
keyCodes[key] = CGKeyCode(i)
154+
}
155+
codes[keyModifier] = keyCodes
154156
}
155-
mappedKeyCodes[source] = keyCodes
157+
mappedKeyCodes[source] = codes
156158
}
157159

158160
func character(with source: TISInputSource, keyCode: Int, carbonModifiers: Int) -> String? {
159161
guard let layoutData = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else { return nil }
160162
let data = Unmanaged<CFData>.fromOpaque(layoutData).takeUnretainedValue() as Data
161-
return character(with: data, keyCode: keyCode, carbonModifiers: carbonModifiers)
163+
let keyModifier = KeyModifier(carbonModifiers: carbonModifiers)
164+
var carbonModifiers = modifierTransformer.convertCharactorSupportCarbonModifiers(from: carbonModifiers)
165+
switch keyModifier {
166+
case .none:
167+
return character(with: data, keyCode: keyCode, carbonModifiers: carbonModifiers)
168+
case .withCommand:
169+
/// Determines if it's a special keyboard environment by comparing the string output with and without the ⌘ key pressed
170+
/// For example, with a `Dvorak - QWERTY ⌘` keyboard, entering keycode `47` returns different characters depending on whether the ⌘ key pressed or not
171+
/// ⌘ not pressed: `v`
172+
/// ⌘ pressed: `.` (same as entering keycode `47` on a QWERTY keyboard)
173+
let noCommandCharacter = character(with: data, keyCode: keyCode, carbonModifiers: 0)
174+
let commandCharacter = character(with: data, keyCode: keyCode, carbonModifiers: cmdKey)
175+
guard noCommandCharacter != commandCharacter else {
176+
/// If the outputs are the same, it's a regular keyboard, so return the string excluding the ⌘ key
177+
return character(with: data, keyCode: keyCode, carbonModifiers: carbonModifiers)
178+
}
179+
/// Workaround: To get a string with modifiers other than ⌘ key working, obtain the keycode for the standard key layout and generate the string
180+
guard let commandCharacter,
181+
let key = Key(character: commandCharacter, virtualKeyCode: keyCode),
182+
let keyCode = mappedKeyCodes[.init(source: source)]?[.none]?.first(where: { $0.key == key })?.value
183+
else {
184+
/// If mapping is not possible, ignore modifiers other than ⌘ and return a value as close as possible to the key input
185+
carbonModifiers |= cmdKey
186+
return character(with: data, keyCode: keyCode, carbonModifiers: carbonModifiers)
187+
}
188+
return character(with: data, keyCode: Int(keyCode), carbonModifiers: carbonModifiers)
189+
}
162190
}
163191

164192
func character(with layoutData: Data, keyCode: Int, carbonModifiers: Int) -> String? {
165193
// In the case of the special key code, it does not depend on the keyboard layout
166194
if let specialKeyCode = SpecialKeyCode(keyCode: keyCode) { return specialKeyCode.character }
167195

168-
let modifierKeyState = (modifierTransformer.convertCharactorSupportCarbonModifiers(from: carbonModifiers) >> 8) & 0xff
196+
let modifierKeyState = (carbonModifiers >> 8) & 0xff
169197
var deadKeyState: UInt32 = 0
170198
let maxChars = 256
171199
var chars = [UniChar](repeating: 0, count: maxChars)

Lib/Sauce/SpecialKeyCode.swift renamed to Lib/Sauce/Internal/SpecialKeyCode.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
//
1010

1111
#if os(macOS)
12-
import Foundation
1312
import Carbon
13+
import Foundation
1414

1515
// swiftlint:disable identifier_name function_body_length
1616

@@ -20,7 +20,7 @@ import Carbon
2020
*
2121
* UCKeyTranslate can not convert a layout-independent keycode to string.
2222
**/
23-
enum SpecialKeyCode {
23+
internal enum SpecialKeyCode {
2424
case `return`
2525
case tab
2626
case space

Lib/Sauce/TISInputSource+Property.swift renamed to Lib/Sauce/Internal/TISInputSource+Property.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
//
1010

1111
#if os(macOS)
12-
import Foundation
1312
import Carbon
13+
import Foundation
1414

1515
extension TISInputSource {
1616
func value<T>(forProperty propertyKey: CFString, type: T.Type) -> T? {

Lib/Sauce/Key.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
//
1010

1111
#if os(macOS)
12-
import Foundation
1312
import Carbon
13+
import Foundation
1414

1515
// swiftlint:disable file_length function_body_length type_body_length identifier_name
1616
public enum Key: String, Codable, Equatable, Sendable {
@@ -560,6 +560,5 @@ public enum Key: String, Codable, Equatable, Sendable {
560560
case .section: return CGKeyCode(kVK_ISO_Section)
561561
}
562562
}
563-
564563
}
565564
#endif

Lib/Sauce/ModifierTransformer.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99
//
1010

1111
#if os(macOS)
12-
import Foundation
13-
import Carbon
1412
import AppKit
13+
import Carbon
14+
import Foundation
1515

1616
open class ModifierTransformer {}
1717

0 commit comments

Comments
 (0)