Skip to content

Commit 04c984c

Browse files
author
Brad Johnson
committed
- Lots of performance improvements with regards to CPU usage
- Aligns much better with all kinds of cursors - Fixed the clipping at the edges of the screens - Hiding cursor is no longer time gated
1 parent 5a011bb commit 04c984c

File tree

2 files changed

+149
-56
lines changed

2 files changed

+149
-56
lines changed

MouseHook/AppDelegate.swift

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,39 +6,82 @@
66
//
77

88
import Cocoa
9+
import Combine
910

1011
class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1112
private var statusItem: NSStatusItem!
1213
private var menu: NSMenu!
14+
private var mouseWindow: NSWindow!
1315
private var mouseController: MouseViewController!
16+
private var slowEventThrottle: Publishers.Throttle<PassthroughSubject<NSEvent, Never>, DispatchQueue>!
17+
private var fastEventThrottle: Publishers.Throttle<PassthroughSubject<NSEvent, Never>, DispatchQueue>!
18+
private var refreshObserver: NSKeyValueObservation!
19+
private var eventMonitor: Any!
20+
private var cancellationToken: AnyCancellable?
21+
22+
@objc private dynamic var fastFreqRefresh: Bool = false
23+
24+
private let eventSubject = PassthroughSubject<NSEvent, Never>()
1425
private let userDefaults = UserDefaults.standard
26+
27+
deinit {
28+
refreshObserver?.invalidate()
29+
NSEvent.removeMonitor(eventMonitor!)
30+
}
1531

1632
func applicationDidFinishLaunching(_ aNotification: Notification) {
1733
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
1834
if let button = statusItem.button {
1935
button.image = NSImage(named: NSImage.Name("mousehook-menubar"))
2036
}
21-
2237
mouseController = MouseViewController()
23-
setupMouseHook()
38+
fastEventThrottle = self.eventSubject.throttle(for: .seconds(1.0/60.0), scheduler: DispatchQueue.global(), latest: true) // 60Hz
39+
slowEventThrottle = self.eventSubject.throttle(for: .seconds(1.0/5.0), scheduler: DispatchQueue.global(), latest: true) // 5Hz
40+
41+
setupMouseWindow()
2442
setupMenu()
2543
statusItem.menu = menu
44+
45+
refreshObserver = self.observe(\.fastFreqRefresh, options: [.new], changeHandler: { (this, change) in
46+
self.cancellationToken?.cancel()
47+
48+
let eventThrottle = change.newValue! ? self.fastEventThrottle : self.slowEventThrottle
49+
50+
self.cancellationToken = eventThrottle?.subscribe(on: DispatchQueue.global()).sink { event in
51+
// Don't bother updating mouse position if it is currently hidden
52+
DispatchQueue.main.async {
53+
self.update(event)
54+
}
55+
}
56+
})
57+
fastFreqRefresh = true
58+
59+
//TODO: Find a way to make global event monitoring not take up SO MUCH CPU
60+
eventMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.mouseMoved, .leftMouseDragged, .rightMouseDragged, .otherMouseDragged]) { (event) in
61+
self.eventSubject.send(event)
62+
}
2663
}
2764

