Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 84 additions & 75 deletions Sources/NotchKit/MoriEyesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
94 changes: 94 additions & 0 deletions Tests/NotchKitTests/EyeAnimationPlanTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading