Files
sanderling/companion/Sources/Gesture.swift
T

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()
}
}