From f7f534293110eafd9d40d2556240868f14e18389 Mon Sep 17 00:00:00 2001 From: Toni Soriano Date: Mon, 10 Aug 2026 12:06:16 +0200 Subject: [PATCH] Pause idle eye rendering Replace the unbounded eye animation timeline with finite schedules for state transitions and gestures. Stable faces render once, and Low Power Mode caps every animated burst. Add policy tests covering idle, transitions, attention, overlapping gestures, and low-power frame budgets. --- Sources/NotchKit/MoriEyesView.swift | 159 +++++++++--------- .../NotchKitTests/EyeAnimationPlanTests.swift | 94 +++++++++++ 2 files changed, 178 insertions(+), 75 deletions(-) create mode 100644 Tests/NotchKitTests/EyeAnimationPlanTests.swift 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) + } +}