Skip to content

Commit e3ec680

Browse files
BohdanBohdan
authored andcommitted
Added CapsLock tests. Added FEATURE_RECALCULATE_FRAMES. Added SliderTickLabels view
1 parent 402b292 commit e3ec680

16 files changed

+835
-230
lines changed

LanguageFlag/Application/AppDelegate.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
1616
private var statusBarManager: StatusBarManager?
1717
private let screenManager: ScreenManager
1818
private let notificationManager: NotificationManager
19+
private let capsLockManager: CapsLockManager
1920

2021
// MARK: - Initialization
2122
override init() {
2223
self.screenManager = ScreenManager()
2324
self.notificationManager = NotificationManager()
25+
self.capsLockManager = CapsLockManager()
2426

2527
super.init()
2628
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
// QAT Configuration - All features enabled for testing
22

3-
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) FEATURE_SHORTCUTS FEATURE_ANALYTICS FEATURE_GROUPS
3+
SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) FEATURE_SHORTCUTS FEATURE_ANALYTICS FEATURE_GROUPS FEATURE_RECALCULATE_FRAMES

LanguageFlag/Extensions/TISInputSource+GetProperty.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ extension TISInputSource {
1414
guard let cfType = TISGetInputSourceProperty(self, key) else {
1515
return nil
1616
}
17+
1718
return Unmanaged<AnyObject>.fromOpaque(cfType).takeUnretainedValue()
1819
}
1920

@@ -45,6 +46,7 @@ extension TISInputSource {
4546
guard let cfType = TISGetInputSourceProperty(self, kTISPropertyIconRef) else {
4647
return nil
4748
}
49+
4850
return OpaquePointer(cfType) as IconRef?
4951
}
5052
}

