import type { WidgetLayoutCubicBezier, WidgetLayoutSize, WidgetLayoutSizeUpdate, WidgetLayoutTimedSizeUpdate, } from "./widget-layout-size-update.js"; import { toInstantWidgetLayoutSizeUpdate, toTimedWidgetLayoutSizeUpdate, } from "./widget-layout-size-update.js"; export type WidgetLayoutComparableSizeUpdate = | WidgetLayoutSizeUpdate | WidgetLayoutTimedSizeUpdate; export type WidgetLayoutUnchangedTransitionPlan< Update extends WidgetLayoutComparableSizeUpdate = WidgetLayoutComparableSizeUpdate, > = { kind: "unchanged"; size: WidgetLayoutSize; update: Update; observedAtMs: number; }; export type WidgetLayoutInstantTransitionPlan = { kind: "instant"; size: WidgetLayoutSize; update: WidgetLayoutSizeUpdate; startedAtMs: number; settledAtMs: number; }; export type WidgetLayoutTimedTransitionPlan = { kind: "timed"; size: WidgetLayoutSize; update: WidgetLayoutTimedSizeUpdate; startedAtMs: number; settledAtMs: number; }; export type WidgetLayoutInstantOnlyTransitionPlan = | WidgetLayoutUnchangedTransitionPlan | WidgetLayoutInstantTransitionPlan; export type WidgetLayoutTransitionPlan = WidgetLayoutInstantOnlyTransitionPlan; export type WidgetLayoutTimedCapableTransitionPlan = | WidgetLayoutUnchangedTransitionPlan | WidgetLayoutInstantTransitionPlan | WidgetLayoutTimedTransitionPlan; export type WidgetLayoutInstantTransitionPolicy = { kind: "instant"; durationMs: 0; }; export type WidgetLayoutTimedTransitionPolicy = { kind: "timed"; durationMs: number; easing: WidgetLayoutCubicBezier; }; export type WidgetLayoutTransitionPolicy = | WidgetLayoutInstantTransitionPolicy | WidgetLayoutTimedTransitionPolicy; /** * The default coordinated layout transition shared by host, View, and * authoring surfaces. Keep this policy in the protocol so each runtime uses * the same timeline and easing when it participates in a layout update. */ export const DEFAULT_WIDGET_LAYOUT_TRANSITION_DURATION_MS = 260; export const DEFAULT_WIDGET_LAYOUT_TRANSITION_EASING = Object.freeze([ 0.22, 1, 0.36, 1, ] as const satisfies WidgetLayoutCubicBezier); export const DEFAULT_WIDGET_LAYOUT_TRANSITION_POLICY = Object.freeze({ kind: "timed", durationMs: DEFAULT_WIDGET_LAYOUT_TRANSITION_DURATION_MS, easing: DEFAULT_WIDGET_LAYOUT_TRANSITION_EASING, }) satisfies Readonly; /** Internal timing for one coordinated host/View layout transaction. */ export type WidgetLayoutTransitionSchedule = { sequence: number; transactionId: string; startAtEpochMs: number; }; // Leaves enough time for independent host and View render pipelines to arm the // same transaction before either animation starts. export const DEFAULT_WIDGET_LAYOUT_TRANSITION_LEAD_TIME_MS = 96; export function createWidgetLayoutTransitionSchedule(input: { sequence: number; transactionId: string; nowEpochMs: number; leadTimeMs?: number; }): WidgetLayoutTransitionSchedule { const leadTimeMs = input.leadTimeMs ?? DEFAULT_WIDGET_LAYOUT_TRANSITION_LEAD_TIME_MS; if ( !Number.isSafeInteger(input.sequence) || input.sequence < 0 || input.transactionId.trim().length === 0 || !Number.isFinite(input.nowEpochMs) || !Number.isFinite(leadTimeMs) ) { throw new RangeError( "Widget layout transition schedules require an identity and finite times", ); } return { sequence: input.sequence, transactionId: input.transactionId, startAtEpochMs: input.nowEpochMs + Math.max(0, leadTimeMs), }; } export function resolveWidgetLayoutTransitionDelayMs( schedule: WidgetLayoutTransitionSchedule | undefined, nowEpochMs: number, ): number { if (!schedule || !Number.isFinite(nowEpochMs)) { return 0; } return Math.max(0, schedule.startAtEpochMs - nowEpochMs); } export function resolveWidgetLayoutTransitionExecution(input: { schedule?: WidgetLayoutTransitionSchedule; durationMs: number; nowEpochMs: number; }): { delayMs: number; durationMs: number; elapsedMs: number; isComplete: boolean; timelineOffsetMs: number; } { const totalDurationMs = Number.isFinite(input.durationMs) && input.durationMs > 0 ? input.durationMs : 0; const delayMs = resolveWidgetLayoutTransitionDelayMs( input.schedule, input.nowEpochMs, ); const elapsedMs = input.schedule ? Math.min( totalDurationMs, Math.max(0, input.nowEpochMs - input.schedule.startAtEpochMs), ) : 0; const durationMs = totalDurationMs - elapsedMs; return { delayMs, durationMs, elapsedMs, isComplete: durationMs === 0, timelineOffsetMs: delayMs > 0 ? delayMs : -elapsedMs, }; } const RESUMED_EASING_PROGRESS_EPSILON = 1e-7; function cubicBezierCoordinate( t: number, controlPoint1: number, controlPoint2: number, ): number { const inverse = 1 - t; return ( 3 * inverse * inverse * t * controlPoint1 + 3 * inverse * t * t * controlPoint2 + t * t * t ); } /** Resolves the output of a CSS cubic-bezier timing function at a time ratio. */ export function resolveWidgetLayoutCubicBezierProgress( easing: readonly [number, number, number, number], timeProgress: number, ): number { const clampedTimeProgress = Math.min(1, Math.max(0, timeProgress)); if (clampedTimeProgress === 0 || clampedTimeProgress === 1) { return clampedTimeProgress; } let lower = 0; let upper = 1; for (let iteration = 0; iteration < 24; iteration += 1) { const midpoint = (lower + upper) / 2; const x = cubicBezierCoordinate(midpoint, easing[0], easing[2]); if (x < clampedTimeProgress) { lower = midpoint; } else { upper = midpoint; } } return cubicBezierCoordinate((lower + upper) / 2, easing[1], easing[3]); } export function resolveWidgetLayoutResumedEasing(input: { easing: readonly [number, number, number, number]; elapsedProgress: number; remainingProgress: number; }): { initialProgress: number; progress: number } { const elapsedProgress = Math.min(1, Math.max(0, input.elapsedProgress)); const initialProgress = resolveWidgetLayoutCubicBezierProgress( input.easing, elapsedProgress, ); if (elapsedProgress === 0) { return { initialProgress: 0, progress: resolveWidgetLayoutCubicBezierProgress( input.easing, input.remainingProgress, ), }; } const denominator = 1 - initialProgress; const stableDenominator = Math.abs(denominator) < RESUMED_EASING_PROGRESS_EPSILON ? denominator < 0 ? -RESUMED_EASING_PROGRESS_EPSILON : RESUMED_EASING_PROGRESS_EPSILON : denominator; const stableInitialProgress = 1 - stableDenominator; const timelineProgress = elapsedProgress + (1 - elapsedProgress) * Math.min(1, Math.max(0, input.remainingProgress)); const timelineEasedProgress = resolveWidgetLayoutCubicBezierProgress( input.easing, timelineProgress, ); return { initialProgress: stableInitialProgress, progress: (timelineEasedProgress - stableInitialProgress) / stableDenominator, }; } export const INSTANT_WIDGET_LAYOUT_TRANSITION_POLICY: WidgetLayoutInstantTransitionPolicy = { kind: "instant", durationMs: 0, }; export function getWidgetLayoutPlanNowMs(): number { const performanceNow = typeof globalThis.performance?.now === "function" ? globalThis.performance.now() : undefined; return typeof performanceNow === "number" && Number.isFinite(performanceNow) ? performanceNow : Date.now(); } export function areWidgetLayoutSizeUpdatesEqual( left: WidgetLayoutComparableSizeUpdate | undefined, right: WidgetLayoutComparableSizeUpdate, ): boolean { if ( left?.size.width !== right.size.width || left.size.height !== right.size.height || left.transition.kind !== right.transition.kind || left.transition.durationMs !== right.transition.durationMs ) { return false; } const leftTransition = left.transition; const rightTransition = right.transition; switch (rightTransition.kind) { case "instant": return true; case "timed": if (leftTransition.kind !== "timed") { return false; } return ( leftTransition.easing.kind === rightTransition.easing.kind && leftTransition.easing.points.length === rightTransition.easing.points.length && leftTransition.easing.points.every( (point, index) => point === rightTransition.easing.points[index], ) ); default: { const unreachable: never = rightTransition; throw new Error( `Unsupported widget layout transition: ${JSON.stringify(unreachable)}`, ); } } } function cloneWidgetLayoutSizeUpdate( update: WidgetLayoutSizeUpdate, ): WidgetLayoutSizeUpdate; function cloneWidgetLayoutSizeUpdate( update: WidgetLayoutTimedSizeUpdate, ): WidgetLayoutTimedSizeUpdate; function cloneWidgetLayoutSizeUpdate( update: WidgetLayoutComparableSizeUpdate, ): WidgetLayoutComparableSizeUpdate; function cloneWidgetLayoutSizeUpdate( update: WidgetLayoutComparableSizeUpdate, ): WidgetLayoutComparableSizeUpdate { if (update.transition.kind === "timed") { return { size: { ...update.size }, transition: { ...update.transition, easing: { ...update.transition.easing, points: [...update.transition.easing.points], }, }, }; } return { size: { ...update.size }, transition: { ...update.transition }, }; } function isWidgetLayoutTimedSizeUpdate( update: WidgetLayoutComparableSizeUpdate, ): update is WidgetLayoutTimedSizeUpdate { return update.transition.kind === "timed"; } function toWidgetLayoutSizeUpdateForPolicy(input: { policy: WidgetLayoutTransitionPolicy | undefined; size: WidgetLayoutSize; }): WidgetLayoutComparableSizeUpdate { const policy = input.policy ?? INSTANT_WIDGET_LAYOUT_TRANSITION_POLICY; switch (policy.kind) { case "instant": return toInstantWidgetLayoutSizeUpdate(input.size); case "timed": return toTimedWidgetLayoutSizeUpdate({ size: input.size, durationMs: policy.durationMs, easing: policy.easing, }); default: { const unreachable: never = policy; throw new Error( `Unsupported widget layout transition policy: ${JSON.stringify(unreachable)}`, ); } } } type WidgetLayoutInstantUpdatePlanInput = { previous?: WidgetLayoutSizeUpdate; next: WidgetLayoutSizeUpdate; nowMs: number; }; type WidgetLayoutTimedCapableUpdatePlanInput = { previous?: WidgetLayoutComparableSizeUpdate; next: WidgetLayoutComparableSizeUpdate; nowMs: number; }; type WidgetLayoutInstantPolicyPlanInput = { previous?: WidgetLayoutComparableSizeUpdate; size: WidgetLayoutSize; nowMs: number; transitionPolicy?: WidgetLayoutInstantTransitionPolicy; }; type WidgetLayoutTimedCapablePolicyPlanInput = { previous?: WidgetLayoutComparableSizeUpdate; size: WidgetLayoutSize; nowMs: number; transitionPolicy: WidgetLayoutTimedTransitionPolicy; }; type WidgetLayoutOptionalPolicyPlanInput = { previous?: WidgetLayoutComparableSizeUpdate; size: WidgetLayoutSize; nowMs: number; transitionPolicy?: WidgetLayoutTransitionPolicy; }; export function planWidgetLayoutTransition(input: { previous?: WidgetLayoutSizeUpdate; next: WidgetLayoutSizeUpdate; nowMs: number; }): WidgetLayoutInstantOnlyTransitionPlan; export function planWidgetLayoutTransition(input: { previous?: WidgetLayoutComparableSizeUpdate; next: WidgetLayoutComparableSizeUpdate; nowMs: number; }): WidgetLayoutTimedCapableTransitionPlan; export function planWidgetLayoutTransition( input: WidgetLayoutInstantPolicyPlanInput, ): WidgetLayoutInstantOnlyTransitionPlan; export function planWidgetLayoutTransition( input: WidgetLayoutTimedCapablePolicyPlanInput, ): WidgetLayoutTimedCapableTransitionPlan; export function planWidgetLayoutTransition( input: WidgetLayoutOptionalPolicyPlanInput, ): WidgetLayoutTimedCapableTransitionPlan; export function planWidgetLayoutTransition( input: | WidgetLayoutInstantUpdatePlanInput | WidgetLayoutTimedCapableUpdatePlanInput | WidgetLayoutInstantPolicyPlanInput | WidgetLayoutTimedCapablePolicyPlanInput | WidgetLayoutOptionalPolicyPlanInput, ): WidgetLayoutTimedCapableTransitionPlan { if (!Number.isFinite(input.nowMs)) { throw new RangeError("Widget layout transition plans require finite nowMs"); } const next = "next" in input ? input.next : toWidgetLayoutSizeUpdateForPolicy({ size: input.size, policy: input.transitionPolicy, }); if (areWidgetLayoutSizeUpdatesEqual(input.previous, next)) { return { kind: "unchanged", size: { ...next.size }, update: cloneWidgetLayoutSizeUpdate(next), observedAtMs: input.nowMs, }; } if (isWidgetLayoutTimedSizeUpdate(next)) { return { kind: "timed", size: { ...next.size }, update: cloneWidgetLayoutSizeUpdate(next), startedAtMs: input.nowMs, settledAtMs: input.nowMs + next.transition.durationMs, }; } return { kind: "instant", size: { ...next.size }, update: cloneWidgetLayoutSizeUpdate(next), startedAtMs: input.nowMs, settledAtMs: input.nowMs, }; }