Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support special keyboards like Dvorak - QWERTY ⌘ keyboard #71

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions .github/workflows/Xcode-Build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ jobs:
-scheme "$SCHEME" \
-sdk "$SDK" \
-configuration Debug \
-parallel-testing-enabled NO \
ENABLE_TESTABILITY=YES | xcpretty -c;
env:
PROJECT: Sauce.xcworkspace
Expand Down
18 changes: 15 additions & 3 deletions Lib/Sauce.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
09CDC68A210F044A007DDFE4 /* TISInputSource+Property.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09CDC689210F044A007DDFE4 /* TISInputSource+Property.swift */; };
50582477261C6F1F00AD2DD8 /* NSMenuItem+Key.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50582476261C6F1F00AD2DD8 /* NSMenuItem+Key.swift */; };
5058247B261C6FA400AD2DD8 /* NSMenuItem+KeyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5058247A261C6FA400AD2DD8 /* NSMenuItem+KeyTests.swift */; };
C5FADB892D32779F00F038E3 /* KeyModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5FADB882D32779B00F038E3 /* KeyModifier.swift */; };
FA1E407621107B0A0016D710 /* SpecialKeyCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA1E407521107B0A0016D710 /* SpecialKeyCode.swift */; };
FAA4132A210E2C730097D522 /* Sauce.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FAA41320210E2C730097D522 /* Sauce.framework */; };
FAA41331210E2C730097D522 /* Sauce.h in Headers */ = {isa = PBXBuildFile; fileRef = FAA41323210E2C730097D522 /* Sauce.h */; settings = {ATTRIBUTES = (Public, ); }; };
Expand All @@ -37,6 +38,7 @@
09CDC689210F044A007DDFE4 /* TISInputSource+Property.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TISInputSource+Property.swift"; sourceTree = "<group>"; };
50582476261C6F1F00AD2DD8 /* NSMenuItem+Key.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSMenuItem+Key.swift"; sourceTree = "<group>"; };
5058247A261C6FA400AD2DD8 /* NSMenuItem+KeyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSMenuItem+KeyTests.swift"; sourceTree = "<group>"; };
C5FADB882D32779B00F038E3 /* KeyModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyModifier.swift; sourceTree = "<group>"; };
FA1E407521107B0A0016D710 /* SpecialKeyCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecialKeyCode.swift; sourceTree = "<group>"; };
FAA41320210E2C730097D522 /* Sauce.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Sauce.framework; sourceTree = BUILT_PRODUCTS_DIR; };
FAA41323210E2C730097D522 /* Sauce.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Sauce.h; sourceTree = "<group>"; };
Expand Down Expand Up @@ -68,6 +70,17 @@
/* End PBXFrameworksBuildPhase section */

/* Begin PBXGroup section */
C5FADB8C2D335E1300F038E3 /* Internal */ = {
isa = PBXGroup;
children = (
FAA41368210E33060097D522 /* KeyboardLayout.swift */,
C5FADB882D32779B00F038E3 /* KeyModifier.swift */,
FA1E407521107B0A0016D710 /* SpecialKeyCode.swift */,
09CDC689210F044A007DDFE4 /* TISInputSource+Property.swift */,
);
path = Internal;
sourceTree = "<group>";
};
FAA41316210E2C730097D522 = {
isa = PBXGroup;
children = (
Expand All @@ -89,14 +102,12 @@
FAA41322210E2C730097D522 /* Sauce */ = {
isa = PBXGroup;
children = (
C5FADB8C2D335E1300F038E3 /* Internal */,
FAA41323210E2C730097D522 /* Sauce.h */,
FAA41324210E2C730097D522 /* Info.plist */,
FAA41364210E2D9B0097D522 /* Sauce.swift */,
FAA4136A210E33CB0097D522 /* InputSource.swift */,
FAA41368210E33060097D522 /* KeyboardLayout.swift */,
09CDC689210F044A007DDFE4 /* TISInputSource+Property.swift */,
FAA41366210E2EFB0097D522 /* Key.swift */,
FA1E407521107B0A0016D710 /* SpecialKeyCode.swift */,
095EF0002456ED9A00174829 /* ModifierTransformer.swift */,
50582476261C6F1F00AD2DD8 /* NSMenuItem+Key.swift */,
);
Expand Down Expand Up @@ -230,6 +241,7 @@
FAA41365210E2D9B0097D522 /* Sauce.swift in Sources */,
FA1E407621107B0A0016D710 /* SpecialKeyCode.swift in Sources */,
FAA41367210E2EFB0097D522 /* Key.swift in Sources */,
C5FADB892D32779F00F038E3 /* KeyModifier.swift in Sources */,
095EF0012456ED9A00174829 /* ModifierTransformer.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
3 changes: 2 additions & 1 deletion Lib/Sauce.xcodeproj/xcshareddata/xcschemes/Sauce.xcscheme
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO">
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FAA41328210E2C730097D522"
Expand Down
4 changes: 1 addition & 3 deletions Lib/Sauce/InputSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,10 @@
//

#if os(macOS)
import Foundation
import Carbon
import Foundation

open class InputSource {

// MARK: - Properties
public let id: String
public let modeID: String?
Expand All @@ -39,7 +38,6 @@ open class InputSource {
self.localizedName = source.value(forProperty: kTISPropertyLocalizedName, type: String.self)
self.source = source
}

}

// MARK: - Hashable
Expand Down
37 changes: 37 additions & 0 deletions Lib/Sauce/Internal/KeyModifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//
// KeyModifier.swift
//
// Sauce
// GitHub: https://github.com/clipy
// HP: https://clipy-app.com
//
// Copyright © 2015-2025 Clipy Project.
//

import Carbon

internal enum KeyModifier: CaseIterable {
case none
/// State when the ⌘ key is pressed
/// Supports keyboard that change key layout when ⌘ is pressd, such as `Dvorak - QWERTY ⌘`
case withCommand

// MARK: - Initialize
init(carbonModifiers: Int) {
if (carbonModifiers & cmdKey) != 0 {
self = .withCommand
} else {
self = .none
}
}

// MARK: - Properties
var carbonModifier: Int {
switch self {
case .none:
return 0
case .withCommand:
return cmdKey
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,14 @@
//

#if os(macOS)
import Foundation
import Carbon
import Foundation

final class KeyboardLayout {

internal final class KeyboardLayout {
// MARK: - Properties
private var currentKeyboardLayoutInputSource: InputSource
private var currentASCIICapableInputSource: InputSource
private var mappedKeyCodes = [InputSource: [Key: CGKeyCode]]()
private var mappedKeyCodes = [InputSource: [KeyModifier: [Key: CGKeyCode]]]()
private(set) var inputSources = [InputSource]()

private let distributedNotificationCenter: DistributedNotificationCenter
Expand All @@ -40,41 +39,40 @@ final class KeyboardLayout {
distributedNotificationCenter.removeObserver(self)
notificationCenter.removeObserver(self)
}

}

// MARK: - KeyCodes
extension KeyboardLayout {
func currentKeyCodes() -> [Key: CGKeyCode]? {
return keyCodes(with: currentKeyboardLayoutInputSource)
internal extension KeyboardLayout {
func currentKeyCodes(carbonModifiers: Int) -> [Key: CGKeyCode]? {
return keyCodes(with: currentKeyboardLayoutInputSource, carbonModifiers: carbonModifiers)
}

func currentKeyCode(for key: Key) -> CGKeyCode? {
return keyCode(with: currentKeyboardLayoutInputSource, key: key)
func currentKeyCode(for key: Key, carbonModifiers: Int) -> CGKeyCode? {
return keyCode(with: currentKeyboardLayoutInputSource, key: key, carbonModifiers: carbonModifiers)
}

func keyCodes(with source: InputSource) -> [Key: CGKeyCode]? {
return mappedKeyCodes[source]
func keyCodes(with source: InputSource, carbonModifiers: Int) -> [Key: CGKeyCode]? {
return mappedKeyCodes[source]?[.init(carbonModifiers: carbonModifiers)]
}

func keyCode(with source: InputSource, key: Key) -> CGKeyCode? {
return mappedKeyCodes[source]?[key]
func keyCode(with source: InputSource, key: Key, carbonModifiers: Int) -> CGKeyCode? {
return mappedKeyCodes[source]?[.init(carbonModifiers: carbonModifiers)]?[key]
}
}

// MARK: - Key
extension KeyboardLayout {
func currentKey(for keyCode: Int) -> Key? {
return key(with: currentKeyboardLayoutInputSource, keyCode: keyCode)
internal extension KeyboardLayout {
func currentKey(for keyCode: Int, carbonModifiers: Int) -> Key? {
return key(with: currentKeyboardLayoutInputSource, keyCode: keyCode, carbonModifiers: carbonModifiers)
}

func key(with source: InputSource, keyCode: Int) -> Key? {
return mappedKeyCodes[source]?.first(where: { $0.value == CGKeyCode(keyCode) })?.key
func key(with source: InputSource, keyCode: Int, carbonModifiers: Int) -> Key? {
return mappedKeyCodes[source]?[.init(carbonModifiers: carbonModifiers)]?.first(where: { $0.value == CGKeyCode(keyCode) })?.key
}
}

// MARK: - Characters
extension KeyboardLayout {
internal extension KeyboardLayout {
func currentCharacter(for keyCode: Int, carbonModifiers: Int) -> String? {
return character(with: currentKeyboardLayoutInputSource, keyCode: keyCode, carbonModifiers: carbonModifiers)
}
Expand All @@ -89,7 +87,7 @@ extension KeyboardLayout {
}

// MARK: - Notifications
extension KeyboardLayout {
internal extension KeyboardLayout {
private func observeNotifications() {
distributedNotificationCenter.addObserver(self,
selector: #selector(selectedKeyboardInputSourceChanged),
Expand Down Expand Up @@ -145,27 +143,57 @@ private extension KeyboardLayout {
func mappingKeyCodes(with source: InputSource) {
guard let layoutData = TISGetInputSourceProperty(source.source, kTISPropertyUnicodeKeyLayoutData) else { return }
let data = Unmanaged<CFData>.fromOpaque(layoutData).takeUnretainedValue() as Data
var keyCodes = [Key: CGKeyCode]()
for i in 0..<128 {
guard let character = character(with: data, keyCode: i, carbonModifiers: 0) else { continue }
guard let key = Key(character: character, virtualKeyCode: i) else { continue }
guard keyCodes[key] == nil else { continue }
keyCodes[key] = CGKeyCode(i)
var codes = [KeyModifier: [Key: CGKeyCode]]()
KeyModifier.allCases.forEach { keyModifier in
var keyCodes = [Key: CGKeyCode]()
for i in 0..<128 {
guard let character = character(with: data, keyCode: i, carbonModifiers: keyModifier.carbonModifier) else { continue }
guard let key = Key(character: character, virtualKeyCode: i) else { continue }
guard keyCodes[key] == nil else { continue }
keyCodes[key] = CGKeyCode(i)
}
codes[keyModifier] = keyCodes
}
mappedKeyCodes[source] = keyCodes
mappedKeyCodes[source] = codes
}

func character(with source: TISInputSource, keyCode: Int, carbonModifiers: Int) -> String? {
guard let layoutData = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData) else { return nil }
let data = Unmanaged<CFData>.fromOpaque(layoutData).takeUnretainedValue() as Data
return character(with: data, keyCode: keyCode, carbonModifiers: carbonModifiers)
let keyModifier = KeyModifier(carbonModifiers: carbonModifiers)
var carbonModifiers = modifierTransformer.convertCharactorSupportCarbonModifiers(from: carbonModifiers)
switch keyModifier {
case .none:
return character(with: data, keyCode: keyCode, carbonModifiers: carbonModifiers)
case .withCommand:
/// Determines if it's a special keyboard environment by comparing the string output with and without the ⌘ key pressed
/// For example, with a `Dvorak - QWERTY ⌘` keyboard, entering keycode `47` returns different characters depending on whether the ⌘ key pressed or not
/// ⌘ not pressed: `v`
/// ⌘ pressed: `.` (same as entering keycode `47` on a QWERTY keyboard)
let noCommandCharacter = character(with: data, keyCode: keyCode, carbonModifiers: 0)
let commandCharacter = character(with: data, keyCode: keyCode, carbonModifiers: cmdKey)
guard noCommandCharacter != commandCharacter else {
/// If the outputs are the same, it's a regular keyboard, so return the string excluding the ⌘ key
return character(with: data, keyCode: keyCode, carbonModifiers: carbonModifiers)
}
/// Workaround: To get a string with modifiers other than ⌘ key working, obtain the keycode for the standard key layout and generate the string
guard let commandCharacter,
let key = Key(character: commandCharacter, virtualKeyCode: keyCode),
let keyCode = mappedKeyCodes[.init(source: source)]?[.none]?.first(where: { $0.key == key })?.value
else {
/// If mapping is not possible, ignore modifiers other than ⌘ and return a value as close as possible to the key input
carbonModifiers |= cmdKey
return character(with: data, keyCode: keyCode, carbonModifiers: carbonModifiers)
}
return character(with: data, keyCode: Int(keyCode), carbonModifiers: carbonModifiers)
}
}

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

let modifierKeyState = (modifierTransformer.convertCharactorSupportCarbonModifiers(from: carbonModifiers) >> 8) & 0xff
let modifierKeyState = (carbonModifiers >> 8) & 0xff
var deadKeyState: UInt32 = 0
let maxChars = 256
var chars = [UniChar](repeating: 0, count: maxChars)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
//

#if os(macOS)
import Foundation
import Carbon
import Foundation

// swiftlint:disable identifier_name function_body_length

Expand All @@ -20,7 +20,7 @@ import Carbon
*
* UCKeyTranslate can not convert a layout-independent keycode to string.
**/
enum SpecialKeyCode {
internal enum SpecialKeyCode {
case `return`
case tab
case space
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
//

#if os(macOS)
import Foundation
import Carbon
import Foundation

extension TISInputSource {
func value<T>(forProperty propertyKey: CFString, type: T.Type) -> T? {
Expand Down
3 changes: 1 addition & 2 deletions Lib/Sauce/Key.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
//

#if os(macOS)
import Foundation
import Carbon
import Foundation

// swiftlint:disable file_length function_body_length type_body_length identifier_name
public enum Key: String, Codable, Equatable, Sendable {
Expand Down Expand Up @@ -560,6 +560,5 @@ public enum Key: String, Codable, Equatable, Sendable {
case .section: return CGKeyCode(kVK_ISO_Section)
}
}

}
#endif
4 changes: 2 additions & 2 deletions Lib/Sauce/ModifierTransformer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
//

#if os(macOS)
import Foundation
import Carbon
import AppKit
import Carbon
import Foundation

open class ModifierTransformer {}

Expand Down
Loading
Loading