LanguageFlag/Helpers/CapsLockManager.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ class CapsLockManager {
99

1010
// MARK: - Init
1111
init() {
12+
isCapsLockEnabled = CGEventSource.flagsState(.combinedSessionState).contains(.maskAlphaShift)
1213
setupCapsLockObserver()
1314
}
1415

@@ -34,7 +35,7 @@ extension CapsLockManager {
3435
self?.handleCapsLockStateChange(event: event)
3536
}
3637
}
37-
38+
3839
/// Handles Caps Lock state changes.
3940
private func handleCapsLockStateChange(event: NSEvent) {
4041
let capsLockEnabled = isCapsLockOn()
@@ -44,7 +45,7 @@ extension CapsLockManager {
4445
notifyCapsLockStateChanged(newCapsLockEnabled: capsLockEnabled)
4546
}
4647
}
47-
48+
4849
/// Notifies observers about the Caps Lock state change.
4950
private func notifyCapsLockStateChanged(newCapsLockEnabled: Bool) {
5051
guard UserPreferences.shared.showCapsLockIndicator else { return }
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/// Alternative IOKit HID implementation for Caps Lock observation.
2+
///
3+
/// Key differences from NSEvent-based approach:
4+
/// - Works without Accessibility permission (read-only HID observation is allowed)
5+
/// - Receives raw hardware events directly, before the OS processes them
6+
/// - Slightly more complex setup; requires manual RunLoop scheduling and cleanup
7+
///
8+
/// This file is an illustrative example and is not used by the app.
9+
/// The production implementation is in CapsLockManager.swift.
10+
///
11+
/// Add these keys to Info.plist
12+
/// <key>NSInputMonitoringUsageDescription</key>
13+
/// <string>LanguageFlag monitors keyboard input to detect Caps Lock state changes.</string>
14+
15+
import Cocoa
16+
import IOKit
17+
import IOKit.hid
18+
19+
class CapsLockManagerHID {
20+
21+
// MARK: - Variables
22+
private var hidManager: IOHIDManager?
23+
private(set) var isCapsLockEnabled: Bool = false
24+
// Timestamp of the last processed key-down; used to debounce Karabiner's
25+
// double-fire (virtual keyboard + physical keyboard both report value=1).
26+
private var lastToggleTime: TimeInterval = 0
27+
28+
// MARK: - Init
29+
init() {
30+
isCapsLockEnabled = CGEventSource.flagsState(.combinedSessionState).contains(.maskAlphaShift)
31+
setupHIDObserver()
32+
}
33+
34+
deinit {
35+
if let manager = hidManager {
36+
IOHIDManagerUnscheduleFromRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue)
37+
IOHIDManagerClose(manager, IOOptionBits(kIOHIDOptionsTypeNone))
38+
}
39+
}
40+
}
41+
42+
// MARK: - Private
43+
private extension CapsLockManagerHID {
44+
45+
func setupHIDObserver() {
46+
let manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))
47+
hidManager = manager
48+
49+
// Match all keyboard devices
50+
let deviceMatch: [String: Any] = [
51+
kIOHIDDeviceUsagePageKey: kHIDPage_GenericDesktop,
52+
kIOHIDDeviceUsageKey: kHIDUsage_GD_Keyboard
53+
]
54+
IOHIDManagerSetDeviceMatching(manager, deviceMatch as CFDictionary)
55+
56+
// Narrow input values to Caps Lock key only
57+
let valueMatch: [String: Any] = [
58+
kIOHIDElementUsagePageKey: kHIDPage_KeyboardOrKeypad,
59+
kIOHIDElementUsageKey: kHIDUsage_KeyboardCapsLock
60+
]
61+
IOHIDManagerSetInputValueMatching(manager, valueMatch as CFDictionary)
62+
63+
// Register the value callback using a C-compatible closure via context pointer
64+
let context = Unmanaged.passUnretained(self).toOpaque()
65+
66+
IOHIDManagerRegisterInputValueCallback(
67+
manager,
68+
{ context, _, _, value in
69+
// HID reports key-down (1) and key-up (0). Only react on key-down.
70+
guard IOHIDValueGetIntegerValue(value) == 1 else { return }
71+
guard let context else { return }
72+
73+
let instance = Unmanaged<CapsLockManagerHID>.fromOpaque(context).takeUnretainedValue()
74+
75+
// IOKit HID fires BEFORE the OS processes the Caps Lock toggle, so
76+
// CGEventSource still holds the old state at this point. Defer by one
77+
// RunLoop cycle to let the OS update the modifier flags first.
78+
// Multiple devices (e.g. Karabiner virtual keyboard + physical keyboard)
79+
// may both report value=1, but CGEventSource will return the same
80+
// already-toggled state for both, so the dedup guard prevents double notifications.
81+
// Debounce: Karabiner fires value=1 twice per press (virtual + physical
82+
// keyboard). The first event is accepted; subsequent events within
83+
// 150ms are dropped. This ensures only one deferred read is scheduled.
84+
let now = Date.timeIntervalSinceReferenceDate
85+
guard now - instance.lastToggleTime > 0.15 else {
86+
print("[HID] duplicate event within debounce window, skipping")
87+
return
88+
}
89+
instance.lastToggleTime = now
90+
91+
// HID fires BEFORE the OS toggles the Caps Lock state. Wait 80ms so
92+
// CGEventSource returns the correct updated state, then read it directly
93+
// instead of self-managing a toggle (which drifts on missed events).
94+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) {
95+
let newState = CGEventSource.flagsState(.combinedSessionState).contains(.maskAlphaShift)
96+
print("[HID] deferred read: \(newState), current: \(instance.isCapsLockEnabled)")
97+
guard instance.isCapsLockEnabled != newState else { return }
98+
99+
instance.isCapsLockEnabled = newState
100+
print("[HID] posting .capsLockChanged with: \(newState)")
101+
instance.notifyCapsLockStateChanged(newCapsLockEnabled: newState)
102+
}
103+
},
104+
context
105+
)
106+
107+
IOHIDManagerScheduleWithRunLoop(manager, CFRunLoopGetMain(), CFRunLoopMode.defaultMode.rawValue)
108+
IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone))
109+
}
110+
111+
func notifyCapsLockStateChanged(newCapsLockEnabled: Bool) {
112+
print("[HID] notifyCapsLockStateChanged — showCapsLockIndicator: \(UserPreferences.shared.showCapsLockIndicator), newState: \(newCapsLockEnabled)")
113+
guard UserPreferences.shared.showCapsLockIndicator else { return }
114+
115+
NotificationCenter.default.post(name: .capsLockChanged, object: newCapsLockEnabled)
116+
print("[HID] notification posted")
117+
}
118+
}

