Skip to content
39 changes: 35 additions & 4 deletions Sources/Presenters/Wallpaper/RipplePresenter.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AppKit
import CoreFoundation
import Dependencies
import Domain
import Foundation
Expand All @@ -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

Expand Down Expand Up @@ -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() {
Expand Down
2 changes: 1 addition & 1 deletion Sources/VersionHandler/Resources/version.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.13.17
2.13.18
82 changes: 82 additions & 0 deletions Tests/PresentersTests/RipplePresenterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading