diff --git a/Sources/NotchKit/MoriEyesView.swift b/Sources/NotchKit/MoriEyesView.swift index 3f59561..1da70be 100644 --- a/Sources/NotchKit/MoriEyesView.swift +++ b/Sources/NotchKit/MoriEyesView.swift @@ -41,11 +41,66 @@ private struct EyePose { } } -/// Two soft, glossy eyes that are always subtly alive: they breathe, glance -/// around, blink asymmetrically, follow the pointer — and *morph* smoothly from -/// one expression to the next rather than cutting. The per-frame look is derived -/// deterministically from the timeline clock; only the expression cross-fade -/// keeps a little state (when the last change happened). +/// The finite render budget for a pair of eyes. Stable faces have no scheduled +/// frames; expression changes and gestures receive a short burst that ends on a +/// deterministic static pose. This keeps an idle notch from invalidating its +/// transparent WindowServer surface forever. +struct EyeAnimationPlan: Equatable { + static let activeInterval: TimeInterval = 1.0 / 60.0 + static let lowPowerInterval: TimeInterval = 1.0 / 15.0 + static let morphDuration: TimeInterval = 0.34 + static let celebrationDuration: TimeInterval = 1.1 + static let winkDuration: TimeInterval = 0.36 + static let expressiveBurstDuration: TimeInterval = 1.0 + static let staticFrameTime: TimeInterval = 0 + + let deadline: Date? + let frameInterval: TimeInterval + + init( + expression: EyeExpression, + transitionStartedAt: Date?, + celebrateAt: Date?, + winkAt: Date?, + isLowPower: Bool + ) { + var deadlines: [Date] = [] + if let transitionStartedAt { + let duration: TimeInterval + switch expression { + case .alert, .listening, .speaking: + duration = Self.expressiveBurstDuration + default: + duration = Self.morphDuration + } + deadlines.append(transitionStartedAt.addingTimeInterval(duration)) + } + if let celebrateAt { + deadlines.append(celebrateAt.addingTimeInterval(Self.celebrationDuration)) + } + if let winkAt { + deadlines.append(winkAt.addingTimeInterval(Self.winkDuration)) + } + deadline = deadlines.max() + frameInterval = isLowPower ? Self.lowPowerInterval : Self.activeInterval + } + + /// A trailing frame at the exact deadline parks the face on its static + /// pose. Once this finite list is exhausted, SwiftUI has nothing to tick. + func frames(from now: Date) -> [Date] { + guard let deadline, deadline > now else { return [] } + return Array(stride(from: now, to: deadline, by: frameInterval)) + [deadline] + } + + func renderTime(for date: Date) -> TimeInterval { + guard let deadline, date < deadline else { return Self.staticFrameTime } + return date.timeIntervalSinceReferenceDate + } +} + +/// Two soft, glossy eyes that briefly come alive for state changes, gestures +/// and attention, then settle into a static pose instead of redrawing forever. +/// Every animated frame is derived deterministically from a finite timeline. public struct MoriEyesView: View { let expression: EyeExpression let color: Color @@ -61,18 +116,8 @@ public struct MoriEyesView: View { @State private var previousExpression: EyeExpression? @State private var transitionStart: Date = .distantPast - /// The live frame budget. It is held rather than derived on the fly - /// because the step back *down* is announced by nothing — a transient - /// window simply expires — so something has to write it. - @State private var frameInterval: TimeInterval = MoriEyesView.restingInterval @State private var isLowPower = ProcessInfo.processInfo.isLowPowerModeEnabled - private static let morphDuration: TimeInterval = 0.34 - /// How long the celebration bob, pop and sparkles stay in flight. - private static let celebrationDuration: TimeInterval = 1.1 - /// The one-eyed wink lid ramp. - private static let winkDuration: TimeInterval = 0.36 - public init(status: SessionStatus?, color: Color, pointerBias: CGPoint = .zero, celebrateAt: Date? = nil, winkAt: Date? = nil) { self.expression = EyeExpression(status: status) self.color = color @@ -90,74 +135,38 @@ public struct MoriEyesView: View { } public var body: some View { - TimelineView(.animation(minimumInterval: frameInterval, paused: false)) { timeline in - Canvas { context, size in - draw(context: &context, size: size, time: timeline.date.timeIntervalSinceReferenceDate) + let plan = EyeAnimationPlan( + expression: expression, + transitionStartedAt: previousExpression == nil ? nil : transitionStart, + celebrateAt: celebrateAt, + winkAt: winkAt, + isLowPower: isLowPower + ) + let frames = plan.frames(from: Date()) + + Group { + if frames.isEmpty { + eyes(at: EyeAnimationPlan.staticFrameTime) + } else { + TimelineView(.explicit(frames)) { timeline in + eyes(at: plan.renderTime(for: timeline.date)) + } } } .onChange(of: expression) { old, _ in previousExpression = old transitionStart = Date() } - .task(id: transientDeadline) { await rideOutTransientMotion() } .onReceive(NotificationCenter.default.publisher(for: .NSProcessInfoPowerStateDidChange)) { _ in isLowPower = ProcessInfo.processInfo.isLowPowerModeEnabled - frameInterval = resolvedFrameInterval } .accessibilityLabel(accessibilityText) } - // MARK: - Frame budget - - /// Fast rate, reserved for motion that genuinely needs it: expression - /// morphs, celebrations, winks, and the expressions that carry their own - /// sub-second oscillation. - private static let activeInterval: TimeInterval = 1.0 / 60.0 - /// The resting rate. Breathing (a 4.3s sine) and gaze drift (saccades eased - /// over ~0.5s) read identically at 30fps, at half the redraw cost — and this - /// view is instantiated once per visible session row, so the saving scales. - private static let restingInterval: TimeInterval = 1.0 / 30.0 - /// On battery saver the face keeps moving, just more coarsely. - private static let lowPowerInterval: TimeInterval = 1.0 / 15.0 - - /// Expressions that animate faster than the resting rate can resolve: the - /// speaking lid (0.42s period), the alert jitter (5.5Hz) and the listening - /// ring (0.9s period). - private var hasContinuousMotion: Bool { - expression == .speaking || expression == .alert || expression == .listening - } - - /// When the last in-flight transient motion ends, or nil when none is. - /// The windows are the ones the draw helpers already use: `morphDuration` - /// for the expression cross-fade, 1.1s for `celebrationElapsed`, and the - /// 0.36s wink ramp in `blinkClose`. - private var transientDeadline: Date? { - let now = Date() - let deadlines = [ - previousExpression == nil ? nil : transitionStart.addingTimeInterval(Self.morphDuration), - celebrateAt?.addingTimeInterval(Self.celebrationDuration), - winkAt?.addingTimeInterval(Self.winkDuration) - ].compactMap { $0 } - return deadlines.filter { $0 > now }.max() - } - - private var resolvedFrameInterval: TimeInterval { - if hasContinuousMotion || transientDeadline != nil { return Self.activeInterval } - if isLowPower { return Self.lowPowerInterval } - return Self.restingInterval - } - - /// Takes the budget up for whatever motion just started, then waits the - /// window out and puts it back down — the expiry itself changes no input, - /// so this is the only thing that can notice it. - private func rideOutTransientMotion() async { - frameInterval = resolvedFrameInterval - guard let deadline = transientDeadline else { return } - let remaining = deadline.timeIntervalSinceNow - guard remaining > 0 else { return } - try? await Task.sleep(nanoseconds: UInt64(remaining * 1_000_000_000)) - guard !Task.isCancelled else { return } - frameInterval = resolvedFrameInterval + private func eyes(at time: TimeInterval) -> some View { + Canvas { context, size in + draw(context: &context, size: size, time: time) + } } // MARK: - Frame @@ -215,8 +224,8 @@ public struct MoriEyesView: View { let target = pose(for: expression, at: time) guard let previous = previousExpression else { return target } let elapsed = time - transitionStart.timeIntervalSinceReferenceDate - guard elapsed >= 0, elapsed < Self.morphDuration else { return target } - let t = CGFloat(smoothstep(elapsed / Self.morphDuration)) + guard elapsed >= 0, elapsed < EyeAnimationPlan.morphDuration else { return target } + let t = CGFloat(smoothstep(elapsed / EyeAnimationPlan.morphDuration)) return EyePose.lerp(pose(for: previous, at: time), target, t) } @@ -257,7 +266,7 @@ public struct MoriEyesView: View { private func celebrationElapsed(at time: TimeInterval) -> Double? { guard let celebrateAt else { return nil } let elapsed = time - celebrateAt.timeIntervalSinceReferenceDate - return (elapsed >= 0 && elapsed < Self.celebrationDuration) ? elapsed : nil + return (elapsed >= 0 && elapsed < EyeAnimationPlan.celebrationDuration) ? elapsed : nil } private func drawEffects(context: inout GraphicsContext, size: CGSize, time: TimeInterval) { @@ -335,7 +344,7 @@ public struct MoriEyesView: View { // reopens over ~0.36s, reusing the blink ramp so it reads as the same // lid motion. The left eye stays on its normal schedule. if eyeIndex == 1, let winkAt { - let duration = Self.winkDuration + let duration = EyeAnimationPlan.winkDuration let t = time - winkAt.timeIntervalSinceReferenceDate if t > 0, t < duration { let progress = t / duration diff --git a/Tests/NotchKitTests/EyeAnimationPlanTests.swift b/Tests/NotchKitTests/EyeAnimationPlanTests.swift new file mode 100644 index 0000000..040a2db --- /dev/null +++ b/Tests/NotchKitTests/EyeAnimationPlanTests.swift @@ -0,0 +1,94 @@ +import Foundation +import XCTest +@testable import NotchKit + +final class EyeAnimationPlanTests: XCTestCase { + private let now = Date(timeIntervalSinceReferenceDate: 10_000) + + func testStableFaceSchedulesNoFrames() { + let plan = EyeAnimationPlan( + expression: .sleepy, + transitionStartedAt: nil, + celebrateAt: nil, + winkAt: nil, + isLowPower: false + ) + + XCTAssertNil(plan.deadline) + XCTAssertTrue(plan.frames(from: now).isEmpty) + XCTAssertEqual(plan.renderTime(for: now), EyeAnimationPlan.staticFrameTime) + } + + func testNormalTransitionHasFiniteScheduleAndStaticTrailingFrame() throws { + let plan = EyeAnimationPlan( + expression: .awake, + transitionStartedAt: now, + celebrateAt: nil, + winkAt: nil, + isLowPower: false + ) + + let deadline = try XCTUnwrap(plan.deadline) + let frames = plan.frames(from: now) + XCTAssertEqual(deadline.timeIntervalSince(now), EyeAnimationPlan.morphDuration, accuracy: 0.001) + XCTAssertEqual(try XCTUnwrap(frames.last), deadline) + XCTAssertEqual(plan.renderTime(for: deadline), EyeAnimationPlan.staticFrameTime) + XCTAssertTrue(plan.frames(from: deadline).isEmpty) + } + + func testAttentionAnimationIsBounded() throws { + let plan = EyeAnimationPlan( + expression: .alert, + transitionStartedAt: now, + celebrateAt: nil, + winkAt: nil, + isLowPower: false + ) + + let deadline = try XCTUnwrap(plan.deadline) + XCTAssertEqual( + deadline.timeIntervalSince(now), + EyeAnimationPlan.expressiveBurstDuration, + accuracy: 0.001 + ) + XCTAssertTrue(plan.frames(from: deadline).isEmpty) + } + + func testLatestGestureControlsDeadline() throws { + let plan = EyeAnimationPlan( + expression: .awake, + transitionStartedAt: now, + celebrateAt: now.addingTimeInterval(0.2), + winkAt: now.addingTimeInterval(0.4), + isLowPower: false + ) + + let deadline = try XCTUnwrap(plan.deadline) + XCTAssertEqual( + deadline.timeIntervalSince(now), + 0.2 + EyeAnimationPlan.celebrationDuration, + accuracy: 0.001 + ) + } + + func testLowPowerModeCapsEveryAnimatedBurst() { + let normal = EyeAnimationPlan( + expression: .alert, + transitionStartedAt: now, + celebrateAt: nil, + winkAt: nil, + isLowPower: false + ) + let lowPower = EyeAnimationPlan( + expression: .alert, + transitionStartedAt: now, + celebrateAt: nil, + winkAt: nil, + isLowPower: true + ) + + XCTAssertEqual(normal.frameInterval, EyeAnimationPlan.activeInterval) + XCTAssertEqual(lowPower.frameInterval, EyeAnimationPlan.lowPowerInterval) + XCTAssertLessThan(lowPower.frames(from: now).count, normal.frames(from: now).count) + } +}