LanguageFlag/Helpers/NotificationManager.swift

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,6 @@ import Combine
44

55
class NotificationManager {
66

7-
// MARK: - Variables
8-
private let capsLockManager = CapsLockManager()
9-
107
// MARK: - Init
118
init() {
129
setupObservers()
@@ -30,8 +27,9 @@ extension NotificationManager {
3027
@objc
3128
private func handleInputSourceChange() {
3229
let currentLayout = TISCopyCurrentKeyboardInputSource().takeUnretainedValue()
30+
let isCapsLockOn = CGEventSource.flagsState(.combinedSessionState).contains(.maskAlphaShift)
3331
let model = KeyboardLayoutNotification(keyboardLayout: currentLayout.name,
34-
isCapsLockEnabled: false,
32+
isCapsLockEnabled: isCapsLockOn,
3533
iconRef: currentLayout.iconRef)
3634
NotificationCenter.default.post(name: .keyboardLayoutChanged, object: model)
3735
}

LanguageFlag/Helpers/StatusBarManager.swift

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,6 @@ private extension StatusBarManager {
128128
#if FEATURE_ANALYTICS
129129
analytics.startTracking(layout: model.keyboardLayout)
130130
#endif
131-
132-
menuBuilder.updateRecentLayouts(with: model.keyboardLayout)
133131
}
134132

135133
@objc

LanguageFlag/Helpers/StatusBarMenuBuilder.swift

Lines changed: 1 addition & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,6 @@ import Carbon
44

55
final class StatusBarMenuBuilder {
66

7-
private var recentLayouts: [String] = []
8-
private let maxRecentLayouts = 5
9-
107
// swiftlint:disable function_body_length
118
/// Builds the menu for the status bar.
129
/// - Parameters:
@@ -30,20 +27,6 @@ final class StatusBarMenuBuilder {
3027

3128
menu.addItem(NSMenuItem.separator())
3229

33-
// Recent Layouts
34-
if !recentLayouts.isEmpty {
35-
let recentMenuItem = menu.addItem(withTitle: "Recent Layouts", action: nil, keyEquivalent: "")
36-
recentMenuItem.isEnabled = false
37-
38-
for layout in recentLayouts {
39-
let item = menu.addItem(withTitle: " \(layout)", action: #selector(StatusBarManager.switchToLayout(_:)), keyEquivalent: "")
40-
item.representedObject = layout
41-
item.target = target
42-
}
43-
44-
menu.addItem(NSMenuItem.separator())
45-
}
46-
4730
// Layout Groups
4831
#if FEATURE_GROUPS
4932
let groups = LayoutGroupManager.shared.getGroups()
@@ -65,20 +48,15 @@ final class StatusBarMenuBuilder {
6548
#endif
6649

6750
// All Available Layouts
68-
let layoutsMenu = NSMenu()
6951
let availableLayouts = getAvailableLayouts()
7052

7153
for layout in availableLayouts {
72-
let item = NSMenuItem(title: layout, action: #selector(StatusBarManager.switchToLayout(_:)), keyEquivalent: "")
54+
let item = menu.addItem(withTitle: layout, action: #selector(StatusBarManager.switchToLayout(_:)), keyEquivalent: "")
7355
item.representedObject = layout
7456
item.target = target
7557
item.state = layout == currentLayout ? .on : .off
76-
layoutsMenu.addItem(item)
7758
}
7859

79-
let layoutsMenuItem = menu.addItem(withTitle: "All Layouts", action: nil, keyEquivalent: "")
80-
menu.setSubmenu(layoutsMenu, for: layoutsMenuItem)
81-
8260
menu.addItem(NSMenuItem.separator())
8361

8462
// Launch at Login
@@ -110,18 +88,6 @@ final class StatusBarMenuBuilder {
11088
}
11189
// swiftlint:enable function_body_length
11290

113-
func updateRecentLayouts(with layout: String) {
114-
// Remove if already exists
115-
recentLayouts.removeAll { $0 == layout }
116-
117-
// Add to front
118-
recentLayouts.insert(layout, at: 0)
119-
120-
// Keep only max recent
121-
if recentLayouts.count > maxRecentLayouts {
122-
recentLayouts = Array(recentLayouts.prefix(maxRecentLayouts))
123-
}
124-
}
12591

12692
private func getAvailableLayouts() -> [String] {
12793
let inputSources = TISCreateInputSourceList(nil, false).takeRetainedValue() as? [TISInputSource] ?? []

LanguageFlag/Stories/LanguageViewController.swift

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ class LanguageViewController: NSViewController {
4242
label.alignment = .center
4343
label.translatesAutoresizingMaskIntoConstraints = false
4444
label.cell?.wraps = true
45+
label.setAccessibilityIdentifier("bigLabel")
4546
return label
4647
}()
4748

@@ -57,6 +58,7 @@ class LanguageViewController: NSViewController {
5758
label.font = .systemFont(ofSize: UserPreferences.shared.windowSize.fontSizes.label)
5859
label.alignment = .center
5960
label.translatesAutoresizingMaskIntoConstraints = false
61+
label.setAccessibilityIdentifier("languageNameLabel")
6062
return label
6163
}()
6264

@@ -106,16 +108,20 @@ extension LanguageViewController {
106108

107109
@objc
108110
private func capsLockChanged(notification: NSNotification) {
109-
guard
110-
let newBool = notification.object as? Bool,
111-
let previousModel
112-
else {
113-
return
111+
guard let newBool = notification.object as? Bool else { return }
112+
113+
let layout: TISInputSource
114+
if let previous = previousModel {
115+
changeFlagImage(keyboardLayout: previous.keyboardLayout,
116+
isCapsLockEnabled: newBool,
117+
iconRef: previous.iconRef)
118+
} else {
119+
// No layout change has occurred yet — use the current system layout
120+
layout = TISCopyCurrentKeyboardInputSource().takeUnretainedValue()
121+
changeFlagImage(keyboardLayout: layout.name,
122+
isCapsLockEnabled: newBool,
123+
iconRef: layout.iconRef)
114124
}
115-
116-
changeFlagImage(keyboardLayout: previousModel.keyboardLayout,
117-
isCapsLockEnabled: newBool,
118-
iconRef: previousModel.iconRef)
119125
}
120126
}
121127

LanguageFlag/Stories/LanguageWindowController.swift

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ private extension LanguageWindowController {
6868

6969
@objc
7070
func capsLockChanged(notification: NSNotification) {
71+
print("[WindowController] capsLockChanged received — object: \(String(describing: notification.object))")
72+
guard notification.object is Bool else { return }
73+
7174
hideTask?.cancel()
7275
runShowWindowAnimation()
7376
scheduleHide()
@@ -82,7 +85,9 @@ private extension LanguageWindowController {
8285
print("❌ No screen rect provided")
8386
return
8487
}
85-
window = LanguageWindow(contentRect: .zero)
88+
let win = LanguageWindow(contentRect: .zero)
89+
win.setAccessibilityIdentifier("LanguageIndicatorWindow")
90+
window = win
8691
}
8792

8893
func configureContentViewController() {
@@ -187,6 +192,7 @@ private extension LanguageWindowController {
187192

188193
func scheduleHide() {
189194
let nanoseconds = UInt64(preferences.displayDuration * 1_000_000_000)
195+
let animationNanoseconds = UInt64((preferences.animationDuration + 0.1) * 1_000_000_000)
190196

191197
hideTask = Task { [weak self] in
192198
guard let self else { return }
@@ -198,6 +204,17 @@ private extension LanguageWindowController {
198204
}
199205

200206
runHideWindowAnimation()
207+
208+
// After the animation finishes, remove the window from screen so it
209+
// disappears from the accessibility tree (needed for UI tests and to
210+
// prevent hover effects on invisible borderless windows).
211+
do {
212+
try await Task.sleep(nanoseconds: animationNanoseconds)
213+
} catch {
214+
return
215+
}
216+
217+
window?.orderOut(nil)
201218
}
202219
}
203220

0 commit comments

Comments
 (0)