28-
func setupMouseHook() {
29-
let mouseWindow = NSWindow(contentViewController: mouseController)
65+
func update(_ event: NSEvent) {
66+
let cursorVisible = mouseController.update(event)
67+
68+
if cursorVisible {
69+
mouseWindow.setFrameOrigin(mouseController.getFrameOrigin(event.locationInWindow))
70+
}
71+
// Only use fast refresh rate if the cursor is actually visible
72+
if (fastFreqRefresh != cursorVisible) {
73+
fastFreqRefresh = cursorVisible
74+
}
75+
}
76+
77+
func setupMouseWindow() {
78+
mouseWindow = NSWindow(contentViewController: mouseController)
3079
mouseWindow.styleMask = [.borderless]
3180
mouseWindow.ignoresMouseEvents = true
3281
mouseWindow.setFrame(NSRect(x: 0, y: 0, width: 50, height: 50), display: false)
3382
mouseWindow.backgroundColor = .clear
3483
mouseWindow.level = .screenSaver
3584
mouseWindow.orderFront(nil)
36-
37-
NSEvent.addGlobalMonitorForEvents(matching: [.mouseMoved, .leftMouseDragged, .rightMouseDragged, .otherMouseDragged]) { (event) in
38-
let mouseLocation = event.locationInWindow
39-
mouseWindow.setFrameOrigin(self.mouseController.getFrameOrigin(mouseLocation))
40-
self.mouseController.update(event)
41-
}
4285
}
4386

4487
func setupMenu() {
@@ -81,6 +124,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
81124

82125
@objc func menuItemClicked(item: NSMenuItem) {
83126
updateEnabledMonitors(CGDirectDisplayID(item.identifier!.rawValue)!)
127+
mouseController.resetCurrentMonitor()
84128
}
85129

86130
func updateEnabledMonitors(_ toUpdate: CGDirectDisplayID) {

MouseHook/MouseViewController.swift

Lines changed: 95 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,21 @@
66
//
77

88
import Cocoa
9+
import CommonCrypto
910

1011
class MouseViewController: NSViewController {
1112
private var cursorImageView: NSImageView!
12-
private var activeMonitorObserver: NSKeyValueObservation!
13+
private var enabledMonitorObserver: NSKeyValueObservation!
14+
private var cursorObserver: NSKeyValueObservation!
1315
private var enabledMonitors: Set<CGDirectDisplayID> = Set<CGDirectDisplayID>()
14-
private var currentCursor: NSCursor = NSCursor.current
15-
private var lastUpdate: Date = Date(milliseconds: 0)
16+
private var currentMonitor: NSScreen?
1617

17-
private let hideRefreshIntervalMs = 1500
18+
@objc private dynamic var currentCursor: NSCursor = NSCursor.current
19+
20+
private let pointingHandHash: String? = NSCursor.pointingHand.image.tiffRepresentation?.sha256
21+
private let openHandHash: String? = NSCursor.openHand.image.tiffRepresentation?.sha256
22+
private let closedHandHash: String? = NSCursor.closedHand.image.tiffRepresentation?.sha256
23+
private let hideRefreshIntervalSecs = 1.5
1824

1925
override init(nibName nibNameOrNil: NSNib.Name?, bundle nibBundleOrNil: Bundle?) {
2026
super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
@@ -26,82 +32,125 @@ class MouseViewController: NSViewController {
2632
setupMouseViewController()
2733
}
2834

35+
deinit {
36+
enabledMonitorObserver?.invalidate()
37+
cursorObserver?.invalidate()
38+
}
39+
2940
private func setupMouseViewController() {
30-
activeMonitorObserver = UserDefaults.standard.observe(\.activeMonitors, options: [.initial, .new], changeHandler: { (defaults, change) in
31-
if let mons = defaults.activeMonitors as [CGDirectDisplayID]? {
32-
self.enabledMonitors = Set(mons.map { $0 })
33-
}
34-
})
35-
NSCursor.addObserver(self, forKeyPath: "currentSystem", options: [.new, .old], context: nil)
3641
cursorImageView = NSImageView()
3742
cursorImageView.wantsLayer = true
3843
cursorImageView.image = currentCursor.image
3944
cursorImageView.imageScaling = .scaleNone
4045
cursorImageView.layer?.backgroundColor = NSColor.clear.cgColor
4146
self.view = cursorImageView
47+
48+
setupObservers()
4249
}
4350

44-
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
45-
if keyPath == "currentSystem" {
46-
if let newCursor = change?[NSKeyValueChangeKey.newKey] as? NSCursor {
47-
self.cursorImageView.image = newCursor.image
51+
private func setupObservers() {
52+
enabledMonitorObserver = UserDefaults.standard.observe(\.activeMonitors, options: [.initial, .new], changeHandler: { (defaults, change) in
53+
if let mons = defaults.activeMonitors as [CGDirectDisplayID]? {
54+
self.enabledMonitors = Set(mons.map { $0 })
4855
}
49-
}
56+
})
57+
cursorObserver = self.observe(\.currentCursor, options: [.initial, .new], changeHandler: { (this, change) in
58+
self.cursorImageView.image = this.currentCursor.image
59+
})
5060
}
5161

62+
/** These magic numbers work for the default mouse cursor at the default size on MacOS Monterey.
63+
I can't guarantee it will work for custom mouse cursors or different OS versions, but I think it'll be pretty close. **/
5264
func getFrameOrigin(_ pt: NSPoint) -> NSPoint {
5365
var offset: (Double, Double)
66+
67+
// There is no simple way to check the exact image of a cursor, but size is cheap, easy, and works in most cases to determine the offset.
5468
switch (currentCursor.image.size) {
5569
case NSCursor.arrow.image.size:
56-
fallthrough
70+
offset = (21, 32)
5771
case NSCursor.pointingHand.image.size:
58-
offset = (21.5, 32)
59-
break
72+
// There are representations that match the size of pointingHand but not the offset, so check the hash of the image
73+
switch (currentCursor.image.tiffRepresentation?.sha256) {
74+
case pointingHandHash:
75+
offset = (21.5, 32)
76+
case openHandHash:
77+
offset = (24.5, 24.5)
78+
case closedHandHash:
79+
offset = (24.5, 25)
80+
default:
81+
// It is likely a type of iBeam
82+
offset = (24, 28)
83+
}
6084
case NSCursor.iBeam.image.size:
6185
fallthrough
6286
case NSCursor.resizeLeftRight.image.size:
63-
fallthrough
64-
case NSCursor.resizeUpDown.image.size:
6587
offset = (24.5, 24.5)
66-
break
88+
case NSCursor.disappearingItem.image.size:
89+
offset = (16.5, 38.5)
90+
case NSCursor.dragLink.image.size:
91+
offset = (28, 32)
6792
default:
6893
offset = (23.5, 25.5)
6994
}
7095

7196
return NSPoint(x: pt.x - offset.0, y: pt.y - offset.1)
7297
}
7398

74-
func update(_ event: NSEvent) {
75-
currentCursor = NSCursor.currentSystem != nil ? NSCursor.currentSystem! : currentCursor
76-
cursorImageView.image = currentCursor.image
99+
100+
override func viewDidLoad() {
101+
let aCursor = NSCursor.resizeUpDown
102+
view.addCursorRect(view.bounds, cursor: aCursor)
103+
aCursor.set()
104+
view.addTrackingRect(view.bounds, owner: aCursor, userData: nil, assumeInside: true)
77105

78-
let now = Date.now
79-
guard (now.millisecondsSince1970 - lastUpdate.millisecondsSince1970) > hideRefreshIntervalMs else { return }
80-
cursorImageView.isHidden = NSScreen.screens.reduce(true) { (result, screen) in
81-
// If the monitor is not enabled, return immediately with a vote to `hide`
82-
guard enabledMonitors.contains(screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as! CGDirectDisplayID) else { return result && true }
83-
84-
let screenBounds = screen.visibleFrame
85-
let mousePosition = event.locationInWindow
86-
87-
return result && !screenBounds.contains(mousePosition)
106+
}
107+
108+
func resetCurrentMonitor(_ mousePosition: NSPoint? = nil) {
109+
if (mousePosition != nil) {
110+
currentMonitor = NSScreen.screens.first(where: { $0.frame.contains(mousePosition!) })
88111
}
89-
lastUpdate = now
112+
cursorImageView.isHidden = !enabledMonitors.contains(currentMonitor?.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID ?? kCGNullDirectDisplay)
90113
}
91-
92-
deinit {
93-
activeMonitorObserver?.invalidate()
94-
NSCursor.removeObserver(self, forKeyPath: "current")
114+
115+
func update(_ event: NSEvent) -> Bool {
116+
if (NSCursor.currentSystem != nil) {
117+
currentCursor = NSCursor.currentSystem!
118+
}
119+
120+
let screenBounds = currentMonitor?.frame
121+
// Apply a small transform to the y axis to avoid it going out of bounds along the top axis
122+
let mousePosition = event.locationInWindow.applying(CGAffineTransform(translationX: 0.0, y: -0.00001))
123+
if (screenBounds?.contains(mousePosition) != true) {
124+
resetCurrentMonitor(mousePosition)
125+
}
126+
127+
return !cursorImageView.isHidden
95128
}
96129
}
97130

98-
extension Date {
99-
var millisecondsSince1970:Int64 {
100-
Int64((self.timeIntervalSince1970 * 1000.0).rounded())
131+
extension Data {
132+
public var sha256:String {
133+
get {
134+
return hexStringFromData(input: digest(input: self as NSData))
135+
}
101136
}
102-
103-
init(milliseconds:Int64) {
104-
self = Date(timeIntervalSince1970: TimeInterval(milliseconds) / 1000)
137+
138+
private func digest(input : NSData) -> NSData {
139+
let digestLength = Int(CC_SHA256_DIGEST_LENGTH)
140+
var hash = [UInt8](repeating: 0, count: digestLength)
141+
CC_SHA256(input.bytes, UInt32(input.length), &hash)
142+
return NSData(bytes: hash, length: digestLength)
105143
}
106-
}
107144

145+
private func hexStringFromData(input: NSData) -> String {
146+
var bytes = [UInt8](repeating: 0, count: input.length)
147+
input.getBytes(&bytes, length: input.length)
148+
149+
var hexString = ""
150+
for byte in bytes {
151+
hexString += String(format:"%02x", UInt8(byte))
152+
}
153+
154+
return hexString
155+
}
156+
}

0 commit comments

Comments
 (0)