Last active
March 5, 2023 19:08
-
-
Save stuartcarnie/d7f4128af4fa48970ea221119cfde45e to your computer and use it in GitHub Desktop.
Combine Publisher support for NSEvent.addLocalMonitorForEvents
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
enum MouseState { | |
case idle, moving | |
} | |
// only publish events when we're active and the main window | |
let pub = NSEvent.publisher(scope: .local, matching: .mouseMoved) | |
.filter { _ in NSApp.isActive && window.isMainWindow } | |
// whilst we're receiving events, send .moving | |
let a = pub.map { _ in MouseState.moving } | |
// after we see no events for 2 seconds, send .idle | |
let b = pub.debounce(for: 2, scheduler: RunLoop.main).map { _ in MouseState.idle } | |
// merge the streams and remove duplicates (.moving, .moving, ...) | |
let c = a.merge(with: b).removeDuplicates() | |
s = c.sink { s in | |
switch s { | |
case .idle: | |
// do something when the mouse is idle | |
case .moving: | |
// do something when the mouse started moving | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@available(macOS 10.15, *) | |
extension NSEvent { | |
static func publisher(scope: Publisher.Scope, matching: EventTypeMask) -> Publisher { | |
return Publisher(scope: scope, matching: matching) | |
} | |
public struct Publisher: Combine.Publisher { | |
public enum Scope { | |
case local, global | |
} | |
public typealias Output = NSEvent | |
public typealias Failure = Never | |
let scope: Scope | |
let matching: EventTypeMask | |
init(scope: Scope, matching: EventTypeMask) { | |
self.scope = scope | |
self.matching = matching | |
} | |
public func receive<S>(subscriber: S) where S : Subscriber, Self.Failure == S.Failure, Self.Output == S.Input { | |
let subscription = Subscription(scope:scope, matching: matching, subscriber: subscriber) | |
subscriber.receive(subscription: subscription) | |
} | |
} | |
} | |
@available(macOS 10.15, *) | |
private extension NSEvent.Publisher { | |
final class Subscription<S: Subscriber> where S.Input == NSEvent, S.Failure == Never { | |
fileprivate let lock = NSLock() | |
fileprivate var demand = Subscribers.Demand.none | |
private var monitor: Any? | |
fileprivate let subscriberLock = NSRecursiveLock() | |
init(scope: Scope, matching: NSEvent.EventTypeMask, subscriber: S) { | |
switch scope { | |
case .local: | |
self.monitor = NSEvent.addLocalMonitorForEvents(matching: matching, handler: { [weak self] (event) -> NSEvent? in | |
self?.didReceive(event: event, subscriber: subscriber) | |
return event | |
}) | |
case .global: | |
self.monitor = NSEvent.addGlobalMonitorForEvents(matching: matching, handler: { [weak self] in | |
self?.didReceive(event: $0, subscriber: subscriber) | |
}) | |
} | |
} | |
deinit { | |
if let monitor = monitor { | |
NSEvent.removeMonitor(monitor) | |
} | |
} | |
func didReceive(event: NSEvent, subscriber: S) { | |
let val = { () -> Subscribers.Demand in | |
lock.lock() | |
defer { lock.unlock() } | |
let before = demand | |
if demand > 0 { | |
demand -= 1 | |
} | |
return before | |
}() | |
guard val > 0 else { return } | |
let newDemand = subscriber.receive(event) | |
lock.lock() | |
demand += newDemand | |
lock.unlock() | |
} | |
} | |
} | |
@available(macOS 10.15, *) | |
extension NSEvent.Publisher.Subscription: Combine.Subscription { | |
func request(_ demand: Subscribers.Demand) { | |
lock.lock() | |
defer { lock.unlock() } | |
self.demand += demand | |
} | |
func cancel() { | |
lock.lock() | |
defer { lock.unlock() } | |
guard let monitor = monitor else { return } | |
self.monitor = nil | |
NSEvent.removeMonitor(monitor) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment