Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions Crisp/Models/EdgeCrossing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,37 @@ enum EdgeCrossing {
return best.point
}
}

/// A jump that a move back takes back: a small push past the edge should not cost
/// the pointer its place, so moving back across the edge before going far on the new
/// display returns the pointer to `origin` instead of the height it slid to.
struct EdgeReturn {
// ponytail: tuned by feel (40 was too small); no time limit, on purpose.
static let reach: CGFloat = 150

/// Where the pointer was before the jump, on the display `from`.
let origin: CGPoint
let from: CGRect
/// Where the jump put it, on the display across the edge.
let landing: CGPoint

enum Step: Equatable { case wait, back(CGPoint), expire }

func step(at point: CGPoint, delta: CGVector) -> Step {
guard hypot(point.x - landing.x, point.y - landing.y) <= Self.reach else { return .expire }
// How far past `from` the pointer is on the crossing axis (1 on the landing edge,
// 0 or less once back on `from`), and the move's part back toward `from` and along the edge.
let gap, back, along: CGFloat
if landing.x >= from.maxX {
(gap, back, along) = (point.x - from.maxX + 1, -delta.dx, delta.dy)
} else if landing.x < from.minX {
(gap, back, along) = (from.minX - point.x, delta.dx, delta.dy)
} else if landing.y >= from.maxY {
(gap, back, along) = (point.y - from.maxY + 1, -delta.dy, delta.dx)
} else {
(gap, back, along) = (from.minY - point.y, delta.dy, delta.dx)
}
// Mostly back, so a slide along the edge does not count.
return gap <= 1 && back > abs(along) ? .back(origin) : .wait
}
}
25 changes: 23 additions & 2 deletions Crisp/Services/EdgeCrossingService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ final class EdgeCrossingService {
private var monitors: [Any] = []
private var screenObserver: NSObjectProtocol?
private var displayBounds: [CGRect] = []
/// The last jump, while a move back can still take it back.
private var lastJump: EdgeReturn?

func setEnabled(_ enabled: Bool) {
if enabled { start() } else { stop() }
Expand Down Expand Up @@ -44,6 +46,7 @@ final class EdgeCrossingService {
monitors = []
if let screenObserver { NotificationCenter.default.removeObserver(screenObserver) }
screenObserver = nil
lastJump = nil
}

private func refreshBounds() {
Expand All @@ -52,17 +55,35 @@ final class EdgeCrossingService {
var ids = [CGDirectDisplayID](repeating: 0, count: Int(count))
CGGetActiveDisplayList(count, &ids, &count)
displayBounds = ids.prefix(Int(count)).map(CGDisplayBounds)
lastJump = nil
}

private func handle(_ event: NSEvent) {
guard displayBounds.count > 1, let cgEvent = event.cgEvent else { return }
let delta = CGVector(dx: cgEvent.getDoubleValueField(.mouseEventDeltaX),
dy: cgEvent.getDoubleValueField(.mouseEventDeltaY))
guard let target = EdgeCrossing.target(from: cgEvent.location, delta: delta, displays: displayBounds)
let point = cgEvent.location
if let jump = lastJump {
switch jump.step(at: point, delta: delta) {
case .back(let origin):
lastJump = nil
warp(to: origin)
return
case .expire: lastJump = nil
case .wait: break
}
}
guard let target = EdgeCrossing.target(from: point, delta: delta, displays: displayBounds),
let from = displayBounds.first(where: { $0.contains(point) })
else { return }
lastJump = EdgeReturn(origin: point, from: from, landing: target)
warp(to: target)
}

private func warp(to point: CGPoint) {
// A warp holds back hardware input for 0.25 s by default, which reads as a stuck
// pointer; CGAssociateMouseAndMouseCursorPosition does not lift it on macOS 27.
CGEventSource(stateID: .combinedSessionState)?.localEventsSuppressionInterval = 0
CGWarpMouseCursorPosition(target)
CGWarpMouseCursorPosition(point)
}
}
40 changes: 40 additions & 0 deletions CrispTests/EdgeCrossingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,43 @@ final class EdgeCrossingTests: XCTestCase {
XCTAssertEqual(target, CGPoint(x: -1, y: 400))
}
}

/// The jump from the big display's left edge at y 100 onto the small display's top corner.
final class EdgeReturnTests: XCTestCase {
private let jump = EdgeReturn(origin: CGPoint(x: 0, y: 100), from: CGRect(x: 0, y: 0, width: 2560, height: 1440),
landing: CGPoint(x: -1, y: 400))

func testMovingBackAtTheEdgeReturnsToTheOrigin() {
XCTAssertEqual(jump.step(at: CGPoint(x: -1, y: 402), delta: CGVector(dx: 2, dy: 0)), .back(CGPoint(x: 0, y: 100)))
}

/// Where both displays cover the spot, macOS takes the pointer back by itself, at the wrong height.
func testCrossedBackByMacOSReturnsToTheOrigin() {
XCTAssertEqual(jump.step(at: CGPoint(x: 1, y: 401), delta: CGVector(dx: 2, dy: 1)), .back(CGPoint(x: 0, y: 100)))
}

/// The push that caused the jump can still arrive after it.
func testContinuedPushWaits() {
XCTAssertEqual(jump.step(at: CGPoint(x: -3, y: 400), delta: CGVector(dx: -2, dy: 0)), .wait)
}

func testMovingBackInsideTheNewDisplayWaitsUntilTheEdge() {
XCTAssertEqual(jump.step(at: CGPoint(x: -20, y: 410), delta: CGVector(dx: 3, dy: 0)), .wait)
}

func testSlidingAlongTheEdgeWaits() {
XCTAssertEqual(jump.step(at: CGPoint(x: -1, y: 420), delta: CGVector(dx: 1, dy: 5)), .wait)
}

func testTooFarExpires() {
XCTAssertEqual(jump.step(at: CGPoint(x: -160, y: 420), delta: CGVector(dx: -2, dy: 0)), .expire)
}

/// A jump down onto a display below: moving back is moving up.
func testVerticalJump() {
let down = EdgeReturn(origin: CGPoint(x: 2400, y: 1439), from: CGRect(x: 0, y: 0, width: 2560, height: 1440),
landing: CGPoint(x: 1799, y: 1440))
XCTAssertEqual(down.step(at: CGPoint(x: 1799, y: 1440), delta: CGVector(dx: 0, dy: -2)), .back(CGPoint(x: 2400, y: 1439)))
XCTAssertEqual(down.step(at: CGPoint(x: 1799, y: 1442), delta: CGVector(dx: 0, dy: 2)), .wait)
}
}
Loading