mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
207 lines
8.8 KiB
Swift
207 lines
8.8 KiB
Swift
import XCTest
|
|
import CoreGraphics
|
|
|
|
// Synthesizes one timestamped event record from a list of primitive events.
|
|
//
|
|
// The synthesizer constrains how precise timing can be expressed:
|
|
// - Within one pointer path, event offsets are honored to the millisecond,
|
|
// but nothing after the path's first lift is delivered: a second press is
|
|
// silently dropped, whether added through the press helper or as a raw
|
|
// pointer event.
|
|
// - Across paths, every event is delivered, but each path's timeline is
|
|
// normalized to its own first event, so a later start offset collapses.
|
|
//
|
|
// Sequential taps therefore become one path per tap, each anchored by a raw
|
|
// zero-offset move event at the tap point: the anchor pins the path's
|
|
// timeline origin to the record's, turning the tap's absolute press offset
|
|
// into an in-path delta, which is honored. This preserves the inter-tap gap
|
|
// to the millisecond.
|
|
enum Gesture {
|
|
enum GestureError: Error {
|
|
case missingField(String)
|
|
case unknownKind(String)
|
|
case touchUpWithoutTouchDown
|
|
case touchDownWhileTouchActive
|
|
case synthesisFailed(String)
|
|
}
|
|
|
|
// minimumHoldSeconds keeps a tap's press observable when its down and up
|
|
// arrive at the same offset; far below any long-press threshold.
|
|
private static let minimumHoldSeconds = 0.03
|
|
|
|
// pathGapSeconds orders a press strictly after the previous lift when a
|
|
// requested gap is shorter than the minimum hold.
|
|
private static let pathGapSeconds = 0.005
|
|
|
|
// The raw shape of a touch move, read once from a probe path built with
|
|
// the path helpers so no private enum values are hardcoded.
|
|
private struct MoveEventShape {
|
|
let type: Int
|
|
let button: Int
|
|
let clicks: UInt
|
|
}
|
|
|
|
private static let moveShape: MoveEventShape = {
|
|
let probe = XCPointerEventPath(forTouchAt: CGPoint(x: 1, y: 1), offset: 0)
|
|
probe.move(to: CGPoint(x: 2, y: 2), atOffset: minimumHoldSeconds)
|
|
probe.liftUp(atOffset: minimumHoldSeconds * 2)
|
|
let events = probe.pointerEvents
|
|
let move = events.count > 1 ? events[1] : nil
|
|
return MoveEventShape(
|
|
type: move?.eventType ?? 2,
|
|
button: move?.buttonType ?? 0,
|
|
clicks: move.map { UInt($0.clickCount) } ?? 0
|
|
)
|
|
}()
|
|
|
|
// probe reports the raw events the path helpers produce, for diagnostics.
|
|
static func probe() -> [String: Any] {
|
|
let path = XCPointerEventPath(forTouchAt: CGPoint(x: 1, y: 2), offset: 0)
|
|
path.move(to: CGPoint(x: 3, y: 4), atOffset: 0.05)
|
|
path.liftUp(atOffset: 0.1)
|
|
return [
|
|
"descriptions": path.pointerEvents.map { $0.description },
|
|
"types": path.pointerEvents.map { $0.eventType },
|
|
"buttons": path.pointerEvents.map { $0.buttonType },
|
|
"offsets": path.pointerEvents.map { $0.offset },
|
|
]
|
|
}
|
|
|
|
// touchSegment is one press-to-lift span at absolute record offsets.
|
|
private struct touchSegment {
|
|
var downPoint: CGPoint
|
|
var downOffset: Double
|
|
var upPoint: CGPoint
|
|
var upOffset: Double
|
|
var movePoint: CGPoint?
|
|
var moveOffset: Double = 0
|
|
}
|
|
|
|
static func perform(events: [[String: Any]]) throws {
|
|
var offset = 0.0
|
|
var lastLiftOffset = -1.0
|
|
var segments: [touchSegment] = []
|
|
var active: touchSegment?
|
|
|
|
func point(_ event: [String: Any], _ xKey: String, _ yKey: String) throws -> CGPoint {
|
|
guard let x = event[xKey] as? Double, let y = event[yKey] as? Double else {
|
|
throw GestureError.missingField("\(xKey)/\(yKey)")
|
|
}
|
|
return CGPoint(x: x, y: y)
|
|
}
|
|
|
|
for event in events {
|
|
guard let kind = event["kind"] as? String else {
|
|
throw GestureError.missingField("kind")
|
|
}
|
|
switch kind {
|
|
case "touchDown":
|
|
guard active == nil else {
|
|
throw GestureError.touchDownWhileTouchActive
|
|
}
|
|
let location = try point(event, "x", "y")
|
|
let downOffset = max(offset, lastLiftOffset + Gesture.pathGapSeconds)
|
|
active = touchSegment(
|
|
downPoint: location, downOffset: downOffset,
|
|
upPoint: location, upOffset: downOffset + Gesture.minimumHoldSeconds)
|
|
case "touchUp":
|
|
guard var segment = active else {
|
|
throw GestureError.touchUpWithoutTouchDown
|
|
}
|
|
segment.upPoint = try point(event, "x", "y")
|
|
segment.upOffset = max(offset, segment.downOffset + Gesture.minimumHoldSeconds)
|
|
lastLiftOffset = segment.upOffset
|
|
segments.append(segment)
|
|
active = nil
|
|
case "delay":
|
|
guard let milliseconds = event["milliseconds"] as? Double else {
|
|
throw GestureError.missingField("milliseconds")
|
|
}
|
|
offset += milliseconds / 1000.0
|
|
case "swipe":
|
|
guard active == nil else {
|
|
throw GestureError.touchDownWhileTouchActive
|
|
}
|
|
let from = try point(event, "fromX", "fromY")
|
|
let to = try point(event, "toX", "toY")
|
|
guard let seconds = event["seconds"] as? Double else {
|
|
throw GestureError.missingField("seconds")
|
|
}
|
|
let downOffset = max(offset, lastLiftOffset + Gesture.pathGapSeconds)
|
|
segments.append(touchSegment(
|
|
downPoint: from, downOffset: downOffset,
|
|
upPoint: to, upOffset: downOffset + seconds,
|
|
movePoint: to, moveOffset: downOffset + seconds))
|
|
lastLiftOffset = downOffset + seconds
|
|
offset = downOffset + seconds
|
|
default:
|
|
throw GestureError.unknownKind(kind)
|
|
}
|
|
}
|
|
if let segment = active {
|
|
// A down without an up would leave a stuck touch on screen.
|
|
segments.append(segment)
|
|
}
|
|
guard !segments.isEmpty else {
|
|
throw GestureError.missingField("events")
|
|
}
|
|
|
|
// The synthesis stack raises ObjC exceptions for invalid paths; bridge
|
|
// them into a thrown error so the server survives a bad record.
|
|
var caughtException: NSError?
|
|
var synthesisError: Error?
|
|
let completed = CompanionRunCatching({
|
|
do {
|
|
try Gesture.synthesize(segments: segments)
|
|
} catch {
|
|
synthesisError = error
|
|
}
|
|
}, &caughtException)
|
|
if !completed {
|
|
throw GestureError.synthesisFailed(caughtException?.localizedDescription ?? "unknown exception")
|
|
}
|
|
if let synthesisError = synthesisError {
|
|
throw synthesisError
|
|
}
|
|
}
|
|
|
|
private static func synthesize(segments: [touchSegment]) throws {
|
|
let record = XCSynthesizedEventRecord(name: "companion", interfaceOrientation: 0)
|
|
for (index, segment) in segments.enumerated() {
|
|
// Paths play back one after another with their start offsets
|
|
// normalized away, so each path carries its events relative to its
|
|
// own press. A trailing hover move then stretches the path out to
|
|
// the next press's absolute offset, which preserves the requested
|
|
// inter-tap gap under sequential playback.
|
|
let base = segment.downOffset
|
|
let path = XCPointerEventPath(forTouchAt: segment.downPoint, offset: 0)
|
|
// Distinct pointer identities: without this, sequential taps at
|
|
// the same point merge into one multi-tap gesture and the app
|
|
// sees a single click no matter the gap.
|
|
path.index = UInt64(index)
|
|
if let movePoint = segment.movePoint {
|
|
path.move(to: movePoint, atOffset: segment.moveOffset - base)
|
|
}
|
|
let upDelta = segment.upOffset - base
|
|
path.liftUp(atOffset: upDelta)
|
|
if index + 1 < segments.count {
|
|
let tailDelta = segments[index + 1].downOffset - base
|
|
if tailDelta > upDelta {
|
|
path._addPointerEvent(XCPointerEvent(
|
|
type: Gesture.moveShape.type,
|
|
buttonType: Gesture.moveShape.button,
|
|
coordinate: segment.upPoint,
|
|
offset: tailDelta,
|
|
clickCount: Gesture.moveShape.clicks))
|
|
}
|
|
}
|
|
record.add(path)
|
|
}
|
|
|
|
// Synchronous delivery: returns whether the event was synthesized and
|
|
// populates the error out-pointer on failure. This avoids the async
|
|
// completion-block path.
|
|
try record.synthesize()
|
|
}
|
|
}
|