Skip to content

Commit 9c53f8d

Browse files
mikkerampagent
andauthored
Fix keyboard layout regression for non-QWERTY layouts (#265)
Fixes #255 #264 - Restore support for AZERTY, Colemak, Dvorak and other keyboard layouts - The 'Force English keyboard layout' preference now works correctly - By default, respects user's active keyboard layout via event.charactersIgnoringModifiers - When force English is enabled, uses hardcoded QWERTY KeyMaps as before - Special keys (arrows, escape, etc.) always use KeyMaps regardless of setting - Maintains case sensitivity (r ≠ R) in both modes - Add comprehensive unit tests for keyboard layout handling - Add help text to preferences explaining the force English option This fixes a regression introduced in v1.17.0 where the new KeyMaps system always used QWERTY mapping, breaking non-QWERTY keyboard layouts. Amp-Thread-ID: https://ampcode.com/threads/T-2db41565-b3b2-4f93-80ce-9345163be879 Co-authored-by: Amp <amp@ampcode.com>
1 parent 9bcdf80 commit 9c53f8d

6 files changed

Lines changed: 187 additions & 19 deletions

File tree

Leader Key.xcodeproj/project.pbxproj

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
427C184D2BD65C5C00955B98 /* Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427C184C2BD65C5C00955B98 /* Defaults.swift */; };
3939
427C18502BD6652500955B98 /* Util.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427C184F2BD6652500955B98 /* Util.swift */; };
4040
427C18542BD6E59300955B98 /* NSWindow+Animations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 427C18532BD6E59300955B98 /* NSWindow+Animations.swift */; };
41+
4284834C2E813212009D7EEF /* KeyboardLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4284834B2E813212009D7EEF /* KeyboardLayoutTests.swift */; };
4142
42B21FBC2D67566100F4A2C7 /* Alerts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42B21FBB2D67566100F4A2C7 /* Alerts.swift */; };
4243
42CCB5A32DAD257700356FC0 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = FBCA04D82D9F02F700271163 /* Kingfisher */; };
4344
42DFCD722D5B7D48002EA111 /* Events.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42DFCD712D5B7D46002EA111 /* Events.swift */; };
@@ -94,6 +95,7 @@
9495
427C184C2BD65C5C00955B98 /* Defaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Defaults.swift; sourceTree = "<group>"; };
9596
427C184F2BD6652500955B98 /* Util.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Util.swift; sourceTree = "<group>"; };
9697
427C18532BD6E59300955B98 /* NSWindow+Animations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSWindow+Animations.swift"; sourceTree = "<group>"; };
98+
4284834B2E813212009D7EEF /* KeyboardLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardLayoutTests.swift; sourceTree = "<group>"; };
9799
42B21FBB2D67566100F4A2C7 /* Alerts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alerts.swift; sourceTree = "<group>"; };
98100
42DFCD712D5B7D46002EA111 /* Events.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Events.swift; sourceTree = "<group>"; };
99101
42F4CDC82D458FF700D0DD76 /* MainMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainMenu.swift; sourceTree = "<group>"; };
@@ -213,6 +215,7 @@
213215
427C17FB2BD311B500955B98 /* Leader KeyTests */ = {
214216
isa = PBXGroup;
215217
children = (
218+
4284834B2E813212009D7EEF /* KeyboardLayoutTests.swift */,
216219
42454DDC2D71CBAB004E1374 /* ConfigValidatorTests.swift */,
217220
427C17FC2BD311B500955B98 /* UserConfigTests.swift */,
218221
);
@@ -307,7 +310,7 @@
307310
attributes = {
308311
BuildIndependentTargetsInParallel = 1;
309312
LastSwiftUpdateCheck = 1530;
310-
LastUpgradeCheck = 1630;
313+
LastUpgradeCheck = 2600;
311314
TargetAttributes = {
312315
427C17E62BD311B400955B98 = {
313316
CreatedOnToolsVersion = 15.3;
@@ -434,6 +437,7 @@
434437
files = (
435438
42454DDD2D71CBAB004E1374 /* ConfigValidatorTests.swift in Sources */,
436439
427C17FD2BD311B500955B98 /* UserConfigTests.swift in Sources */,
440+
4284834C2E813212009D7EEF /* KeyboardLayoutTests.swift in Sources */,
437441
);
438442
runOnlyForDeploymentPostprocessing = 0;
439443
};
@@ -512,6 +516,7 @@
512516
MTL_FAST_MATH = YES;
513517
ONLY_ACTIVE_ARCH = YES;
514518
SDKROOT = macosx;
519+
STRING_CATALOG_GENERATE_SYMBOLS = YES;
515520
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
516521
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
517522
};
@@ -570,6 +575,7 @@
570575
MTL_ENABLE_DEBUG_INFO = NO;
571576
MTL_FAST_MATH = YES;
572577
SDKROOT = macosx;
578+
STRING_CATALOG_GENERATE_SYMBOLS = YES;
573579
SWIFT_COMPILATION_MODE = wholemodule;
574580
};
575581
name = Release;

Leader Key.xcodeproj/xcshareddata/xcschemes/Leader Key.xcscheme

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<?xml version="1.0" encoding="UTF-8"?>
22
<Scheme
3-
LastUpgradeVersion = "1630"
3+
LastUpgradeVersion = "2600"
44
version = "1.7">
55
<BuildAction
66
parallelizeBuildables = "YES"

Leader Key/Controller.swift

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -211,22 +211,37 @@ class Controller {
211211
}
212212
}
213213

214-
private func charForEvent(_ event: NSEvent) -> String? {
215-
let code = event.keyCode
216-
217-
// Check our centralized key map first
218-
if let entry = KeyMaps.entry(for: code) {
219-
// For letter keys, always respect shift modifier regardless of forceEnglishKeyboardLayout
220-
if entry.glyph.first?.isLetter == true && !entry.isReserved {
221-
let char = event.modifierFlags.contains(.shift) ? entry.glyph.uppercased() : entry.glyph
222-
return char
223-
}
224-
// For special keys (arrows, space, etc.), always return the glyph
225-
return entry.glyph
214+
internal func charForEvent(_ event: NSEvent) -> String? {
215+
let forceEnglish = Defaults[.forceEnglishKeyboardLayout]
216+
217+
// 1. If the user forces English, or if the key is non-printable,
218+
// fall back to the hard-coded map.
219+
if forceEnglish {
220+
return englishGlyph(for: event)
221+
}
222+
223+
// 2. Use the system-translated character first.
224+
if let printable = event.charactersIgnoringModifiers,
225+
!printable.isEmpty,
226+
printable.unicodeScalars.first?.isASCII ?? false
227+
{
228+
return printable // already contains correct case
226229
}
227230

228-
// Fallback to system characters for unmapped keys
229-
return event.charactersIgnoringModifiers
231+
// 3. For arrows, ␣, ⌫ … use map as last resort.
232+
return englishGlyph(for: event)
233+
}
234+
235+
private func englishGlyph(for event: NSEvent) -> String? {
236+
guard let entry = KeyMaps.entry(for: event.keyCode) else {
237+
return event.charactersIgnoringModifiers
238+
}
239+
if entry.glyph.first?.isLetter == true && !entry.isReserved {
240+
return event.modifierFlags.contains(.shift)
241+
? entry.glyph.uppercased()
242+
: entry.glyph
243+
}
244+
return entry.glyph
230245
}
231246

232247
private func positionCheatsheetWindow() {

Leader Key/Settings/AdvancedPane.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,16 @@ struct AdvancedPane: View {
146146
}
147147
Settings.Section(title: "Other") {
148148
Defaults.Toggle("Show Leader Key in menubar", key: .showMenuBarIcon)
149-
Defaults.Toggle(
150-
"Force English keyboard layout", key: .forceEnglishKeyboardLayout)
149+
VStack(alignment: .leading, spacing: 4) {
150+
Defaults.Toggle(
151+
"Force English keyboard layout", key: .forceEnglishKeyboardLayout)
152+
Text(
153+
"When enabled, letter keys are interpreted in US-English (QWERTY) regardless of your current keyboard layout."
154+
)
155+
.font(.caption)
156+
.foregroundColor(.secondary)
157+
.fixedSize(horizontal: false, vertical: true)
158+
}
151159
}
152160
}
153161
}

Leader Key/Views/Pulsate.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77

88
import Foundation
99
import SwiftUI
10-
import SwiftUICore
1110

1211
public struct Pulsate: ViewModifier {
1312
@State var scale: Bool = true
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import Combine
2+
import Defaults
3+
import XCTest
4+
5+
@testable import Leader_Key
6+
7+
class KeyboardLayoutTests: XCTestCase {
8+
var controller: Controller!
9+
var cancellables: Set<AnyCancellable>!
10+
var userState: UserState!
11+
var userConfig: UserConfig!
12+
13+
override func setUp() {
14+
super.setUp()
15+
cancellables = Set<AnyCancellable>()
16+
17+
// Create test instances
18+
userConfig = UserConfig()
19+
userState = UserState(userConfig: userConfig)
20+
controller = Controller(userState: userState, userConfig: userConfig)
21+
22+
// Reset to default state
23+
Defaults[.forceEnglishKeyboardLayout] = false
24+
}
25+
26+
override func tearDown() {
27+
cancellables = nil
28+
controller = nil
29+
userState = nil
30+
userConfig = nil
31+
super.tearDown()
32+
}
33+
34+
// Helper to create fake NSEvent for testing
35+
private func fakeEvent(
36+
keyCode: UInt16, characters: String, charactersIgnoringModifiers: String,
37+
modifierFlags: NSEvent.ModifierFlags = []
38+
) -> NSEvent {
39+
// This is a simplified mock - in real implementation we'd need to create a proper NSEvent
40+
// For now, we'll test the logic indirectly through Controller methods
41+
return NSEvent.keyEvent(
42+
with: .keyDown,
43+
location: NSPoint.zero,
44+
modifierFlags: modifierFlags,
45+
timestamp: 0,
46+
windowNumber: 0,
47+
context: nil,
48+
characters: characters,
49+
charactersIgnoringModifiers: charactersIgnoringModifiers,
50+
isARepeat: false,
51+
keyCode: keyCode
52+
)!
53+
}
54+
55+
func testAZERTYLayoutWithForceEnglishDisabled() {
56+
Defaults[.forceEnglishKeyboardLayout] = false
57+
58+
// Physical A key on AZERTY keyboard produces "q"
59+
let azertyAKey = fakeEvent(keyCode: 0x00, characters: "q", charactersIgnoringModifiers: "q")
60+
let result = controller.charForEvent(azertyAKey)
61+
62+
XCTAssertEqual(result, "q", "Should respect AZERTY layout and return 'q' for physical A key")
63+
}
64+
65+
func testAZERTYLayoutWithForceEnglishEnabled() {
66+
Defaults[.forceEnglishKeyboardLayout] = true
67+
68+
// Physical A key on AZERTY keyboard - should force to English "a"
69+
let azertyAKey = fakeEvent(keyCode: 0x00, characters: "q", charactersIgnoringModifiers: "q")
70+
let result = controller.charForEvent(azertyAKey)
71+
72+
XCTAssertEqual(result, "a", "Should force English layout and return 'a' for physical A key")
73+
}
74+
75+
func testColemakLayoutWithForceEnglishDisabled() {
76+
Defaults[.forceEnglishKeyboardLayout] = false
77+
78+
// Physical S key on Colemak produces "r"
79+
let colemakSKey = fakeEvent(keyCode: 0x01, characters: "r", charactersIgnoringModifiers: "r")
80+
let result = controller.charForEvent(colemakSKey)
81+
82+
XCTAssertEqual(result, "r", "Should respect Colemak layout and return 'r' for physical S key")
83+
}
84+
85+
func testColemakLayoutWithForceEnglishEnabled() {
86+
Defaults[.forceEnglishKeyboardLayout] = true
87+
88+
// Physical S key on Colemak - should force to English "s"
89+
let colemakSKey = fakeEvent(keyCode: 0x01, characters: "r", charactersIgnoringModifiers: "r")
90+
let result = controller.charForEvent(colemakSKey)
91+
92+
XCTAssertEqual(result, "s", "Should force English layout and return 's' for physical S key")
93+
}
94+
95+
func testCaseSensitivityWithLayout() {
96+
Defaults[.forceEnglishKeyboardLayout] = false
97+
98+
// Test lowercase
99+
let lowerR = fakeEvent(keyCode: 0x0F, characters: "r", charactersIgnoringModifiers: "r")
100+
let lowerResult = controller.charForEvent(lowerR)
101+
XCTAssertEqual(lowerResult, "r", "Should return lowercase 'r'")
102+
103+
// Test uppercase with shift
104+
let upperR = fakeEvent(
105+
keyCode: 0x0F, characters: "R", charactersIgnoringModifiers: "R", modifierFlags: .shift)
106+
let upperResult = controller.charForEvent(upperR)
107+
XCTAssertEqual(upperResult, "R", "Should return uppercase 'R' with shift")
108+
109+
XCTAssertNotEqual(lowerResult, upperResult, "Lowercase and uppercase should be different")
110+
}
111+
112+
func testCaseSensitivityWithForceEnglish() {
113+
Defaults[.forceEnglishKeyboardLayout] = true
114+
115+
// Test lowercase
116+
let lowerR = fakeEvent(keyCode: 0x0F, characters: "r", charactersIgnoringModifiers: "r")
117+
let lowerResult = controller.charForEvent(lowerR)
118+
XCTAssertEqual(lowerResult, "r", "Should return lowercase 'r' in force English mode")
119+
120+
// Test uppercase with shift
121+
let upperR = fakeEvent(
122+
keyCode: 0x0F, characters: "R", charactersIgnoringModifiers: "R", modifierFlags: .shift)
123+
let upperResult = controller.charForEvent(upperR)
124+
XCTAssertEqual(upperResult, "R", "Should return uppercase 'R' with shift in force English mode")
125+
126+
XCTAssertNotEqual(
127+
lowerResult, upperResult, "Lowercase and uppercase should be different in force English mode")
128+
}
129+
130+
func testSpecialKeysAlwaysUseKeyMaps() {
131+
Defaults[.forceEnglishKeyboardLayout] = false
132+
133+
// Arrow keys should always use KeyMaps regardless of layout setting
134+
let leftArrow = fakeEvent(keyCode: 0x7B, characters: "", charactersIgnoringModifiers: "")
135+
let result = controller.charForEvent(leftArrow)
136+
137+
// Should return the KeyMaps entry for left arrow
138+
XCTAssertEqual(result, "", "Special keys should always use KeyMaps")
139+
}
140+
}

0 commit comments

Comments
 (0)