Skip to content

Commit 39cbe28

Browse files
jsonifyclaude
andauthored
Add Screen and Mouse Coordinate Modes (#15)
* Add Screen Coordinates and Live Mouse modes to ClickIt Lite Implements two coordinate modes for the Lite version: - Screen Coordinates Mode: Clicks at a static, user-defined position - Live Mouse Mode: Clicks follow cursor position, triggered by right-click Changes: - SimpleViewModel: Added CoordinateMode enum and mode switching logic - SimpleHotkeyManager: Extended with right-click monitoring and debouncing - SimpleClickEngine: Added dynamic point provider support for cursor-following - SimplifiedMainView: Added mode selector UI with context-aware controls Both modes continue to use ESC/SPACEBAR for emergency stop. * Fix Swift concurrency actor isolation issues - Mark stopMonitoring() and stopMouseMonitoring() as nonisolated to allow calling from deinit without actor context - Wrap checkPermissions() call in Task { @mainactor in } block to properly handle async call from notification observer Resolves build errors in ClickIt Lite. * Mark event monitor properties as nonisolated(unsafe) The event monitor properties need to be accessible from nonisolated stop methods that are called from deinit. Marking them as nonisolated(unsafe) is safe because: - They are opaque tokens from NSEvent.addMonitorForEvents - NSEvent.removeMonitor is thread-safe - We only read them to pass to removeMonitor This resolves the MainActor isolation compilation errors. * Fix coordinate conversion for Live Mouse Mode The previous implementation used NSScreen.main for coordinate conversion, which caused clicks to drift towards the top of the screen, especially in multi-monitor setups. Changes: - Find the screen that contains the mouse cursor location - Use that specific screen's frame for accurate coordinate conversion - Add fallback logic for edge cases using the full desktop bounds This ensures clicks happen exactly where the cursor is positioned. * Fix coordinate conversion and resource leaks from code review Coordinate conversion (SimpleViewModel.swift): - Fixed multi-monitor coordinate conversion to use global maxY - Previous per-screen logic was incorrect and caused wrong Y positions - Now properly accounts for entire virtual screen space Resource management (SimpleHotkeyManager.swift): - Restored stopMonitoring() to MainActor with proper cleanup - Fixed stopMouseMonitoring() to dispatch cleanup to MainActor - Both methods now properly nil out monitor properties and callbacks - Prevents memory leaks from retained closures in singleton - Removed nonisolated(unsafe) annotations (no longer needed) These fixes address all issues identified in code review. --------- Co-authored-by: Claude <[email protected]>
1 parent 398935d commit 39cbe28

File tree

5 files changed

+257
-38
lines changed

5 files changed

+257
-38
lines changed

Sources/ClickIt/Lite/SimpleClickEngine.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,21 @@ final class SimpleClickEngine {
3333
interval: TimeInterval,
3434
clickType: ClickType,
3535
onUpdate: @escaping (Int) -> Void
36+
) {
37+
startClicking(
38+
pointProvider: { point },
39+
interval: interval,
40+
clickType: clickType,
41+
onUpdate: onUpdate
42+
)
43+
}
44+
45+
/// Start clicking with dynamic point generation
46+
func startClicking(
47+
pointProvider: @escaping () -> CGPoint,
48+
interval: TimeInterval,
49+
clickType: ClickType,
50+
onUpdate: @escaping (Int) -> Void
3651
) {
3752
guard !isRunning else { return }
3853

@@ -43,6 +58,9 @@ final class SimpleClickEngine {
4358
guard let self = self else { return }
4459

4560
while !Task.isCancelled && self.isRunning {
61+
// Get current point (can be static or dynamic)
62+
let point = pointProvider()
63+
4664
// Perform click
4765
await self.performClick(at: point, type: clickType)
4866
self.clickCount += 1

Sources/ClickIt/Lite/SimpleHotkeyManager.swift

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ final class SimpleHotkeyManager {
1919
private var localMonitor: Any?
2020
private var onEmergencyStop: (() -> Void)?
2121

22+
private var globalMouseMonitor: Any?
23+
private var localMouseMonitor: Any?
24+
private var onRightMouseClick: (() -> Void)?
25+
private var lastClickTime: TimeInterval = 0
26+
private let clickDebounceInterval: TimeInterval = 0.1
27+
2228
// MARK: - Singleton
2329

2430
static let shared = SimpleHotkeyManager()
@@ -66,6 +72,42 @@ final class SimpleHotkeyManager {
6672
onEmergencyStop = nil
6773
}
6874

75+
/// Start monitoring for right mouse clicks (Live Mouse Mode)
76+
func startMouseMonitoring(onRightClick: @escaping () -> Void) {
77+
self.onRightMouseClick = onRightClick
78+
79+
// Monitor globally (when app is inactive)
80+
globalMouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: .rightMouseDown) { [weak self] event in
81+
// Dispatch to MainActor since global monitor runs on background thread
82+
Task { @MainActor in
83+
self?.handleRightMouseClick()
84+
}
85+
}
86+
87+
// Monitor locally (when app is active)
88+
localMouseMonitor = NSEvent.addLocalMonitorForEvents(matching: .rightMouseDown) { [weak self] event in
89+
// Already on MainActor
90+
self?.handleRightMouseClick()
91+
return event // Pass through the event
92+
}
93+
}
94+
95+
/// Stop mouse monitoring
96+
nonisolated func stopMouseMonitoring() {
97+
// Dispatch cleanup to the main actor to safely access and nil out properties.
98+
Task { @MainActor in
99+
if let monitor = self.globalMouseMonitor {
100+
NSEvent.removeMonitor(monitor)
101+
self.globalMouseMonitor = nil
102+
}
103+
if let monitor = self.localMouseMonitor {
104+
NSEvent.removeMonitor(monitor)
105+
self.localMouseMonitor = nil
106+
}
107+
self.onRightMouseClick = nil
108+
}
109+
}
110+
69111
// MARK: - Private Methods
70112

71113
/// Check if the event is an emergency stop key (ESC or SPACEBAR)
@@ -76,4 +118,14 @@ final class SimpleHotkeyManager {
76118
private func handleEmergencyStop() {
77119
onEmergencyStop?()
78120
}
121+
122+
/// Handle right mouse click with debouncing
123+
private func handleRightMouseClick() {
124+
let currentTime = CFAbsoluteTimeGetCurrent()
125+
if currentTime - lastClickTime < clickDebounceInterval {
126+
return
127+
}
128+
lastClickTime = currentTime
129+
onRightMouseClick?()
130+
}
79131
}

Sources/ClickIt/Lite/SimplePermissionManager.swift

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@ final class SimplePermissionManager: ObservableObject {
4545
queue: .main
4646
) { [weak self] _ in
4747
// Re-check permissions when app becomes active (e.g., returning from System Settings)
48-
self?.checkPermissions()
48+
Task { @MainActor in
49+
self?.checkPermissions()
50+
}
4951
}
5052
}
5153

Sources/ClickIt/Lite/SimpleViewModel.swift

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ import SwiftUI
1212
@MainActor
1313
final class SimpleViewModel: ObservableObject {
1414

15+
// MARK: - Types
16+
17+
/// Coordinate mode for clicking
18+
enum CoordinateMode {
19+
case screenCoordinates // Static position
20+
case liveMouse // Follow cursor
21+
}
22+
1523
// MARK: - Published Properties
1624

1725
@Published var clickLocation: CGPoint = CGPoint(x: 500, y: 300)
@@ -20,6 +28,7 @@ final class SimpleViewModel: ObservableObject {
2028
@Published var isRunning = false
2129
@Published var clickCount = 0
2230
@Published var statusMessage = "Stopped"
31+
@Published var coordinateMode: CoordinateMode = .screenCoordinates
2332

2433
// MARK: - Private Properties
2534

@@ -36,8 +45,29 @@ final class SimpleViewModel: ObservableObject {
3645
}
3746
}
3847

48+
deinit {
49+
hotkeyManager.stopMouseMonitoring()
50+
}
51+
3952
// MARK: - Public Methods
4053

54+
/// Set coordinate mode
55+
func setCoordinateMode(_ mode: CoordinateMode) {
56+
coordinateMode = mode
57+
58+
// Set up or tear down mouse monitoring based on mode
59+
switch mode {
60+
case .screenCoordinates:
61+
hotkeyManager.stopMouseMonitoring()
62+
statusMessage = "Screen Coordinates Mode"
63+
case .liveMouse:
64+
hotkeyManager.startMouseMonitoring { [weak self] in
65+
self?.startClicking()
66+
}
67+
statusMessage = "Live Mouse Mode - Right-click to trigger"
68+
}
69+
}
70+
4171
/// Start clicking
4272
func startClicking() {
4373
// Check permissions first
@@ -50,8 +80,21 @@ final class SimpleViewModel: ObservableObject {
5080
clickCount = 0
5181
statusMessage = "Running: 0 clicks"
5282

83+
// Use appropriate point provider based on mode
84+
let pointProvider: () -> CGPoint
85+
switch coordinateMode {
86+
case .screenCoordinates:
87+
pointProvider = { [weak self] in
88+
self?.clickLocation ?? CGPoint(x: 500, y: 300)
89+
}
90+
case .liveMouse:
91+
pointProvider = {
92+
NSEvent.mouseLocation.asCGPoint()
93+
}
94+
}
95+
5396
clickEngine.startClicking(
54-
at: clickLocation,
97+
pointProvider: pointProvider,
5598
interval: clickInterval,
5699
clickType: clickType
57100
) { [weak self] count in
@@ -84,9 +127,22 @@ final class SimpleViewModel: ObservableObject {
84127

85128
private extension NSPoint {
86129
func asCGPoint() -> CGPoint {
87-
// Convert from AppKit coordinates (bottom-left origin) to CG coordinates (top-left origin)
88-
guard let screen = NSScreen.main else { return CGPoint(x: x, y: y) }
89-
let screenHeight = screen.frame.height
90-
return CGPoint(x: x, y: screenHeight - y)
130+
// Convert from AppKit coordinates (bottom-left origin) to CG coordinates (top-left origin).
131+
// This must account for the entire virtual screen space in multi-monitor setups.
132+
133+
// The total height of the virtual screen space is the max Y coordinate across all screens.
134+
// The conversion is then to subtract the AppKit Y from this total height.
135+
if let globalMaxY = NSScreen.screens.map({ $0.frame.maxY }).max() {
136+
return CGPoint(x: x, y: globalMaxY - y)
137+
}
138+
139+
// As a fallback, use the main screen's height, which works for single-monitor setups.
140+
if let mainScreen = NSScreen.main {
141+
let screenHeight = mainScreen.frame.height
142+
return CGPoint(x: x, y: screenHeight - y)
143+
}
144+
145+
// Ultimate fallback if no screen information is available.
146+
return CGPoint(x: x, y: y)
91147
}
92148
}

0 commit comments

Comments
 (0)