import Foundation import RemoteCore /// Proposed normalized thresholds; these are not calibrated motion measurements. public struct GestureConfiguration: Sendable, Equatable { public let activationThreshold: Double public let neutralThreshold: Double public let minimumInterval: TimeInterval public let maximumArmedDuration: TimeInterval public let axisDominanceRatio: Double public init() { activationThreshold = 0.7 neutralThreshold = 0.25 minimumInterval = 0.3 maximumArmedDuration = 10 axisDominanceRatio = 0.6 } public enum ConfigurationError: Error { case invalidValues } public init(activationThreshold: Double, neutralThreshold: Double, minimumInterval: TimeInterval, maximumArmedDuration: TimeInterval, axisDominanceRatio: Double) throws { guard activationThreshold.isFinite, neutralThreshold.isFinite, minimumInterval.isFinite, maximumArmedDuration.isFinite, axisDominanceRatio.isFinite, activationThreshold > 0, activationThreshold <= 1, neutralThreshold >= 0, neutralThreshold < activationThreshold, minimumInterval >= 0, maximumArmedDuration > 0, axisDominanceRatio > 0, axisDominanceRatio < 1 else { throw ConfigurationError.invalidValues } self.activationThreshold = activationThreshold self.neutralThreshold = neutralThreshold self.minimumInterval = minimumInterval self.maximumArmedDuration = maximumArmedDuration self.axisDominanceRatio = axisDominanceRatio } } public struct GestureIntent: Sendable, Equatable { public let sessionID: UUID public let command: RemoteCommand /// Monotonic local seconds, never serialize this as a cross-device wall clock. public let timestamp: TimeInterval } /// A deterministic gate over already-normalized directional input, NOT a classifier /// of accelerometer/gyroscope samples or an implementation of Apple system gestures. /// x positive = right; y positive = up. An adapter must establish wrist orientation. public struct DirectionalGestureGate: Sendable { public let configuration: GestureConfiguration public private(set) var sessionID: UUID? private var armedAt: TimeInterval? private var lastSampleAt: TimeInterval? private var lastEmissionAt: TimeInterval? private var ready = false public init(configuration: GestureConfiguration = GestureConfiguration()) { self.configuration = configuration } /// Deliberate foreground action only. After arming, a neutral sample is required. public mutating func arm(sessionID: UUID, at timestamp: TimeInterval) { disarm() guard timestamp.isFinite else { return } self.sessionID = sessionID armedAt = timestamp } /// Call on app deactivation, reachability loss, TV/session change, or user cancel. public mutating func disarm() { sessionID = nil armedAt = nil lastSampleAt = nil lastEmissionAt = nil ready = false } public mutating func process(x: Double, y: Double, at timestamp: TimeInterval) -> GestureIntent? { guard let sessionID, let armedAt else { return nil } guard timestamp.isFinite, timestamp >= armedAt, timestamp - armedAt < configuration.maximumArmedDuration else { disarm() return nil } guard lastSampleAt.map({ timestamp > $0 }) ?? true else { return nil } lastSampleAt = timestamp guard x.isFinite, y.isFinite, abs(x) <= 1, abs(y) <= 1 else { // Invalid input requires neutral recovery and cannot preserve readiness. ready = false return nil } let magnitude = max(abs(x), abs(y)) if magnitude <= configuration.neutralThreshold { ready = true return nil } guard ready, magnitude >= configuration.activationThreshold else { return nil } // Every threshold excursion consumes readiness, including ambiguous diagonals // and debounce suppression. Holding an input can never trigger a late event. ready = false guard lastEmissionAt.map({ timestamp - $0 >= configuration.minimumInterval }) ?? true else { return nil } let command: RemoteCommand if abs(x) > abs(y), abs(y) <= abs(x) * configuration.axisDominanceRatio { command = x > 0 ? .right : .left } else if abs(y) > abs(x), abs(x) <= abs(y) * configuration.axisDominanceRatio { command = y > 0 ? .up : .down } else { return nil } lastEmissionAt = timestamp return GestureIntent(sessionID: sessionID, command: command, timestamp: timestamp) } }