diff --git a/Sources/Presenters/Wallpaper/RipplePresenter.swift b/Sources/Presenters/Wallpaper/RipplePresenter.swift index 1562faa..5acc8a3 100644 --- a/Sources/Presenters/Wallpaper/RipplePresenter.swift +++ b/Sources/Presenters/Wallpaper/RipplePresenter.swift @@ -1,4 +1,5 @@ import AppKit +import CoreFoundation import Dependencies import Domain import Foundation @@ -10,6 +11,9 @@ public final class RipplePresenter: ObservableObject { private var screenRect: CGRect private var mouseInScreen = false private var mouseMonitor: Any? + /// `CACurrentMediaTime()` of the last processed mouse-move event, used to + /// throttle ripple work to roughly 30 Hz during rapid motion (#271). + private var lastMouseMoveTime: CFTimeInterval = 0 @Dependency(\.wallpaperInteractor) private var interactor @@ -83,11 +87,38 @@ public final class RipplePresenter: ObservableObject { guard config.enabled else { return } mouseMonitor = NSEvent.addGlobalMonitorForEvents(matching: .mouseMoved) { [weak self] _ in - Task { @MainActor in - guard let self else { return } - self.handleMouseLocation(NSEvent.mouseLocation) - } + self?.handleGlobalMouseMove() + } + } + + /// Bridges a global mouse-move callback onto the MainActor and forwards it + /// to the throttled handler. Global-monitor callbacks are delivered on the + /// main thread, so `assumeIsolated` runs synchronously instead of hopping + /// through a `Task` per event; off-screen and throttled samples bail out + /// inside `processMouseMove` without further work, capping per-event cost + /// during rapid motion (#271). Split out from the monitor closure so the + /// main-actor hop is unit-testable — the global monitor itself cannot be + /// fired from a test. + nonisolated func handleGlobalMouseMove() { + MainActor.assumeIsolated { processMouseMove() } + } + + /// Applies the screen-exclusion filter and the ~30 Hz throttle to one + /// mouse-move sample before forwarding it to `handleMouseLocation` (#271). + /// The defaults read the live cursor/clock for the global monitor; tests + /// inject deterministic values, since real samples cannot be simulated. + func processMouseMove(at location: CGPoint = NSEvent.mouseLocation, time: CFTimeInterval = CACurrentMediaTime()) { + // Movement outside the overlay screen never spawns a ripple — reset the + // hover flag and bail before touching ripple state. + guard screenRect.contains(location) else { + mouseInScreen = false + return } + // Cap ripple processing to ~30 Hz so rapid in-screen motion does not + // redraw the ripple layer on every event. + guard time - lastMouseMoveTime >= 0.033 else { return } + lastMouseMoveTime = time + handleMouseLocation(location) } public func stop() { diff --git a/Sources/VersionHandler/Resources/version.txt b/Sources/VersionHandler/Resources/version.txt index 3053a1c..de4e782 100644 --- a/Sources/VersionHandler/Resources/version.txt +++ b/Sources/VersionHandler/Resources/version.txt @@ -1 +1 @@ -2.13.17 +2.13.18 diff --git a/Tests/PresentersTests/RipplePresenterTests.swift b/Tests/PresentersTests/RipplePresenterTests.swift index 064571c..e438c76 100644 --- a/Tests/PresentersTests/RipplePresenterTests.swift +++ b/Tests/PresentersTests/RipplePresenterTests.swift @@ -300,6 +300,88 @@ struct RipplePresenterTests { } } + @Suite("processMouseMove") + struct ProcessMouseMove { + private static let screenRect = CGRect(x: 0, y: 0, width: 1920, height: 1080) + + @MainActor + @Test("rejects point outside screenRect and clears mouseInScreen (#271)") + func rejectsOutsidePoint() { + withDependencies { + $0.wallpaperInteractor = StubWallpaperInteractor(rippleConfig: .init(enabled: true, duration: 2.0)) + $0.date = .init { fixedDate } + } operation: { + let presenter = RipplePresenter(screenRect: Self.screenRect) + presenter.start() + // An in-screen sample sets mouseInScreen = true and spawns a ripple. + presenter.processMouseMove(at: CGPoint(x: 960, y: 540), time: 1.0) + let spawned = presenter.rippleState?.ripples.count ?? 0 + #expect(spawned > 0) + // An off-screen sample clears the hover flag without further work, + // so a following idle tick spawns no idle ripple. + presenter.processMouseMove(at: CGPoint(x: 5000, y: 5000), time: 2.0) + let before = presenter.rippleState?.ripples.count ?? 0 + presenter.idle() + #expect((presenter.rippleState?.ripples.count ?? 0) == before) + } + } + + @MainActor + @Test("accepts point inside screenRect and spawns ripple (#271)") + func acceptsInsidePoint() { + withDependencies { + $0.wallpaperInteractor = StubWallpaperInteractor(rippleConfig: .init(enabled: true, duration: 2.0)) + $0.date = .init { fixedDate } + } operation: { + let presenter = RipplePresenter(screenRect: Self.screenRect) + presenter.start() + presenter.processMouseMove(at: CGPoint(x: 960, y: 540), time: 1.0) + #expect((presenter.rippleState?.ripples.count ?? 0) > 0) + } + } + + @MainActor + @Test("throttles samples arriving within 33 ms (#271)") + func throttlesRapidSamples() { + withDependencies { + $0.wallpaperInteractor = StubWallpaperInteractor(rippleConfig: .init(enabled: true, duration: 2.0)) + $0.date = .init { fixedDate } + } operation: { + let presenter = RipplePresenter(screenRect: Self.screenRect) + presenter.start() + presenter.processMouseMove(at: CGPoint(x: 100, y: 100), time: 1.0) + let afterFirst = presenter.rippleState?.ripples.count ?? 0 + // Second sample only 10 ms later is dropped by the throttle. + presenter.processMouseMove(at: CGPoint(x: 800, y: 800), time: 1.010) + #expect((presenter.rippleState?.ripples.count ?? 0) == afterFirst) + // A sample past the 33 ms window is processed again. + presenter.processMouseMove(at: CGPoint(x: 800, y: 800), time: 1.050) + #expect((presenter.rippleState?.ripples.count ?? 0) > afterFirst) + } + } + } + + @Suite("handleGlobalMouseMove") + struct HandleGlobalMouseMove { + @MainActor + @Test("hops onto the main actor and applies screen exclusion (#271)") + func bridgesMonitorCallback() { + withDependencies { + $0.wallpaperInteractor = StubWallpaperInteractor(rippleConfig: .init(enabled: true, duration: 2.0)) + $0.date = .init { fixedDate } + } operation: { + // A zero screenRect can never contain the live cursor, so the + // bridged call must run synchronously on the main actor and exit + // via the exclusion guard without spawning a ripple. + let presenter = RipplePresenter(screenRect: .zero) + presenter.start() + presenter.handleGlobalMouseMove() + #expect(presenter.rippleState?.ripples.isEmpty == true) + #expect(!presenter.isAnimating) + } + } + } + @Suite("isAnimating") struct IsAnimating { @MainActor