Merge "Add TrackTracer to trace to a single perfetto track" into main
diff --git a/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.java b/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.java
index 480061a..c7875f6 100644
--- a/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.java
+++ b/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.java
@@ -15,6 +15,8 @@
*/
package com.android.launcher3.icons;
+import static com.android.launcher3.icons.cache.CacheLookupFlag.DEFAULT_LOOKUP_FLAG;
+
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Bitmap.Config;
@@ -25,6 +27,7 @@
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import com.android.launcher3.icons.cache.CacheLookupFlag;
import com.android.launcher3.util.FlagOp;
public class BitmapInfo {
@@ -121,6 +124,13 @@
}
/**
+ * Returns the lookup flag to match this current state of this info
+ */
+ public CacheLookupFlag getMatchingLookupFlag() {
+ return DEFAULT_LOOKUP_FLAG.withUseLowRes(isLowRes());
+ }
+
+ /**
* BitmapInfo can be stored on disk or other persistent storage
*/
public boolean canPersist() {
diff --git a/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.java b/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.java
index 959f14d..c08efd9 100644
--- a/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.java
+++ b/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.java
@@ -49,7 +49,6 @@
import android.util.Log;
import android.util.SparseArray;
-import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
@@ -65,8 +64,6 @@
import com.android.launcher3.util.FlagOp;
import com.android.launcher3.util.SQLiteCacheHelper;
-import java.lang.annotation.Retention;
-import java.lang.annotation.RetentionPolicy;
import java.util.AbstractMap;
import java.util.Arrays;
import java.util.Collections;
@@ -88,37 +85,6 @@
// Empty class name is used for storing package default entry.
public static final String EMPTY_CLASS_NAME = ".";
- @Retention(RetentionPolicy.SOURCE)
- @IntDef(value = {
- LookupFlag.DEFAULT,
- LookupFlag.USE_LOW_RES,
- LookupFlag.USE_PACKAGE_ICON,
- LookupFlag.SKIP_ADD_TO_MEM_CACHE
- }, flag = true)
- /** Various options to control cache lookup */
- public @interface LookupFlag {
- /**
- * Default behavior of cache lookup is to load high-res icon with no fallback
- */
- int DEFAULT = 0;
-
- /**
- * When specified, the cache tries to load the low res version of the entry unless a
- * high-res is already in memory
- */
- int USE_LOW_RES = 1 << 0;
- /**
- * When specified, the cache tries to lookup the package entry for the item, if the object
- * entry fails
- */
- int USE_PACKAGE_ICON = 1 << 1;
- /**
- * When specified, the entry will not be added to the memory cache if it was not already
- * added by a previous lookup
- */
- int SKIP_ADD_TO_MEM_CACHE = 1 << 2;
- }
-
public static class CacheEntry {
@NonNull
@@ -404,7 +370,7 @@
protected <T> CacheEntry cacheLocked(
@NonNull final ComponentName componentName, @NonNull final UserHandle user,
@NonNull final Supplier<T> infoProvider, @NonNull final CachingLogic<T> cachingLogic,
- @LookupFlag int lookupFlags) {
+ @NonNull CacheLookupFlag lookupFlags) {
return cacheLocked(
componentName,
user,
@@ -418,14 +384,13 @@
protected <T> CacheEntry cacheLocked(
@NonNull final ComponentName componentName, @NonNull final UserHandle user,
@NonNull final Supplier<T> infoProvider, @NonNull final CachingLogic<T> cachingLogic,
- @LookupFlag int lookupFlags, @Nullable final Cursor cursor) {
+ @NonNull CacheLookupFlag lookupFlags, @Nullable final Cursor cursor) {
assertWorkerThread();
ComponentKey cacheKey = new ComponentKey(componentName, user);
CacheEntry entry = mCache.get(cacheKey);
- final boolean useLowResIcon = (lookupFlags & LookupFlag.USE_LOW_RES) != 0;
+ final boolean useLowResIcon = lookupFlags.useLowRes();
if (entry == null || (entry.bitmap.isLowRes() && !useLowResIcon)) {
- boolean addToMemCache = entry != null
- || (lookupFlags & LookupFlag.SKIP_ADD_TO_MEM_CACHE) == 0;
+ boolean addToMemCache = entry != null || !lookupFlags.skipAddToMemCache();
entry = new CacheEntry();
if (addToMemCache) {
mCache.put(cacheKey, entry);
@@ -445,7 +410,7 @@
object,
entry,
cachingLogic,
- (lookupFlags & LookupFlag.USE_PACKAGE_ICON) != 0,
+ lookupFlags.usePackageIcon(),
/* usePackageTitle= */ true,
componentName,
user);
diff --git a/iconloaderlib/src/com/android/launcher3/icons/cache/CacheLookupFlag.kt b/iconloaderlib/src/com/android/launcher3/icons/cache/CacheLookupFlag.kt
new file mode 100644
index 0000000..be93eff
--- /dev/null
+++ b/iconloaderlib/src/com/android/launcher3/icons/cache/CacheLookupFlag.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.launcher3.icons.cache
+
+import androidx.annotation.IntDef
+import kotlin.annotation.AnnotationRetention.SOURCE
+
+/** Flags to control cache lookup behavior */
+data class CacheLookupFlag private constructor(@LookupFlag private val flag: Int) {
+
+ /**
+ * Cache will try to load the low res version of the entry unless a high-res is already in
+ * memory
+ */
+ fun useLowRes() = hasFlag(USE_LOW_RES)
+
+ @JvmOverloads fun withUseLowRes(useLowRes: Boolean = true) = updateMask(USE_LOW_RES, useLowRes)
+
+ /** Cache will try to lookup the package entry for the item, if the object entry fails */
+ fun usePackageIcon() = hasFlag(USE_PACKAGE_ICON)
+
+ @JvmOverloads
+ fun withUsePackageIcon(usePackageIcon: Boolean = true) =
+ updateMask(USE_PACKAGE_ICON, usePackageIcon)
+
+ /**
+ * Entry will not be added to the memory cache if it was not already added by a previous lookup
+ */
+ fun skipAddToMemCache() = hasFlag(SKIP_ADD_TO_MEM_CACHE)
+
+ @JvmOverloads
+ fun withSkipAddToMemCache(skipAddToMemCache: Boolean = true) =
+ updateMask(SKIP_ADD_TO_MEM_CACHE, skipAddToMemCache)
+
+ private fun hasFlag(@LookupFlag mask: Int) = flag.and(mask) != 0
+
+ private fun updateMask(@LookupFlag mask: Int, addMask: Boolean) =
+ if (addMask) flagCache[flag.or(mask)] else flagCache[flag.and(mask.inv())]
+
+ @Retention(SOURCE)
+ @IntDef(value = [USE_LOW_RES, USE_PACKAGE_ICON, SKIP_ADD_TO_MEM_CACHE], flag = true)
+ /** Various options to control cache lookup */
+ private annotation class LookupFlag
+
+ companion object {
+ private const val USE_LOW_RES: Int = 1 shl 0
+ private const val USE_PACKAGE_ICON: Int = 1 shl 1
+ private const val SKIP_ADD_TO_MEM_CACHE: Int = 1 shl 2
+
+ private val flagCache = Array(8) { CacheLookupFlag(it) }
+
+ @JvmField val DEFAULT_LOOKUP_FLAG = CacheLookupFlag(0)
+ }
+}
diff --git a/mechanics/Android.bp b/mechanics/Android.bp
index ae00b5f..7b8748a 100644
--- a/mechanics/Android.bp
+++ b/mechanics/Android.bp
@@ -32,6 +32,7 @@
static_libs: [
"androidx.compose.runtime_runtime",
"androidx.compose.ui_ui-util",
+ "androidx.compose.foundation_foundation-layout",
],
srcs: [
":mechanics-srcs",
diff --git a/mechanics/src/com/android/mechanics/MotionValue.kt b/mechanics/src/com/android/mechanics/MotionValue.kt
new file mode 100644
index 0000000..ac8e654
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/MotionValue.kt
@@ -0,0 +1,920 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.mechanics
+
+import androidx.compose.runtime.FloatState
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.referentialEqualityPolicy
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.runtime.withFrameNanos
+import androidx.compose.ui.util.lerp
+import androidx.compose.ui.util.packFloats
+import androidx.compose.ui.util.unpackFloat1
+import androidx.compose.ui.util.unpackFloat2
+import com.android.mechanics.debug.DebugInspector
+import com.android.mechanics.debug.FrameData
+import com.android.mechanics.spec.Breakpoint
+import com.android.mechanics.spec.Guarantee
+import com.android.mechanics.spec.InputDirection
+import com.android.mechanics.spec.Mapping
+import com.android.mechanics.spec.MotionSpec
+import com.android.mechanics.spec.SegmentData
+import com.android.mechanics.spring.SpringParameters
+import com.android.mechanics.spring.SpringState
+import com.android.mechanics.spring.calculateUpdatedState
+import java.util.concurrent.atomic.AtomicInteger
+import kotlin.math.max
+import kotlinx.coroutines.awaitCancellation
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.launch
+
+/**
+ * Computes an animated [output] value, by mapping the [currentInput] according to the [spec].
+ *
+ * A [MotionValue] represents a single animated value within a larger animation. It takes a
+ * numerical [currentInput] value, typically a spatial value like width, height, or gesture length,
+ * and transforms it into an [output] value using a [MotionSpec].
+ *
+ * ## Mapping Input to Output
+ *
+ * The [MotionSpec] defines the relationship between the input and output values. It does this by
+ * specifying a series of [Mapping] functions and [Breakpoint]s. Breakpoints divide the input domain
+ * into segments. Each segment has an associated [Mapping] function, which determines how input
+ * values within that segment are transformed into output values.
+ *
+ * These [Mapping] functions can be arbitrary, as long as they are
+ * 1. deterministic: When invoked repeatedly for the same input, they must produce the same output.
+ * 2. continuous: meaning infinitesimally small changes in input result in infinitesimally small
+ * changes in output
+ *
+ * A valid [Mapping] function is one whose graph could be drawn without lifting your pen from the
+ * paper, meaning there are no abrupt jumps or breaks.
+ *
+ * ## Animating Discontinuities
+ *
+ * When the input value crosses a breakpoint, there might be a discontinuity in the output value due
+ * to the switch between mapping functions. `MotionValue` automatically animates these
+ * discontinuities using a spring animation. The spring parameters are defined for each
+ * [Breakpoint].
+ *
+ * ## Guarantees for Choreography
+ *
+ * Breakpoints can also define [Guarantee]s. These guarantees can make the spring animation finish
+ * faster, in response to quick input value changes. Thus, [Guarantee]s allows to maintain a
+ * predictable choreography, even as the input is unpredictably changed by a user's gesture.
+ *
+ * ## Updating the MotionSpec
+ *
+ * The [spec] property can be changed at any time. If the new spec produces a different output for
+ * the current input, the difference will be animated using the spring parameters defined in
+ * [MotionSpec.resetSpring].
+ *
+ * ## Gesture Context
+ *
+ * The [GestureContext] augments the [currentInput] value with the user's intent. The
+ * [GestureContext] is created wherever gesture input is handled. If the motion value is not driven
+ * by a gesture, it is OK for the [GestureContext] to return static values.
+ *
+ * ## Usage
+ *
+ * The [MotionValue] does animate the [output] implicitly, whenever a change in [currentInput],
+ * [spec], or [gestureContext] requires it. The animated value is computed whenever the [output]
+ * property is read, or the latest once the animation frame is complete.
+ * 1. Create an instance, providing the input value, gesture context, and an initial spec.
+ * 2. Call [keepRunning] in a coroutine scope, and keep the coroutine running while the
+ * `MotionValue` is in use.
+ * 3. Access the animated output value through the [output] property.
+ *
+ * Internally, the [keepRunning] coroutine is automatically suspended if there is nothing to
+ * animate.
+ *
+ * @param currentInput Provides the current input value.
+ * @param gestureContext The [GestureContext] augmenting the [currentInput].
+ * @param label An optional label to aid in debugging.
+ * @param stableThreshold A threshold value (in output units) that determines when the
+ * [MotionValue]'s internal spring animation is considered stable.
+ */
+class MotionValue(
+ private val currentInput: () -> Float,
+ private val gestureContext: GestureContext,
+ initialSpec: MotionSpec = MotionSpec.Empty,
+ val label: String? = null,
+ private val stableThreshold: Float = 0.01f,
+) : FloatState {
+
+ /** The [MotionSpec] describing the mapping of this [MotionValue]'s input to the output. */
+ var spec by mutableStateOf(initialSpec)
+
+ /** Animated [output] value. */
+ val output: Float
+ get() = currentDirectMapped + currentAnimatedDelta
+
+ /**
+ * [output] value, but without animations.
+ *
+ * This value always reports the target value, even before a animation is finished.
+ *
+ * While [isStable], [outputTarget] and [output] are the same value.
+ */
+ val outputTarget: Float
+ get() = currentDirectMapped + currentAnimation.targetValue
+
+ /** The [output] exposed as [FloatState]. */
+ override val floatValue: Float
+ get() = output
+
+ /** Whether an animation is currently running. */
+ val isStable: Boolean
+ get() = currentSpringState == SpringState.AtRest
+
+ /**
+ * Keeps the [MotionValue]'s animated output running.
+ *
+ * Clients must call [keepRunning], and keep the coroutine running while the [MotionValue] is in
+ * use. When disposing this [MotionValue], cancel the coroutine.
+ *
+ * Internally, this method does suspend, unless there are animations ongoing.
+ */
+ suspend fun keepRunning(): Nothing = coroutineScope {
+ check(!isActive) { "keepRunning() invoked while already running" }
+ isActive = true
+ try {
+ // The purpose of this implementation is to run an animation frame (via withFrameNanos)
+ // whenever the input changes, or the spring is still settling, but otherwise just
+ // suspend.
+
+ // Used to suspend when no animations are running, and to wait for a wakeup signal.
+ val wakeupChannel = Channel<Unit>(capacity = Channel.CONFLATED)
+
+ // `true` while the spring is settling.
+ var runAnimationFrames = !isStable
+ launch {
+ // TODO(b/383979536) use a SnapshotStateObserver instead
+ snapshotFlow {
+ // observe all input values
+ var result = spec.hashCode()
+ result = result * 31 + currentInput().hashCode()
+ result = result * 31 + currentDirection.hashCode()
+ result = result * 31 + currentGestureDistance.hashCode()
+
+ // Track whether the spring needs animation frames to finish
+ // In fact, whether the spring is settling is the only relevant bit to
+ // export from here. For everything else, just cause the flow to emit a
+ // different value (hence the hashing)
+ (result shl 1) + if (isStable) 0 else 1
+ }
+ .collect { hashedState ->
+ // while the 'runAnimationFrames' bit was set on the result
+ runAnimationFrames = (hashedState and 1) != 0
+ // nudge the animation runner in case its sleeping.
+ wakeupChannel.send(Unit)
+ }
+ }
+
+ while (true) {
+ if (!runAnimationFrames) {
+ // While the spring does not need animation frames (its stable), wait until
+ // woken up - this can be for a single frame after an input change.
+ debugIsAnimating = false
+ wakeupChannel.receive()
+ }
+
+ debugIsAnimating = true
+ withFrameNanos { frameTimeNanos -> currentAnimationTimeNanos = frameTimeNanos }
+
+ // At this point, the complete frame is done (including layout, drawing and
+ // everything else). What follows next is similar what one would do in a
+ // `SideEffect`, were this composable code:
+ // If during the last frame, a new animation was started, or a new segment entered,
+ // this state is copied over. If nothing changed, the computed `current*` state will
+ // be the same, it won't have a side effect.
+
+ // Capturing the state here is required since crossing a breakpoint is an event -
+ // the code has to record that this happened.
+
+ // Important - capture all values first, and only afterwards update the state.
+ // Interleaving read and update might trigger immediate re-computations.
+ val newSegment = currentSegment
+ val newGuaranteeState = currentGuaranteeState
+ val newAnimation = currentAnimation
+ val newSpringState = currentSpringState
+
+ // Capture the last frames input.
+ lastFrameTimeNanos = currentAnimationTimeNanos
+ lastInput = currentInput()
+ lastGestureDistance = currentGestureDistance
+ // Not capturing currentDirection and spec explicitly, they are included in
+ // lastSegment
+
+ // Update the state to the computed `current*` values
+ lastSegment = newSegment
+ lastGuaranteeState = newGuaranteeState
+ lastAnimation = newAnimation
+ lastSpringState = newSpringState
+ debugInspector?.run {
+ frame =
+ FrameData(
+ lastInput,
+ currentDirection,
+ lastGestureDistance,
+ lastFrameTimeNanos,
+ lastSpringState,
+ lastSegment,
+ lastAnimation,
+ )
+ }
+ }
+
+ // Keep the compiler happy - the while (true) {} above will not complete, yet the
+ // compiler wants a return value.
+ @Suppress("UNREACHABLE_CODE") awaitCancellation()
+ } finally {
+ isActive = false
+ }
+ }
+
+ companion object {
+ /** Creates a [MotionValue] whose [currentInput] is the animated [output] of [source]. */
+ fun createDerived(
+ source: MotionValue,
+ initialSpec: MotionSpec = MotionSpec.Empty,
+ label: String? = null,
+ stableThreshold: Float = 0.01f,
+ ): MotionValue {
+ return MotionValue(
+ currentInput = source::output,
+ gestureContext = source.gestureContext,
+ initialSpec = initialSpec,
+ label = label,
+ stableThreshold = stableThreshold,
+ )
+ }
+
+ internal const val TAG = "MotionValue"
+ }
+
+ // ---- Last frame's state ---------------------------------------------------------------------
+
+ // The following state values prefixed with `last*` contain the state of the last completed
+ // frame. These values are updated in [keepRunning], once [withFrameNanos] returns.
+ // The `last*` state is what the `current*` computations further down are based on.
+
+ /**
+ * The segment in use, defined by the min/max [Breakpoint]s and the [Mapping] in between. This
+ * implicitly also captures the [InputDirection] and [MotionSpec].
+ */
+ private var lastSegment: SegmentData by
+ mutableStateOf(
+ spec.segmentAtInput(currentInput(), currentDirection),
+ referentialEqualityPolicy(),
+ )
+
+ /**
+ * State of the [Guarantee]. Its interpretation is defined by the [lastSegment]'s
+ * [SegmentData.entryBreakpoint]'s [Breakpoint.guarantee]. If that breakpoint has no guarantee,
+ * this value will be [GuaranteeState.Inactive].
+ *
+ * This is the maximal guarantee value seen so far, as well as the guarantee's start value, and
+ * is used to compute the spring-tightening fraction.
+ */
+ private inline var lastGuaranteeState: GuaranteeState
+ get() = GuaranteeState(_lastGuaranteeStatePacked)
+ set(value) {
+ _lastGuaranteeStatePacked = value.packedValue
+ }
+
+ /** Backing field for [lastGuaranteeState], to avoid auto-boxing. */
+ private var _lastGuaranteeStatePacked: Long by
+ mutableLongStateOf(GuaranteeState.Inactive.packedValue)
+
+ /**
+ * The state of an ongoing animation of a discontinuity.
+ *
+ * The spring animation is described by the [DiscontinuityAnimation.springStartState], which
+ * tracks the oscillation of the spring until the displacement is guaranteed not to exceed
+ * [stableThreshold] anymore. The spring animation started at
+ * [DiscontinuityAnimation.springStartTimeNanos], and uses the
+ * [DiscontinuityAnimation.springParameters]. The displacement's origin is at
+ * [DiscontinuityAnimation.targetValue].
+ *
+ * This state does not have to be updated every frame, even as an animation is ongoing: the
+ * spring animation can be computed with the same start parameters, and as time progresses, the
+ * [SpringState.calculateUpdatedState] is passed an ever larger `elapsedNanos` on each frame.
+ *
+ * The [DiscontinuityAnimation.targetValue] is a delta to the direct mapped output value from
+ * the [SegmentData.mapping]. It might accumulate the target value - it is not required to reset
+ * when the animation ends.
+ */
+ private var lastAnimation: DiscontinuityAnimation by
+ mutableStateOf(DiscontinuityAnimation.None, referentialEqualityPolicy())
+
+ // ---- Last frame's input and output ----------------------------------------------------------
+
+ // The state below captures relevant input values (including frame time) and the computed spring
+ // state, thus are updated on every frame. To avoid excessive invalidations, these must only be
+ // read from [currentAnimation] and [currentGuaranteeState], when starting a new animation.
+
+ /**
+ * Last frame's spring state, based on initial origin values in [lastAnimation], carried-forward
+ * to [lastFrameTimeNanos].
+ */
+ private inline var lastSpringState: SpringState
+ get() = SpringState(_lastSpringStatePacked)
+ set(value) {
+ _lastSpringStatePacked = value.packedValue
+ }
+
+ /** Backing field for [lastSpringState], to avoid auto-boxing. */
+ private var _lastSpringStatePacked: Long by
+ mutableLongStateOf(lastAnimation.springStartState.packedValue)
+
+ /** The time of the last frame, in nanoseconds. */
+ private var lastFrameTimeNanos by mutableLongStateOf(-1L)
+
+ /** The [currentInput] of the last frame */
+ private var lastInput by mutableFloatStateOf(currentInput())
+
+ /** The [currentGestureDistance] input of the last frame. */
+ private var lastGestureDistance by mutableFloatStateOf(currentGestureDistance)
+
+ // ---- Declarative Update ---------------------------------------------------------------------
+
+ // All the below contains the magic to compute the updated [MotionValue] state.
+ // The code is strictly ordered by dependencies - code is only ever allowed to access a value
+ // that is placed above in this file, to avoid cyclic dependencies.
+
+ /**
+ * The current frame's animation time, updated by [keepRunning] while an animation is running or
+ * the input changed.
+ */
+ private var currentAnimationTimeNanos by mutableLongStateOf(-1L)
+
+ /** [gestureContext]'s [GestureContext.direction], exists solely for consistent naming. */
+ private inline val currentDirection: InputDirection
+ get() = gestureContext.direction
+
+ /** [gestureContext]'s [GestureContext.distance], exists solely for consistent naming. */
+ private inline val currentGestureDistance: Float
+ get() = gestureContext.distance
+
+ /**
+ * The current segment, which defines the [Mapping] function used to transform the input to the
+ * output.
+ *
+ * While both [spec] and [currentDirection] remain the same, and [currentInput] is within the
+ * segment (see [SegmentData.isValidForInput]), this is [lastSegment].
+ *
+ * Otherwise, [MotionSpec.onChangeSegment] is queried for an up-dated segment.
+ */
+ private val currentSegment: SegmentData by derivedStateOf {
+ val lastSegment = lastSegment
+ val input = currentInput()
+ val direction = currentDirection
+
+ val specChanged = lastSegment.spec != spec
+ if (specChanged || !lastSegment.isValidForInput(input, direction)) {
+ spec.onChangeSegment(lastSegment, input, direction)
+ } else {
+ lastSegment
+ }
+ }
+
+ /**
+ * Describes how the [currentSegment] is different from last frame's [lastSegment].
+ *
+ * This affects how the discontinuities are animated and [Guarantee]s applied.
+ */
+ private enum class SegmentChangeType {
+ /**
+ * The segment has the same key, this is considered equivalent.
+ *
+ * Only the [GuaranteeState] needs to be kept updated.
+ */
+ Same,
+
+ /**
+ * The segment's direction changed, however the min / max breakpoints remain the same: This
+ * is a direction change within a segment.
+ *
+ * The delta between the mapping must be animated with the reset spring, and there is no
+ * guarantee associated with the change.
+ */
+ SameOppositeDirection,
+
+ /**
+ * The segment and its direction change. This is a direction change that happened over a
+ * segment boundary.
+ *
+ * The direction change might have happened outside the [lastSegment] already, since a
+ * segment can't be exited at the entry side.
+ */
+ Direction,
+
+ /**
+ * The segment changed, due to the [currentInput] advancing in the [currentDirection],
+ * crossing one or more breakpoints.
+ *
+ * The guarantees of all crossed breakpoints have to be applied. The [GuaranteeState] must
+ * be reset, and a new [DiscontinuityAnimation] is started.
+ */
+ Traverse,
+
+ /**
+ * The spec was changed and added or removed the previous and/or current segment.
+ *
+ * The [MotionValue] does not have a semantic understanding of this change, hence the
+ * difference output produced by the previous and current mapping are animated with the
+ * [MotionSpec.resetSpring]
+ */
+ Spec,
+ }
+
+ /** Computes the [SegmentChangeType] between [lastSegment] and [currentSegment]. */
+ private val segmentChangeType: SegmentChangeType
+ get() {
+ val currentSegment = currentSegment
+ val lastSegment = lastSegment
+
+ if (currentSegment.key == lastSegment.key) {
+ return SegmentChangeType.Same
+ }
+
+ if (
+ currentSegment.key.minBreakpoint == lastSegment.key.minBreakpoint &&
+ currentSegment.key.maxBreakpoint == lastSegment.key.maxBreakpoint
+ ) {
+ return SegmentChangeType.SameOppositeDirection
+ }
+
+ val currentSpec = currentSegment.spec
+ val lastSpec = lastSegment.spec
+ if (currentSpec !== lastSpec) {
+ // Determine/guess whether the segment change was due to the changed spec, or
+ // whether lastSpec would return the same segment key for the update input.
+ val lastSpecSegmentForSameInput =
+ lastSpec.segmentAtInput(currentInput(), gestureContext.direction).key
+ if (currentSegment.key != lastSpecSegmentForSameInput) {
+ // Note: this might not be correct if the new [MotionSpec.segmentHandlers] were
+ // involved.
+ return SegmentChangeType.Spec
+ }
+ }
+
+ return if (currentSegment.direction == lastSegment.direction) {
+ SegmentChangeType.Traverse
+ } else {
+ SegmentChangeType.Direction
+ }
+ }
+
+ /**
+ * Computes the fraction of [position] between [lastInput] and [currentInput].
+ *
+ * Essentially, this determines fractionally when [position] was crossed, between the current
+ * frame and the last frame.
+ *
+ * Since frames are updated periodically, not continuously, crossing a breakpoint happened
+ * sometime between the last frame's start and this frame's start.
+ *
+ * This fraction is used to estimate the time when a breakpoint was crossed since last frame,
+ * and simplifies the logic of crossing multiple breakpoints in one frame, as it offers the
+ * springs and guarantees time to be updated correctly.
+ *
+ * Of course, this is a simplification that assumes the input velocity was uniform during the
+ * last frame, but that is likely good enough.
+ */
+ private fun lastFrameFractionOfPosition(position: Float): Float {
+ return ((position - lastInput) / (currentInput() - lastInput)).coerceIn(0f, 1f)
+ }
+
+ /**
+ * The [GuaranteeState] for [currentSegment].
+ *
+ * Without a segment change, this carries forward [lastGuaranteeState], adjusted to the new
+ * input if needed.
+ *
+ * If a segment change happened, this is a new [GuaranteeState] for the [currentSegment]. Any
+ * remaining [lastGuaranteeState] will be consumed in [currentAnimation].
+ */
+ private val currentGuaranteeState: GuaranteeState by derivedStateOf {
+ val currentSegment = currentSegment
+ val entryBreakpoint = currentSegment.entryBreakpoint
+
+ // First, determine the origin of the guarantee computations
+ val guaranteeOriginState =
+ when (segmentChangeType) {
+ // Still in the segment, the origin is carried over from the last frame
+ SegmentChangeType.Same -> lastGuaranteeState
+ // The direction changed within the same segment, no guarantee to enforce.
+ SegmentChangeType.SameOppositeDirection ->
+ return@derivedStateOf GuaranteeState.Inactive
+ // The spec changes, there is no guarantee associated with the animation.
+ SegmentChangeType.Spec -> return@derivedStateOf GuaranteeState.Inactive
+ SegmentChangeType.Direction -> {
+ // Direction changed over a segment boundary. To make up for the
+ // directionChangeSlop, the guarantee starts at the current input.
+ GuaranteeState.withStartValue(
+ when (entryBreakpoint.guarantee) {
+ is Guarantee.InputDelta -> currentInput()
+ is Guarantee.GestureDistance -> gestureContext.distance
+ is Guarantee.None -> return@derivedStateOf GuaranteeState.Inactive
+ }
+ )
+ }
+
+ SegmentChangeType.Traverse -> {
+ // Traversed over a segment boundary, the guarantee going forward is determined
+ // by the [entryBreakpoint].
+ GuaranteeState.withStartValue(
+ when (entryBreakpoint.guarantee) {
+ is Guarantee.InputDelta -> entryBreakpoint.position
+ is Guarantee.GestureDistance -> {
+ // Guess the [GestureDistance] origin - since the gesture distance
+ // is sampled, interpolate it according to when the breakpoint was
+ // crossed in the last frame.
+ val fractionalBreakpointPos =
+ lastFrameFractionOfPosition(entryBreakpoint.position)
+
+ lerp(
+ lastGestureDistance,
+ gestureContext.distance,
+ fractionalBreakpointPos,
+ )
+ }
+
+ // No guarantee to enforce.
+ is Guarantee.None -> return@derivedStateOf GuaranteeState.Inactive
+ }
+ )
+ }
+ }
+
+ // Finally, update the origin state with the current guarantee value.
+ guaranteeOriginState.withCurrentValue(
+ when (entryBreakpoint.guarantee) {
+ is Guarantee.InputDelta -> currentInput()
+ is Guarantee.GestureDistance -> gestureContext.distance
+ is Guarantee.None -> return@derivedStateOf GuaranteeState.Inactive
+ },
+ currentSegment.direction,
+ )
+ }
+
+ /**
+ * The [DiscontinuityAnimation] in effect for the current frame.
+ *
+ * This describes the starting condition of the spring animation, and is only updated if the
+ * spring animation must restarted: that is, if yet another discontinuity must be animated as a
+ * result of a segment change, or if the [currentGuaranteeState] requires the spring to be
+ * tightened.
+ *
+ * See [currentSpringState] for the continuously updated, animated spring values.
+ */
+ private val currentAnimation: DiscontinuityAnimation by derivedStateOf {
+ val currentSegment = currentSegment
+ val lastSegment = lastSegment
+ val currentSpec = spec
+ val currentInput = currentInput()
+ val lastAnimation = lastAnimation
+
+ when (segmentChangeType) {
+ SegmentChangeType.Same -> {
+ if (lastAnimation.isAtRest) {
+ // Nothing to update if no animation is ongoing
+ lastAnimation
+ } else if (lastGuaranteeState == currentGuaranteeState) {
+ // Nothing to update if the spring must not be tightened.
+ lastAnimation
+ } else {
+ // Compute the updated spring parameters
+ val tightenedSpringParameters =
+ currentGuaranteeState.updatedSpringParameters(
+ currentSegment.entryBreakpoint
+ )
+
+ lastAnimation.copy(
+ springStartState = lastSpringState,
+ springParameters = tightenedSpringParameters,
+ springStartTimeNanos = lastFrameTimeNanos,
+ )
+ }
+ }
+
+ SegmentChangeType.SameOppositeDirection,
+ SegmentChangeType.Direction,
+ SegmentChangeType.Spec -> {
+ // Determine the delta in the output, as produced by the old and new mapping.
+ val delta =
+ currentSegment.mapping.map(currentInput) - lastSegment.mapping.map(currentInput)
+
+ if (delta == 0f) {
+ // Nothing new to animate.
+ lastAnimation
+ } else {
+ val springParameters =
+ if (segmentChangeType == SegmentChangeType.Direction) {
+ currentSegment.entryBreakpoint.spring
+ } else {
+ currentSpec.resetSpring
+ }
+
+ val newTarget = delta - lastSpringState.displacement
+ DiscontinuityAnimation(
+ newTarget,
+ SpringState(-newTarget, lastSpringState.velocity),
+ springParameters,
+ lastFrameTimeNanos,
+ )
+ }
+ }
+
+ SegmentChangeType.Traverse -> {
+ // Process all breakpoints traversed, in order.
+ // This is involved due to the guarantees - they have to be applied, one after the
+ // other, before crossing the next breakpoint.
+ val currentDirection = currentSegment.direction
+
+ with(currentSpec[currentDirection]) {
+ val targetIndex = findSegmentIndex(currentSegment.key)
+ val sourceIndex = findSegmentIndex(lastSegment.key)
+ check(targetIndex != sourceIndex)
+
+ val directionOffset = if (targetIndex > sourceIndex) 1 else -1
+
+ var lastBreakpoint = lastSegment.entryBreakpoint
+ var lastAnimationTime = lastFrameTimeNanos
+ var guaranteeState = lastGuaranteeState
+ var springState = lastSpringState
+ var springTarget = lastAnimation.targetValue
+ var springParameters = lastAnimation.springParameters
+
+ var segmentIndex = sourceIndex
+ while (segmentIndex != targetIndex) {
+ val nextBreakpoint =
+ breakpoints[segmentIndex + directionOffset.coerceAtLeast(0)]
+
+ val nextBreakpointFrameFraction =
+ lastFrameFractionOfPosition(nextBreakpoint.position)
+
+ val nextBreakpointCrossTime =
+ lerp(
+ lastFrameTimeNanos,
+ currentAnimationTimeNanos,
+ nextBreakpointFrameFraction,
+ )
+ if (
+ guaranteeState != GuaranteeState.Inactive &&
+ springState != SpringState.AtRest
+ ) {
+ val guaranteeValueAtNextBreakpoint =
+ when (lastBreakpoint.guarantee) {
+ is Guarantee.InputDelta -> nextBreakpoint.position
+ is Guarantee.GestureDistance ->
+ lerp(
+ lastGestureDistance,
+ gestureContext.distance,
+ nextBreakpointFrameFraction,
+ )
+
+ is Guarantee.None ->
+ error(
+ "guaranteeState ($guaranteeState) is not Inactive, guarantee is missing"
+ )
+ }
+
+ guaranteeState =
+ guaranteeState.withCurrentValue(
+ guaranteeValueAtNextBreakpoint,
+ currentDirection,
+ )
+
+ springParameters =
+ guaranteeState.updatedSpringParameters(lastBreakpoint)
+ }
+
+ springState =
+ springState.calculateUpdatedState(
+ nextBreakpointCrossTime - lastAnimationTime,
+ springParameters,
+ )
+ lastAnimationTime = nextBreakpointCrossTime
+
+ val beforeBreakpoint = mappings[segmentIndex].map(nextBreakpoint.position)
+ val afterBreakpoint =
+ mappings[segmentIndex + directionOffset].map(nextBreakpoint.position)
+
+ val delta = afterBreakpoint - beforeBreakpoint
+ springTarget += delta
+ springState = springState.addDisplacement(-delta)
+
+ segmentIndex += directionOffset
+ lastBreakpoint = nextBreakpoint
+ guaranteeState =
+ when (nextBreakpoint.guarantee) {
+ is Guarantee.InputDelta ->
+ GuaranteeState.withStartValue(nextBreakpoint.position)
+
+ is Guarantee.GestureDistance ->
+ GuaranteeState.withStartValue(
+ lerp(
+ lastGestureDistance,
+ gestureContext.distance,
+ nextBreakpointFrameFraction,
+ )
+ )
+
+ is Guarantee.None -> GuaranteeState.Inactive
+ }
+ }
+
+ val tightened =
+ currentGuaranteeState.updatedSpringParameters(
+ currentSegment.entryBreakpoint
+ )
+
+ DiscontinuityAnimation(springTarget, springState, tightened, lastAnimationTime)
+ }
+ }
+ }
+ }
+
+ /**
+ * Up to date and animated spring state, based on initial origin values in [currentAnimation],
+ * carried-forward to [currentAnimationTimeNanos].
+ *
+ * Is guaranteed to be [SpringState.AtRest] if the spring is not animating (the oscillation is
+ * less than [stableThreshold]).
+ */
+ private val currentSpringState: SpringState by derivedStateOf {
+ with(currentAnimation) {
+ if (isAtRest) return@derivedStateOf SpringState.AtRest
+
+ val nanosSinceAnimationStart = currentAnimationTimeNanos - springStartTimeNanos
+ val updatedSpringState =
+ springStartState.calculateUpdatedState(nanosSinceAnimationStart, springParameters)
+
+ if (updatedSpringState.isStable(springParameters, stableThreshold)) {
+ SpringState.AtRest
+ } else {
+ updatedSpringState
+ }
+ }
+ }
+
+ private val currentDirectMapped: Float
+ get() = currentSegment.mapping.map(currentInput()) - currentAnimation.targetValue
+
+ private val currentAnimatedDelta: Float
+ get() = currentAnimation.targetValue + currentSpringState.displacement
+
+ // ---- Accessor to internals, for inspection and tests ----------------------------------------
+
+ /** Whether a [keepRunning] coroutine is active currently. */
+ private var isActive = false
+ set(value) {
+ field = value
+ debugInspector?.isActive = value
+ }
+
+ /**
+ * `false` whenever the [keepRunning] coroutine is suspended while no animation is running and
+ * the input is not changing.
+ */
+ private var debugIsAnimating = false
+ set(value) {
+ field = value
+ debugInspector?.isAnimating = value
+ }
+
+ private var debugInspector: DebugInspector? = null
+ private var debugInspectorRefCount = AtomicInteger(0)
+
+ private fun onDisposeDebugInspector() {
+ if (debugInspectorRefCount.decrementAndGet() == 0) {
+ debugInspector = null
+ }
+ }
+
+ /**
+ * Provides access to internal state for debug tooling and tests.
+ *
+ * The returned [DebugInspector] must be [DebugInspector.dispose]d when no longer needed.
+ */
+ fun debugInspector(): DebugInspector {
+ if (debugInspectorRefCount.getAndIncrement() == 0) {
+ debugInspector =
+ DebugInspector(
+ FrameData(
+ lastInput,
+ lastSegment.direction,
+ lastGestureDistance,
+ lastFrameTimeNanos,
+ lastSpringState,
+ lastSegment,
+ lastAnimation,
+ ),
+ isActive,
+ debugIsAnimating,
+ ::onDisposeDebugInspector,
+ )
+ }
+
+ return checkNotNull(debugInspector)
+ }
+}
+
+/**
+ * Captures the start-state of a spring-animation to smooth over a discontinuity.
+ *
+ * Discontinuities are caused by segment changes, where the new and old segment produce different
+ * output values for the same input.
+ */
+internal data class DiscontinuityAnimation(
+ val targetValue: Float,
+ val springStartState: SpringState,
+ val springParameters: SpringParameters,
+ val springStartTimeNanos: Long,
+) {
+ val isAtRest: Boolean
+ get() = springStartState == SpringState.AtRest
+
+ companion object {
+ val None =
+ DiscontinuityAnimation(
+ targetValue = 0f,
+ springStartState = SpringState.AtRest,
+ springParameters = SpringParameters.Snap,
+ springStartTimeNanos = 0L,
+ )
+ }
+}
+
+/**
+ * Captures the origin of a guarantee, and the maximal distance the input has been away from the
+ * origin at most.
+ */
+@JvmInline
+internal value class GuaranteeState(val packedValue: Long) {
+ private val start: Float
+ get() = unpackFloat1(packedValue)
+
+ private val maxDelta: Float
+ get() = unpackFloat2(packedValue)
+
+ private val isInactive: Boolean
+ get() = this == Inactive
+
+ fun withCurrentValue(value: Float, direction: InputDirection): GuaranteeState {
+ if (isInactive) return Inactive
+
+ val delta = ((value - start) * direction.sign).coerceAtLeast(0f)
+ return GuaranteeState(start, max(delta, maxDelta))
+ }
+
+ fun updatedSpringParameters(breakpoint: Breakpoint): SpringParameters {
+ if (isInactive) return breakpoint.spring
+
+ val denominator =
+ when (val guarantee = breakpoint.guarantee) {
+ is Guarantee.None -> return breakpoint.spring
+ is Guarantee.InputDelta -> guarantee.delta
+ is Guarantee.GestureDistance -> guarantee.distance
+ }
+
+ val springTighteningFraction = maxDelta / denominator
+ return com.android.mechanics.spring.lerp(
+ breakpoint.spring,
+ SpringParameters.Snap,
+ springTighteningFraction,
+ )
+ }
+
+ companion object {
+ val Inactive = GuaranteeState(packFloats(Float.NaN, Float.NaN))
+
+ fun withStartValue(start: Float) = GuaranteeState(packFloats(start, 0f))
+ }
+}
+
+internal fun GuaranteeState(start: Float, maxDelta: Float) =
+ GuaranteeState(packFloats(start, maxDelta))
diff --git a/mechanics/src/com/android/mechanics/debug/DebugInspector.kt b/mechanics/src/com/android/mechanics/debug/DebugInspector.kt
new file mode 100644
index 0000000..f1e6f99
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/debug/DebugInspector.kt
@@ -0,0 +1,84 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.mechanics.debug
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import com.android.mechanics.DiscontinuityAnimation
+import com.android.mechanics.MotionValue
+import com.android.mechanics.spec.InputDirection
+import com.android.mechanics.spec.SegmentData
+import com.android.mechanics.spec.SegmentKey
+import com.android.mechanics.spring.SpringParameters
+import com.android.mechanics.spring.SpringState
+import kotlinx.coroutines.DisposableHandle
+
+/** Utility to gain inspection access to internal [MotionValue] state. */
+class DebugInspector
+internal constructor(
+ initialFrameData: FrameData,
+ initialIsActive: Boolean,
+ initialIsAnimating: Boolean,
+ disposableHandle: DisposableHandle,
+) : DisposableHandle by disposableHandle {
+
+ /** The last completed frame's data. */
+ var frame: FrameData by mutableStateOf(initialFrameData)
+ internal set
+
+ /** Whether a [MotionValue.keepRunning] coroutine is active currently. */
+ var isActive: Boolean by mutableStateOf(initialIsActive)
+ internal set
+
+ /**
+ * `false` whenever the [MotionValue.keepRunning] coroutine internally is suspended while no
+ * animation is running and the input is not changing.
+ */
+ var isAnimating: Boolean by mutableStateOf(initialIsAnimating)
+ internal set
+}
+
+/** The input, output and internal state of a [MotionValue] for the frame. */
+data class FrameData
+internal constructor(
+ val input: Float,
+ val gestureDirection: InputDirection,
+ val gestureDistance: Float,
+ val frameTimeNanos: Long,
+ val springState: SpringState,
+ private val segment: SegmentData,
+ private val animation: DiscontinuityAnimation,
+) {
+ val isStable: Boolean
+ get() = springState == SpringState.AtRest
+
+ val springParameters: SpringParameters
+ get() = animation.springParameters
+
+ val segmentKey: SegmentKey
+ get() = segment.key
+
+ val output: Float
+ get() = currentDirectMapped + (animation.targetValue + springState.displacement)
+
+ val outputTarget: Float
+ get() = currentDirectMapped + animation.targetValue
+
+ private val currentDirectMapped: Float
+ get() = segment.mapping.map(input) - animation.targetValue
+}
diff --git a/mechanics/src/com/android/mechanics/debug/DebugVisualization.kt b/mechanics/src/com/android/mechanics/debug/DebugVisualization.kt
new file mode 100644
index 0000000..1242b20
--- /dev/null
+++ b/mechanics/src/com/android/mechanics/debug/DebugVisualization.kt
@@ -0,0 +1,293 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.mechanics.debug
+
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.composed
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.util.fastForEachIndexed
+import com.android.mechanics.MotionValue
+import com.android.mechanics.spec.DirectionalMotionSpec
+import com.android.mechanics.spec.Guarantee
+import com.android.mechanics.spec.MotionSpec
+import kotlin.math.max
+import kotlin.math.min
+
+/**
+ * A debug visualization of the [motionValue].
+ *
+ * Draws both the [MotionValue.spec], as well as the input and output.
+ *
+ * NOTE: This is a debug tool, do not enable in production.
+ *
+ * @param motionValue The [MotionValue] to inspect.
+ * @param inputRange The relevant range of the input (x) axis, for which to draw the graph.
+ * @param color Color for the dots indicating the value
+ * @param historySize Number of past values to draw as a trail.
+ */
+@Composable
+fun DebugMotionValueVisualization(
+ motionValue: MotionValue,
+ inputRange: ClosedFloatingPointRange<Float>,
+ modifier: Modifier = Modifier,
+ color: Color = Color.DarkGray,
+ historySize: Int = 100,
+) {
+ val spec = motionValue.spec
+ val outputRange = remember(spec, inputRange) { spec.computeOutputValueRange(inputRange) }
+
+ Spacer(
+ modifier =
+ modifier
+ .debugMotionSpecGraph(spec, inputRange, outputRange)
+ .debugMotionValueGraph(motionValue, color, inputRange, outputRange, historySize)
+ )
+}
+
+/**
+ * Draws a full-sized debug visualization of [spec].
+ *
+ * NOTE: This is a debug tool, do not enable in production.
+ *
+ * @param inputRange The range of the input (x) axis
+ * @param outputRange The range of the output (y) axis.
+ */
+@Composable
+fun Modifier.debugMotionSpecGraph(
+ spec: MotionSpec,
+ inputRange: ClosedFloatingPointRange<Float>,
+ outputRange: ClosedFloatingPointRange<Float> =
+ remember(spec, inputRange) { spec.computeOutputValueRange(inputRange) },
+): Modifier = drawBehind {
+ drawAxis(Color.Gray)
+ if (spec.isUnidirectional) {
+ drawDirectionalSpec(spec.maxDirection, inputRange, outputRange, Color.Red)
+ } else {
+ drawDirectionalSpec(spec.minDirection, inputRange, outputRange, Color.Red)
+ drawDirectionalSpec(spec.maxDirection, inputRange, outputRange, Color.Blue)
+ }
+}
+
+/**
+ * Draws a full-sized debug visualization of the [motionValue] state.
+ *
+ * This can be combined with [debugMotionSpecGraph], when [inputRange] and [outputRange] are the
+ * same.
+ *
+ * NOTE: This is a debug tool, do not enable in production.
+ *
+ * @param color Color for the dots indicating the value
+ * @param inputRange The range of the input (x) axis
+ * @param outputRange The range of the output (y) axis.
+ * @param historySize Number of past values to draw as a trail.
+ */
+@Composable
+fun Modifier.debugMotionValueGraph(
+ motionValue: MotionValue,
+ color: Color,
+ inputRange: ClosedFloatingPointRange<Float>,
+ outputRange: ClosedFloatingPointRange<Float> =
+ remember(motionValue.spec, inputRange) {
+ motionValue.spec.computeOutputValueRange(inputRange)
+ },
+ historySize: Int = 100,
+): Modifier = composed {
+ val inspector = remember(motionValue) { motionValue.debugInspector() }
+
+ val history = remember { mutableStateListOf<FrameData>() }
+
+ LaunchedEffect(inspector, history) {
+ snapshotFlow { inspector.frame }
+ .collect {
+ history.add(it)
+ if (history.size > historySize) {
+ history.removeFirst()
+ }
+ }
+ }
+
+ DisposableEffect(inspector) { onDispose { inspector.dispose() } }
+
+ this.drawBehind { drawInputOutputTrail(history, inputRange, outputRange, color) }
+}
+
+private val MotionSpec.isUnidirectional: Boolean
+ get() = maxDirection == minDirection
+
+private fun MotionSpec.computeOutputValueRange(
+ inputRange: ClosedFloatingPointRange<Float>
+): ClosedFloatingPointRange<Float> {
+ return if (isUnidirectional) {
+ maxDirection.computeOutputValueRange(inputRange)
+ } else {
+ val maxRange = maxDirection.computeOutputValueRange(inputRange)
+ val minRange = minDirection.computeOutputValueRange(inputRange)
+
+ val start = min(minRange.start, maxRange.start)
+ val endInclusive = max(minRange.endInclusive, maxRange.endInclusive)
+
+ start..endInclusive
+ }
+}
+
+private fun DirectionalMotionSpec.computeOutputValueRange(
+ inputRange: ClosedFloatingPointRange<Float>
+): ClosedFloatingPointRange<Float> {
+
+ val start = findBreakpointIndex(inputRange.start)
+ val end = findBreakpointIndex(inputRange.endInclusive)
+
+ val samples = buildList {
+ add(mappings[start].map(inputRange.start))
+
+ for (breakpointIndex in (start + 1)..end) {
+
+ val position = breakpoints[breakpointIndex].position
+
+ add(mappings[breakpointIndex - 1].map(position))
+ add(mappings[breakpointIndex].map(position))
+ }
+
+ add(mappings[end].map(inputRange.endInclusive))
+ }
+
+ return samples.min()..samples.max()
+}
+
+private fun DrawScope.mapPointInInputToX(
+ input: Float,
+ inputRange: ClosedFloatingPointRange<Float>,
+): Float {
+ val inputExtent = (inputRange.endInclusive - inputRange.start)
+ return ((input - inputRange.start) / (inputExtent)) * size.width
+}
+
+private fun DrawScope.mapPointInOutputToY(
+ output: Float,
+ outputRange: ClosedFloatingPointRange<Float>,
+): Float {
+ val outputExtent = (outputRange.endInclusive - outputRange.start)
+ return (1 - (output - outputRange.start) / (outputExtent)) * size.height
+}
+
+private fun DrawScope.drawDirectionalSpec(
+ spec: DirectionalMotionSpec,
+ inputRange: ClosedFloatingPointRange<Float>,
+ outputRange: ClosedFloatingPointRange<Float>,
+ color: Color,
+) {
+
+ val startSegment = spec.findBreakpointIndex(inputRange.start)
+ val endSegment = spec.findBreakpointIndex(inputRange.endInclusive)
+
+ for (segmentIndex in startSegment..endSegment) {
+ val mapping = spec.mappings[segmentIndex]
+ val startBreakpoint = spec.breakpoints[segmentIndex]
+ val segmentStart = startBreakpoint.position
+ val fromInput = segmentStart.coerceAtLeast(inputRange.start)
+ val endBreakpoint = spec.breakpoints[segmentIndex + 1]
+ val segmentEnd = endBreakpoint.position
+ val toInput = segmentEnd.coerceAtMost(inputRange.endInclusive)
+
+ // TODO add support for functions that are not linear
+ val fromY = mapPointInOutputToY(mapping.map(fromInput), outputRange)
+ val toY = mapPointInOutputToY(mapping.map(toInput), outputRange)
+
+ val start = Offset(mapPointInInputToX(fromInput, inputRange), fromY)
+ val end = Offset(mapPointInInputToX(toInput, inputRange), toY)
+ drawLine(color, start, end)
+
+ if (segmentStart == fromInput) {
+ drawCircle(color, 2.dp.toPx(), start)
+ }
+
+ if (segmentEnd == toInput) {
+ drawCircle(color, 2.dp.toPx(), end)
+ }
+
+ val guarantee = startBreakpoint.guarantee
+ if (guarantee is Guarantee.InputDelta) {
+ val guaranteePos = segmentStart + guarantee.delta
+ if (guaranteePos > inputRange.start) {
+
+ val guaranteeOffset =
+ Offset(
+ mapPointInInputToX(guaranteePos, inputRange),
+ mapPointInOutputToY(mapping.map(guaranteePos), outputRange),
+ )
+
+ val arrowSize = 4.dp.toPx()
+
+ drawLine(
+ color,
+ guaranteeOffset,
+ guaranteeOffset.plus(Offset(arrowSize, -arrowSize)),
+ )
+ drawLine(color, guaranteeOffset, guaranteeOffset.plus(Offset(arrowSize, arrowSize)))
+ }
+ }
+ }
+}
+
+private fun DrawScope.drawInputOutputTrail(
+ history: List<FrameData>,
+ inputRange: ClosedFloatingPointRange<Float>,
+ outputRange: ClosedFloatingPointRange<Float>,
+ color: Color,
+) {
+ history.fastForEachIndexed { index, frame ->
+ val x = mapPointInInputToX(frame.input, inputRange)
+ val y = mapPointInOutputToY(frame.output, outputRange)
+
+ drawCircle(color, 2.dp.toPx(), Offset(x, y), alpha = index / history.size.toFloat())
+ }
+}
+
+private fun DrawScope.drawAxis(color: Color) {
+
+ drawXAxis(color)
+ drawYAxis(color)
+}
+
+private fun DrawScope.drawYAxis(color: Color, atX: Float = 0f) {
+
+ val arrowSize = 4.dp.toPx()
+
+ drawLine(color, Offset(atX, size.height), Offset(atX, 0f))
+ drawLine(color, Offset(atX, 0f), Offset(atX + arrowSize, arrowSize))
+ drawLine(color, Offset(atX, 0f), Offset(atX - arrowSize, arrowSize))
+}
+
+private fun DrawScope.drawXAxis(color: Color, atY: Float = size.height) {
+
+ val arrowSize = 4.dp.toPx()
+
+ drawLine(color, Offset(0f, atY), Offset(size.width, atY))
+ drawLine(color, Offset(size.width, atY), Offset(size.width - arrowSize, atY + arrowSize))
+ drawLine(color, Offset(size.width, atY), Offset(size.width - arrowSize, atY - arrowSize))
+}
diff --git a/mechanics/src/com/android/mechanics/spring/SpringParameters.kt b/mechanics/src/com/android/mechanics/spring/SpringParameters.kt
index 98b64e8..a031213 100644
--- a/mechanics/src/com/android/mechanics/spring/SpringParameters.kt
+++ b/mechanics/src/com/android/mechanics/spring/SpringParameters.kt
@@ -31,7 +31,7 @@
* @see SpringParameters function to create this value.
*/
@JvmInline
-value class SpringParameters(private val packedValue: Long) {
+value class SpringParameters(val packedValue: Long) {
val stiffness: Float
get() = unpackFloat1(packedValue)
diff --git a/mechanics/src/com/android/mechanics/spring/SpringState.kt b/mechanics/src/com/android/mechanics/spring/SpringState.kt
index 57de280..c6e1947 100644
--- a/mechanics/src/com/android/mechanics/spring/SpringState.kt
+++ b/mechanics/src/com/android/mechanics/spring/SpringState.kt
@@ -31,7 +31,7 @@
* @see SpringState function to create this value.
*/
@JvmInline
-value class SpringState(private val packedValue: Long) {
+value class SpringState(val packedValue: Long) {
val displacement: Float
get() = unpackFloat1(packedValue)
@@ -51,6 +51,10 @@
return currentEnergy <= maxStableEnergy
}
+ fun addDisplacement(displacementDelta: Float): SpringState {
+ return SpringState(displacement + displacementDelta, velocity)
+ }
+
override fun toString(): String {
return "MechanicsSpringState(displacement=$displacement, velocity=$velocity)"
}
diff --git a/mechanics/tests/Android.bp b/mechanics/tests/Android.bp
index f892ef1..b064017 100644
--- a/mechanics/tests/Android.bp
+++ b/mechanics/tests/Android.bp
@@ -20,6 +20,7 @@
android_test {
name: "mechanics_tests",
manifest: "AndroidManifest.xml",
+ defaults: ["MotionTestDefaults"],
test_suites: ["device-tests"],
srcs: [
@@ -33,12 +34,14 @@
// ":mechanics" dependencies
"androidx.compose.runtime_runtime",
"androidx.compose.ui_ui-util",
+ "androidx.compose.foundation_foundation-layout",
// ":mechanics_tests" dependencies
"androidx.compose.animation_animation-core",
"platform-test-annotations",
- "PlatformMotionTesting",
+ "PlatformMotionTestingCompose",
"androidx.compose.ui_ui-test-junit4",
+ "androidx.compose.ui_ui-test-manifest",
"androidx.test.runner",
"androidx.test.ext.junit",
"kotlin-test",
diff --git a/mechanics/tests/AndroidManifest.xml b/mechanics/tests/AndroidManifest.xml
index edbbcbf..636ebb8 100644
--- a/mechanics/tests/AndroidManifest.xml
+++ b/mechanics/tests/AndroidManifest.xml
@@ -17,10 +17,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.android.mechanics.tests">
- <application>
- <uses-library android:name="android.test.runner" />
- </application>
-
<instrumentation
android:name="androidx.test.runner.AndroidJUnitRunner"
android:label="Tests for Motion Mechanics"
diff --git a/mechanics/tests/goldens/changeDirection_flipsBetweenDirectionalSegments.json b/mechanics/tests/goldens/changeDirection_flipsBetweenDirectionalSegments.json
new file mode 100644
index 0000000..9fd0087
--- /dev/null
+++ b/mechanics/tests/goldens/changeDirection_flipsBetweenDirectionalSegments.json
@@ -0,0 +1,202 @@
+{
+ "frame_ids": [
+ 0,
+ 16,
+ 32,
+ 48,
+ 64,
+ 80,
+ 96,
+ 112,
+ 128,
+ 144,
+ 160,
+ 176,
+ 192,
+ 208,
+ 224,
+ 240
+ ],
+ "features": [
+ {
+ "name": "input",
+ "type": "float",
+ "data_points": [
+ 2,
+ 1.6,
+ 1.2,
+ 0.8000001,
+ 0.40000007,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ {
+ "name": "gestureDirection",
+ "type": "string",
+ "data_points": [
+ "Max",
+ "Max",
+ "Max",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min"
+ ]
+ },
+ {
+ "name": "output",
+ "type": "float",
+ "data_points": [
+ 0,
+ 0,
+ 0,
+ 0.12146205,
+ 0.3364076,
+ 0.53597057,
+ 0.69039464,
+ 0.79985267,
+ 0.8735208,
+ 0.92143244,
+ 0.95184386,
+ 0.97079945,
+ 0.9824491,
+ 0.98952854,
+ 1,
+ 1
+ ]
+ },
+ {
+ "name": "outputTarget",
+ "type": "float",
+ "data_points": [
+ 0,
+ 0,
+ 0,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1
+ ]
+ },
+ {
+ "name": "outputSpring",
+ "type": "springParameters",
+ "data_points": [
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 1
+ }
+ ]
+ },
+ {
+ "name": "isStable",
+ "type": "boolean",
+ "data_points": [
+ true,
+ true,
+ true,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ true,
+ true
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/changingInput_addsAnimationToMapping_becomesStable.json b/mechanics/tests/goldens/changingInput_addsAnimationToMapping_becomesStable.json
new file mode 100644
index 0000000..510426b
--- /dev/null
+++ b/mechanics/tests/goldens/changingInput_addsAnimationToMapping_becomesStable.json
@@ -0,0 +1,82 @@
+{
+ "frame_ids": [
+ 0,
+ 16,
+ 32,
+ 48
+ ],
+ "features": [
+ {
+ "name": "input",
+ "type": "float",
+ "data_points": [
+ 0,
+ 0.5,
+ 1.1,
+ 1.1
+ ]
+ },
+ {
+ "name": "gestureDirection",
+ "type": "string",
+ "data_points": [
+ "Max",
+ "Max",
+ "Max",
+ "Max"
+ ]
+ },
+ {
+ "name": "output",
+ "type": "float",
+ "data_points": [
+ 0,
+ 0,
+ 0.05119291,
+ 0.095428914
+ ]
+ },
+ {
+ "name": "outputTarget",
+ "type": "float",
+ "data_points": [
+ 0,
+ 0,
+ 0.55,
+ 0.55
+ ]
+ },
+ {
+ "name": "outputSpring",
+ "type": "springParameters",
+ "data_points": [
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ }
+ ]
+ },
+ {
+ "name": "isStable",
+ "type": "boolean",
+ "data_points": [
+ true,
+ true,
+ false,
+ false
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/directionChange_maxToMin_appliesGuarantee_afterDirectionChange.json b/mechanics/tests/goldens/directionChange_maxToMin_appliesGuarantee_afterDirectionChange.json
new file mode 100644
index 0000000..c015899
--- /dev/null
+++ b/mechanics/tests/goldens/directionChange_maxToMin_appliesGuarantee_afterDirectionChange.json
@@ -0,0 +1,182 @@
+{
+ "frame_ids": [
+ 0,
+ 16,
+ 32,
+ 48,
+ 64,
+ 80,
+ 96,
+ 112,
+ 128,
+ 144,
+ 160,
+ 176,
+ 192,
+ 208
+ ],
+ "features": [
+ {
+ "name": "input",
+ "type": "float",
+ "data_points": [
+ 2,
+ 1.5,
+ 1,
+ 0.5,
+ 0,
+ -0.5,
+ -1,
+ -1.5,
+ -2,
+ -2,
+ -2,
+ -2,
+ -2,
+ -2
+ ]
+ },
+ {
+ "name": "gestureDirection",
+ "type": "string",
+ "data_points": [
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min"
+ ]
+ },
+ {
+ "name": "output",
+ "type": "float",
+ "data_points": [
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 0.9303996,
+ 0.48961937,
+ 0.1611222,
+ 0.04164827,
+ 0.008622885,
+ 0,
+ 0
+ ]
+ },
+ {
+ "name": "outputTarget",
+ "type": "float",
+ "data_points": [
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ {
+ "name": "outputSpring",
+ "type": "springParameters",
+ "data_points": [
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 8366.601,
+ "dampingRatio": 0.95
+ },
+ {
+ "stiffness": 8366.601,
+ "dampingRatio": 0.95
+ },
+ {
+ "stiffness": 8366.601,
+ "dampingRatio": 0.95
+ },
+ {
+ "stiffness": 8366.601,
+ "dampingRatio": 0.95
+ },
+ {
+ "stiffness": 8366.601,
+ "dampingRatio": 0.95
+ },
+ {
+ "stiffness": 8366.601,
+ "dampingRatio": 0.95
+ }
+ ]
+ },
+ {
+ "name": "isStable",
+ "type": "boolean",
+ "data_points": [
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ false,
+ false,
+ false,
+ false,
+ false,
+ true,
+ true
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/directionChange_maxToMin_changesSegmentWithDirectionChange.json b/mechanics/tests/goldens/directionChange_maxToMin_changesSegmentWithDirectionChange.json
new file mode 100644
index 0000000..37b9396
--- /dev/null
+++ b/mechanics/tests/goldens/directionChange_maxToMin_changesSegmentWithDirectionChange.json
@@ -0,0 +1,262 @@
+{
+ "frame_ids": [
+ 0,
+ 16,
+ 32,
+ 48,
+ 64,
+ 80,
+ 96,
+ 112,
+ 128,
+ 144,
+ 160,
+ 176,
+ 192,
+ 208,
+ 224,
+ 240,
+ 256,
+ 272,
+ 288,
+ 304,
+ 320,
+ 336
+ ],
+ "features": [
+ {
+ "name": "input",
+ "type": "float",
+ "data_points": [
+ 2,
+ 1.5,
+ 1,
+ 0.5,
+ 0,
+ -0.5,
+ -1,
+ -1.5,
+ -2,
+ -2,
+ -2,
+ -2,
+ -2,
+ -2,
+ -2,
+ -2,
+ -2,
+ -2,
+ -2,
+ -2,
+ -2,
+ -2
+ ]
+ },
+ {
+ "name": "gestureDirection",
+ "type": "string",
+ "data_points": [
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min"
+ ]
+ },
+ {
+ "name": "output",
+ "type": "float",
+ "data_points": [
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 0.9303996,
+ 0.7829481,
+ 0.61738,
+ 0.46381497,
+ 0.3348276,
+ 0.2332502,
+ 0.15701783,
+ 0.10203475,
+ 0.06376374,
+ 0.038021922,
+ 0.021308899,
+ 0.010875165,
+ 0.0046615005,
+ 0,
+ 0
+ ]
+ },
+ {
+ "name": "outputTarget",
+ "type": "float",
+ "data_points": [
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ {
+ "name": "outputSpring",
+ "type": "springParameters",
+ "data_points": [
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ }
+ ]
+ },
+ {
+ "name": "isStable",
+ "type": "boolean",
+ "data_points": [
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ true,
+ true
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/directionChange_minToMax_changesSegmentWithDirectionChange.json b/mechanics/tests/goldens/directionChange_minToMax_changesSegmentWithDirectionChange.json
new file mode 100644
index 0000000..0c034c2
--- /dev/null
+++ b/mechanics/tests/goldens/directionChange_minToMax_changesSegmentWithDirectionChange.json
@@ -0,0 +1,262 @@
+{
+ "frame_ids": [
+ 0,
+ 16,
+ 32,
+ 48,
+ 64,
+ 80,
+ 96,
+ 112,
+ 128,
+ 144,
+ 160,
+ 176,
+ 192,
+ 208,
+ 224,
+ 240,
+ 256,
+ 272,
+ 288,
+ 304,
+ 320,
+ 336
+ ],
+ "features": [
+ {
+ "name": "input",
+ "type": "float",
+ "data_points": [
+ 0,
+ 0.5,
+ 1,
+ 1.5,
+ 2,
+ 2.5,
+ 3,
+ 3.5,
+ 4,
+ 4,
+ 4,
+ 4,
+ 4,
+ 4,
+ 4,
+ 4,
+ 4,
+ 4,
+ 4,
+ 4,
+ 4,
+ 4
+ ]
+ },
+ {
+ "name": "gestureDirection",
+ "type": "string",
+ "data_points": [
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max"
+ ]
+ },
+ {
+ "name": "output",
+ "type": "float",
+ "data_points": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0.0696004,
+ 0.21705192,
+ 0.38261998,
+ 0.536185,
+ 0.6651724,
+ 0.7667498,
+ 0.8429822,
+ 0.89796525,
+ 0.93623626,
+ 0.9619781,
+ 0.9786911,
+ 0.98912483,
+ 0.9953385,
+ 1,
+ 1
+ ]
+ },
+ {
+ "name": "outputTarget",
+ "type": "float",
+ "data_points": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1
+ ]
+ },
+ {
+ "name": "outputSpring",
+ "type": "springParameters",
+ "data_points": [
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ }
+ ]
+ },
+ {
+ "name": "isStable",
+ "type": "boolean",
+ "data_points": [
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ true,
+ true
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/emptySpec_outputMatchesInput_withoutAnimation.json b/mechanics/tests/goldens/emptySpec_outputMatchesInput_withoutAnimation.json
new file mode 100644
index 0000000..70d62ab
--- /dev/null
+++ b/mechanics/tests/goldens/emptySpec_outputMatchesInput_withoutAnimation.json
@@ -0,0 +1,112 @@
+{
+ "frame_ids": [
+ 0,
+ 16,
+ 32,
+ 48,
+ 64,
+ 80,
+ 96
+ ],
+ "features": [
+ {
+ "name": "input",
+ "type": "float",
+ "data_points": [
+ 0,
+ 20,
+ 40,
+ 60,
+ 80,
+ 100,
+ 100
+ ]
+ },
+ {
+ "name": "gestureDirection",
+ "type": "string",
+ "data_points": [
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max"
+ ]
+ },
+ {
+ "name": "output",
+ "type": "float",
+ "data_points": [
+ 0,
+ 20,
+ 40,
+ 60,
+ 80,
+ 100,
+ 100
+ ]
+ },
+ {
+ "name": "outputTarget",
+ "type": "float",
+ "data_points": [
+ 0,
+ 20,
+ 40,
+ 60,
+ 80,
+ 100,
+ 100
+ ]
+ },
+ {
+ "name": "outputSpring",
+ "type": "springParameters",
+ "data_points": [
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ }
+ ]
+ },
+ {
+ "name": "isStable",
+ "type": "boolean",
+ "data_points": [
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/segmentChange_guaranteeGestureDistance_springCompletesWithinDistance.json b/mechanics/tests/goldens/segmentChange_guaranteeGestureDistance_springCompletesWithinDistance.json
new file mode 100644
index 0000000..6eb0987
--- /dev/null
+++ b/mechanics/tests/goldens/segmentChange_guaranteeGestureDistance_springCompletesWithinDistance.json
@@ -0,0 +1,132 @@
+{
+ "frame_ids": [
+ 0,
+ 16,
+ 32,
+ 48,
+ 64,
+ 80,
+ 96,
+ 112,
+ 128
+ ],
+ "features": [
+ {
+ "name": "input",
+ "type": "float",
+ "data_points": [
+ 0,
+ 0.5,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1
+ ]
+ },
+ {
+ "name": "gestureDirection",
+ "type": "string",
+ "data_points": [
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max"
+ ]
+ },
+ {
+ "name": "output",
+ "type": "float",
+ "data_points": [
+ 0,
+ 0,
+ 0,
+ 0.13920438,
+ 0.45275474,
+ 0.772992,
+ 0.9506903,
+ 1,
+ 1
+ ]
+ },
+ {
+ "name": "outputTarget",
+ "type": "float",
+ "data_points": [
+ 0,
+ 0,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1
+ ]
+ },
+ {
+ "name": "outputSpring",
+ "type": "springParameters",
+ "data_points": [
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 1600.4729,
+ "dampingRatio": 0.9166666
+ },
+ {
+ "stiffness": 3659.3052,
+ "dampingRatio": 0.9333333
+ },
+ {
+ "stiffness": 8366.601,
+ "dampingRatio": 0.95
+ },
+ {
+ "stiffness": 19129.314,
+ "dampingRatio": 0.9666667
+ },
+ {
+ "stiffness": 43737.062,
+ "dampingRatio": 0.98333335
+ },
+ {
+ "stiffness": 43737.062,
+ "dampingRatio": 0.98333335
+ }
+ ]
+ },
+ {
+ "name": "isStable",
+ "type": "boolean",
+ "data_points": [
+ true,
+ true,
+ false,
+ false,
+ false,
+ false,
+ false,
+ true,
+ true
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/segmentChange_guaranteeInputDelta_springCompletesWithinDistance.json b/mechanics/tests/goldens/segmentChange_guaranteeInputDelta_springCompletesWithinDistance.json
new file mode 100644
index 0000000..9ca1bfa
--- /dev/null
+++ b/mechanics/tests/goldens/segmentChange_guaranteeInputDelta_springCompletesWithinDistance.json
@@ -0,0 +1,142 @@
+{
+ "frame_ids": [
+ 0,
+ 16,
+ 32,
+ 48,
+ 64,
+ 80,
+ 96,
+ 112,
+ 128,
+ 144
+ ],
+ "features": [
+ {
+ "name": "input",
+ "type": "float",
+ "data_points": [
+ 0,
+ 0.5,
+ 1,
+ 1.5,
+ 2,
+ 2.5,
+ 3,
+ 3.5,
+ 4,
+ 4
+ ]
+ },
+ {
+ "name": "gestureDirection",
+ "type": "string",
+ "data_points": [
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max"
+ ]
+ },
+ {
+ "name": "output",
+ "type": "float",
+ "data_points": [
+ 0,
+ 0,
+ 0,
+ 0.13920438,
+ 0.45275474,
+ 0.772992,
+ 0.9506903,
+ 1,
+ 1,
+ 1
+ ]
+ },
+ {
+ "name": "outputTarget",
+ "type": "float",
+ "data_points": [
+ 0,
+ 0,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1
+ ]
+ },
+ {
+ "name": "outputSpring",
+ "type": "springParameters",
+ "data_points": [
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 1600.4729,
+ "dampingRatio": 0.9166666
+ },
+ {
+ "stiffness": 3659.3052,
+ "dampingRatio": 0.9333333
+ },
+ {
+ "stiffness": 8366.601,
+ "dampingRatio": 0.95
+ },
+ {
+ "stiffness": 19129.314,
+ "dampingRatio": 0.9666667
+ },
+ {
+ "stiffness": 43737.062,
+ "dampingRatio": 0.98333335
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ }
+ ]
+ },
+ {
+ "name": "isStable",
+ "type": "boolean",
+ "data_points": [
+ true,
+ true,
+ false,
+ false,
+ false,
+ false,
+ false,
+ true,
+ true,
+ true
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/segmentChange_guaranteeNone_springAnimatesIndependentOfInput.json b/mechanics/tests/goldens/segmentChange_guaranteeNone_springAnimatesIndependentOfInput.json
new file mode 100644
index 0000000..fe6c211
--- /dev/null
+++ b/mechanics/tests/goldens/segmentChange_guaranteeNone_springAnimatesIndependentOfInput.json
@@ -0,0 +1,222 @@
+{
+ "frame_ids": [
+ 0,
+ 16,
+ 32,
+ 48,
+ 64,
+ 80,
+ 96,
+ 112,
+ 128,
+ 144,
+ 160,
+ 176,
+ 192,
+ 208,
+ 224,
+ 240,
+ 256,
+ 272
+ ],
+ "features": [
+ {
+ "name": "input",
+ "type": "float",
+ "data_points": [
+ 0,
+ 0.5,
+ 1,
+ 1.5,
+ 2,
+ 2.5,
+ 3,
+ 3.5,
+ 4,
+ 4.5,
+ 5,
+ 5,
+ 5,
+ 5,
+ 5,
+ 5,
+ 5,
+ 5
+ ]
+ },
+ {
+ "name": "gestureDirection",
+ "type": "string",
+ "data_points": [
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max"
+ ]
+ },
+ {
+ "name": "output",
+ "type": "float",
+ "data_points": [
+ 0,
+ 0,
+ 0,
+ 0.0696004,
+ 0.21705192,
+ 0.38261998,
+ 0.536185,
+ 0.6651724,
+ 0.7667498,
+ 0.8429822,
+ 0.89796525,
+ 0.93623626,
+ 0.9619781,
+ 0.9786911,
+ 0.98912483,
+ 0.9953385,
+ 1,
+ 1
+ ]
+ },
+ {
+ "name": "outputTarget",
+ "type": "float",
+ "data_points": [
+ 0,
+ 0,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1
+ ]
+ },
+ {
+ "name": "outputSpring",
+ "type": "springParameters",
+ "data_points": [
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ }
+ ]
+ },
+ {
+ "name": "isStable",
+ "type": "boolean",
+ "data_points": [
+ true,
+ true,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ true,
+ true
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/segmentChange_inMaxDirection_animatedWhenReachingBreakpoint.json b/mechanics/tests/goldens/segmentChange_inMaxDirection_animatedWhenReachingBreakpoint.json
new file mode 100644
index 0000000..e78a244
--- /dev/null
+++ b/mechanics/tests/goldens/segmentChange_inMaxDirection_animatedWhenReachingBreakpoint.json
@@ -0,0 +1,222 @@
+{
+ "frame_ids": [
+ 0,
+ 16,
+ 32,
+ 48,
+ 64,
+ 80,
+ 96,
+ 112,
+ 128,
+ 144,
+ 160,
+ 176,
+ 192,
+ 208,
+ 224,
+ 240,
+ 256,
+ 272
+ ],
+ "features": [
+ {
+ "name": "input",
+ "type": "float",
+ "data_points": [
+ 0,
+ 0.5,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1
+ ]
+ },
+ {
+ "name": "gestureDirection",
+ "type": "string",
+ "data_points": [
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max"
+ ]
+ },
+ {
+ "name": "output",
+ "type": "float",
+ "data_points": [
+ 0,
+ 0,
+ 0,
+ 0.0696004,
+ 0.21705192,
+ 0.38261998,
+ 0.536185,
+ 0.6651724,
+ 0.7667498,
+ 0.8429822,
+ 0.89796525,
+ 0.93623626,
+ 0.9619781,
+ 0.9786911,
+ 0.98912483,
+ 0.9953385,
+ 1,
+ 1
+ ]
+ },
+ {
+ "name": "outputTarget",
+ "type": "float",
+ "data_points": [
+ 0,
+ 0,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1
+ ]
+ },
+ {
+ "name": "outputSpring",
+ "type": "springParameters",
+ "data_points": [
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ }
+ ]
+ },
+ {
+ "name": "isStable",
+ "type": "boolean",
+ "data_points": [
+ true,
+ true,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ true,
+ true
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/segmentChange_inMaxDirection_springAnimationStartedRetroactively.json b/mechanics/tests/goldens/segmentChange_inMaxDirection_springAnimationStartedRetroactively.json
new file mode 100644
index 0000000..0ad35c3
--- /dev/null
+++ b/mechanics/tests/goldens/segmentChange_inMaxDirection_springAnimationStartedRetroactively.json
@@ -0,0 +1,212 @@
+{
+ "frame_ids": [
+ 0,
+ 16,
+ 32,
+ 48,
+ 64,
+ 80,
+ 96,
+ 112,
+ 128,
+ 144,
+ 160,
+ 176,
+ 192,
+ 208,
+ 224,
+ 240,
+ 256
+ ],
+ "features": [
+ {
+ "name": "input",
+ "type": "float",
+ "data_points": [
+ 0,
+ 0.5,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1
+ ]
+ },
+ {
+ "name": "gestureDirection",
+ "type": "string",
+ "data_points": [
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max"
+ ]
+ },
+ {
+ "name": "output",
+ "type": "float",
+ "data_points": [
+ 0,
+ 0,
+ 0.01973492,
+ 0.1381998,
+ 0.29998195,
+ 0.4619913,
+ 0.6040878,
+ 0.71933174,
+ 0.80780226,
+ 0.8728444,
+ 0.9189145,
+ 0.95043683,
+ 0.971274,
+ 0.9845492,
+ 0.9926545,
+ 1,
+ 1
+ ]
+ },
+ {
+ "name": "outputTarget",
+ "type": "float",
+ "data_points": [
+ 0,
+ 0,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1
+ ]
+ },
+ {
+ "name": "outputSpring",
+ "type": "springParameters",
+ "data_points": [
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ }
+ ]
+ },
+ {
+ "name": "isStable",
+ "type": "boolean",
+ "data_points": [
+ true,
+ true,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ true,
+ true
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/segmentChange_inMinDirection_animatedWhenReachingBreakpoint.json b/mechanics/tests/goldens/segmentChange_inMinDirection_animatedWhenReachingBreakpoint.json
new file mode 100644
index 0000000..333387e
--- /dev/null
+++ b/mechanics/tests/goldens/segmentChange_inMinDirection_animatedWhenReachingBreakpoint.json
@@ -0,0 +1,222 @@
+{
+ "frame_ids": [
+ 0,
+ 16,
+ 32,
+ 48,
+ 64,
+ 80,
+ 96,
+ 112,
+ 128,
+ 144,
+ 160,
+ 176,
+ 192,
+ 208,
+ 224,
+ 240,
+ 256,
+ 272
+ ],
+ "features": [
+ {
+ "name": "input",
+ "type": "float",
+ "data_points": [
+ 2,
+ 1.5,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1
+ ]
+ },
+ {
+ "name": "gestureDirection",
+ "type": "string",
+ "data_points": [
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min"
+ ]
+ },
+ {
+ "name": "output",
+ "type": "float",
+ "data_points": [
+ 1,
+ 1,
+ 1,
+ 0.9303996,
+ 0.7829481,
+ 0.61738,
+ 0.46381497,
+ 0.3348276,
+ 0.2332502,
+ 0.15701783,
+ 0.10203475,
+ 0.06376374,
+ 0.038021922,
+ 0.021308899,
+ 0.010875165,
+ 0.0046615005,
+ 0,
+ 0
+ ]
+ },
+ {
+ "name": "outputTarget",
+ "type": "float",
+ "data_points": [
+ 1,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ {
+ "name": "outputSpring",
+ "type": "springParameters",
+ "data_points": [
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ }
+ ]
+ },
+ {
+ "name": "isStable",
+ "type": "boolean",
+ "data_points": [
+ true,
+ true,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ true,
+ true
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/segmentChange_inMinDirection_springAnimationStartedRetroactively.json b/mechanics/tests/goldens/segmentChange_inMinDirection_springAnimationStartedRetroactively.json
new file mode 100644
index 0000000..87337cc
--- /dev/null
+++ b/mechanics/tests/goldens/segmentChange_inMinDirection_springAnimationStartedRetroactively.json
@@ -0,0 +1,212 @@
+{
+ "frame_ids": [
+ 0,
+ 16,
+ 32,
+ 48,
+ 64,
+ 80,
+ 96,
+ 112,
+ 128,
+ 144,
+ 160,
+ 176,
+ 192,
+ 208,
+ 224,
+ 240,
+ 256
+ ],
+ "features": [
+ {
+ "name": "input",
+ "type": "float",
+ "data_points": [
+ 2,
+ 1.5,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1
+ ]
+ },
+ {
+ "name": "gestureDirection",
+ "type": "string",
+ "data_points": [
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min"
+ ]
+ },
+ {
+ "name": "output",
+ "type": "float",
+ "data_points": [
+ 1,
+ 1,
+ 0.9802651,
+ 0.8618002,
+ 0.70001805,
+ 0.5380087,
+ 0.39591217,
+ 0.28066826,
+ 0.19219774,
+ 0.1271556,
+ 0.0810855,
+ 0.04956317,
+ 0.028725982,
+ 0.015450776,
+ 0.0073454976,
+ 0,
+ 0
+ ]
+ },
+ {
+ "name": "outputTarget",
+ "type": "float",
+ "data_points": [
+ 1,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ {
+ "name": "outputSpring",
+ "type": "springParameters",
+ "data_points": [
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ }
+ ]
+ },
+ {
+ "name": "isStable",
+ "type": "boolean",
+ "data_points": [
+ true,
+ true,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ true,
+ true
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/specChange_shiftSegmentBackwards_doesNotAnimateWithinSegment_animatesSegmentChange.json b/mechanics/tests/goldens/specChange_shiftSegmentBackwards_doesNotAnimateWithinSegment_animatesSegmentChange.json
new file mode 100644
index 0000000..04fd9bd
--- /dev/null
+++ b/mechanics/tests/goldens/specChange_shiftSegmentBackwards_doesNotAnimateWithinSegment_animatesSegmentChange.json
@@ -0,0 +1,192 @@
+{
+ "frame_ids": [
+ 0,
+ 16,
+ 32,
+ 48,
+ 64,
+ 80,
+ 96,
+ 112,
+ 128,
+ 144,
+ 160,
+ 176,
+ 192,
+ 208,
+ 224
+ ],
+ "features": [
+ {
+ "name": "input",
+ "type": "float",
+ "data_points": [
+ 0.5,
+ 0.5,
+ 0.5,
+ 0.5,
+ 0.5,
+ 0.5,
+ 0.5,
+ 0.5,
+ 0.5,
+ 0.5,
+ 0.5,
+ 0.5,
+ 0.5,
+ 0.5,
+ 0.5
+ ]
+ },
+ {
+ "name": "gestureDirection",
+ "type": "string",
+ "data_points": [
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max"
+ ]
+ },
+ {
+ "name": "output",
+ "type": "float",
+ "data_points": [
+ 1.5,
+ 1.7,
+ 1.9,
+ 1.6615533,
+ 1.226438,
+ 0.817063,
+ 0.5036553,
+ 0.28967857,
+ 0.15513074,
+ 0.07628298,
+ 0.033192396,
+ 0.011453152,
+ 0.0016130209,
+ 0,
+ 0
+ ]
+ },
+ {
+ "name": "outputTarget",
+ "type": "float",
+ "data_points": [
+ 1.5,
+ 1.7,
+ 1.9,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ {
+ "name": "outputSpring",
+ "type": "springParameters",
+ "data_points": [
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 0.9
+ }
+ ]
+ },
+ {
+ "name": "isStable",
+ "type": "boolean",
+ "data_points": [
+ true,
+ true,
+ true,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ true,
+ true
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/specChange_shiftSegmentForward_doesNotAnimateWithinSegment_animatesSegmentChange.json b/mechanics/tests/goldens/specChange_shiftSegmentForward_doesNotAnimateWithinSegment_animatesSegmentChange.json
new file mode 100644
index 0000000..675f553
--- /dev/null
+++ b/mechanics/tests/goldens/specChange_shiftSegmentForward_doesNotAnimateWithinSegment_animatesSegmentChange.json
@@ -0,0 +1,182 @@
+{
+ "frame_ids": [
+ 0,
+ 16,
+ 32,
+ 48,
+ 64,
+ 80,
+ 96,
+ 112,
+ 128,
+ 144,
+ 160,
+ 176,
+ 192,
+ 208
+ ],
+ "features": [
+ {
+ "name": "input",
+ "type": "float",
+ "data_points": [
+ 0.5,
+ 0.5,
+ 0.5,
+ 0.5,
+ 0.5,
+ 0.5,
+ 0.5,
+ 0.5,
+ 0.5,
+ 0.5,
+ 0.5,
+ 0.5,
+ 0.5,
+ 0.5
+ ]
+ },
+ {
+ "name": "gestureDirection",
+ "type": "string",
+ "data_points": [
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max"
+ ]
+ },
+ {
+ "name": "output",
+ "type": "float",
+ "data_points": [
+ 1.5,
+ 1.3,
+ 1.1,
+ 0.9619519,
+ 0.7100431,
+ 0.4730364,
+ 0.29158998,
+ 0.16770864,
+ 0.08981252,
+ 0.044163823,
+ 0.019216657,
+ 0.0066307783,
+ 0,
+ 0
+ ]
+ },
+ {
+ "name": "outputTarget",
+ "type": "float",
+ "data_points": [
+ 1.5,
+ 1.3,
+ 1.1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ {
+ "name": "outputSpring",
+ "type": "springParameters",
+ "data_points": [
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 1400,
+ "dampingRatio": 0.9
+ }
+ ]
+ },
+ {
+ "name": "isStable",
+ "type": "boolean",
+ "data_points": [
+ true,
+ true,
+ true,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ true,
+ true
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/traverseSegmentsInOneFrame_noGuarantee_combinesDiscontinuity.json b/mechanics/tests/goldens/traverseSegmentsInOneFrame_noGuarantee_combinesDiscontinuity.json
new file mode 100644
index 0000000..79fd8b3
--- /dev/null
+++ b/mechanics/tests/goldens/traverseSegmentsInOneFrame_noGuarantee_combinesDiscontinuity.json
@@ -0,0 +1,212 @@
+{
+ "frame_ids": [
+ 0,
+ 16,
+ 32,
+ 48,
+ 64,
+ 80,
+ 96,
+ 112,
+ 128,
+ 144,
+ 160,
+ 176,
+ 192,
+ 208,
+ 224,
+ 240,
+ 256
+ ],
+ "features": [
+ {
+ "name": "input",
+ "type": "float",
+ "data_points": [
+ 0,
+ 2.5,
+ 2.5,
+ 2.5,
+ 2.5,
+ 2.5,
+ 2.5,
+ 2.5,
+ 2.5,
+ 2.5,
+ 2.5,
+ 2.5,
+ 2.5,
+ 2.5,
+ 2.5,
+ 2.5,
+ 2.5
+ ]
+ },
+ {
+ "name": "gestureDirection",
+ "type": "string",
+ "data_points": [
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max"
+ ]
+ },
+ {
+ "name": "output",
+ "type": "float",
+ "data_points": [
+ 0,
+ 1.0034066,
+ 1.0953252,
+ 1.250015,
+ 1.4148881,
+ 1.5641418,
+ 1.6876622,
+ 1.783909,
+ 1.855533,
+ 1.9068143,
+ 1.9422642,
+ 1.9659443,
+ 1.981205,
+ 1.9906502,
+ 1.996214,
+ 2,
+ 2
+ ]
+ },
+ {
+ "name": "outputTarget",
+ "type": "float",
+ "data_points": [
+ 0,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2
+ ]
+ },
+ {
+ "name": "outputSpring",
+ "type": "springParameters",
+ "data_points": [
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ }
+ ]
+ },
+ {
+ "name": "isStable",
+ "type": "boolean",
+ "data_points": [
+ true,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ true,
+ true
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/traverseSegmentsInOneFrame_withDirectionChange_appliesGuarantees.json b/mechanics/tests/goldens/traverseSegmentsInOneFrame_withDirectionChange_appliesGuarantees.json
new file mode 100644
index 0000000..a2765d1
--- /dev/null
+++ b/mechanics/tests/goldens/traverseSegmentsInOneFrame_withDirectionChange_appliesGuarantees.json
@@ -0,0 +1,152 @@
+{
+ "frame_ids": [
+ 0,
+ 16,
+ 32,
+ 48,
+ 64,
+ 80,
+ 96,
+ 112,
+ 128,
+ 144,
+ 160
+ ],
+ "features": [
+ {
+ "name": "input",
+ "type": "float",
+ "data_points": [
+ 2.5,
+ 0.4,
+ 0.3,
+ 0.20000002,
+ 0.10000002,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ {
+ "name": "gestureDirection",
+ "type": "string",
+ "data_points": [
+ "Max",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min",
+ "Min"
+ ]
+ },
+ {
+ "name": "output",
+ "type": "float",
+ "data_points": [
+ 2,
+ 1.8607992,
+ 1.5158144,
+ 1.0649259,
+ 0.62475336,
+ 0.29145694,
+ 0.11132395,
+ 0.036348104,
+ 0.009979486,
+ 0,
+ 0
+ ]
+ },
+ {
+ "name": "outputTarget",
+ "type": "float",
+ "data_points": [
+ 2,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ {
+ "name": "outputSpring",
+ "type": "springParameters",
+ "data_points": [
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 1149.7095,
+ "dampingRatio": 0.90999997
+ },
+ {
+ "stiffness": 1888.3324,
+ "dampingRatio": 0.91999996
+ },
+ {
+ "stiffness": 3101.4778,
+ "dampingRatio": 0.93000007
+ },
+ {
+ "stiffness": 5094,
+ "dampingRatio": 0.94000006
+ },
+ {
+ "stiffness": 5094,
+ "dampingRatio": 0.94000006
+ },
+ {
+ "stiffness": 5094,
+ "dampingRatio": 0.94000006
+ },
+ {
+ "stiffness": 5094,
+ "dampingRatio": 0.94000006
+ },
+ {
+ "stiffness": 5094,
+ "dampingRatio": 0.94000006
+ },
+ {
+ "stiffness": 5094,
+ "dampingRatio": 0.94000006
+ }
+ ]
+ },
+ {
+ "name": "isStable",
+ "type": "boolean",
+ "data_points": [
+ true,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ true,
+ true
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/traverseSegmentsInOneFrame_withGuarantee_appliesGuarantees.json b/mechanics/tests/goldens/traverseSegmentsInOneFrame_withGuarantee_appliesGuarantees.json
new file mode 100644
index 0000000..418a6de
--- /dev/null
+++ b/mechanics/tests/goldens/traverseSegmentsInOneFrame_withGuarantee_appliesGuarantees.json
@@ -0,0 +1,182 @@
+{
+ "frame_ids": [
+ 0,
+ 16,
+ 32,
+ 48,
+ 64,
+ 80,
+ 96,
+ 112,
+ 128,
+ 144,
+ 160,
+ 176,
+ 192,
+ 208
+ ],
+ "features": [
+ {
+ "name": "input",
+ "type": "float",
+ "data_points": [
+ 0,
+ 2.1,
+ 2.1,
+ 2.1,
+ 2.1,
+ 2.1,
+ 2.1,
+ 2.1,
+ 2.1,
+ 2.1,
+ 2.1,
+ 2.1,
+ 2.1,
+ 2.1
+ ]
+ },
+ {
+ "name": "gestureDirection",
+ "type": "string",
+ "data_points": [
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max"
+ ]
+ },
+ {
+ "name": "output",
+ "type": "float",
+ "data_points": [
+ 0,
+ 5.000347,
+ 5.12011,
+ 5.3309407,
+ 5.534604,
+ 5.6969075,
+ 5.8133464,
+ 5.8910213,
+ 5.93988,
+ 5.969008,
+ 5.9854507,
+ 5.9941716,
+ 6,
+ 6
+ ]
+ },
+ {
+ "name": "outputTarget",
+ "type": "float",
+ "data_points": [
+ 0,
+ 6,
+ 6,
+ 6,
+ 6,
+ 6,
+ 6,
+ 6,
+ 6,
+ 6,
+ 6,
+ 6,
+ 6,
+ 6
+ ]
+ },
+ {
+ "name": "outputSpring",
+ "type": "springParameters",
+ "data_points": [
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 1214.8745,
+ "dampingRatio": 0.91111106
+ },
+ {
+ "stiffness": 1214.8745,
+ "dampingRatio": 0.91111106
+ },
+ {
+ "stiffness": 1214.8745,
+ "dampingRatio": 0.91111106
+ },
+ {
+ "stiffness": 1214.8745,
+ "dampingRatio": 0.91111106
+ },
+ {
+ "stiffness": 1214.8745,
+ "dampingRatio": 0.91111106
+ },
+ {
+ "stiffness": 1214.8745,
+ "dampingRatio": 0.91111106
+ },
+ {
+ "stiffness": 1214.8745,
+ "dampingRatio": 0.91111106
+ },
+ {
+ "stiffness": 1214.8745,
+ "dampingRatio": 0.91111106
+ },
+ {
+ "stiffness": 1214.8745,
+ "dampingRatio": 0.91111106
+ },
+ {
+ "stiffness": 1214.8745,
+ "dampingRatio": 0.91111106
+ },
+ {
+ "stiffness": 1214.8745,
+ "dampingRatio": 0.91111106
+ },
+ {
+ "stiffness": 1214.8745,
+ "dampingRatio": 0.91111106
+ },
+ {
+ "stiffness": 1214.8745,
+ "dampingRatio": 0.91111106
+ }
+ ]
+ },
+ {
+ "name": "isStable",
+ "type": "boolean",
+ "data_points": [
+ true,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ true,
+ true
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/goldens/traverseSegments_maxDirection_noGuarantee_addsDiscontinuityToOngoingAnimation.json b/mechanics/tests/goldens/traverseSegments_maxDirection_noGuarantee_addsDiscontinuityToOngoingAnimation.json
new file mode 100644
index 0000000..35ede9c
--- /dev/null
+++ b/mechanics/tests/goldens/traverseSegments_maxDirection_noGuarantee_addsDiscontinuityToOngoingAnimation.json
@@ -0,0 +1,302 @@
+{
+ "frame_ids": [
+ 0,
+ 16,
+ 32,
+ 48,
+ 64,
+ 80,
+ 96,
+ 112,
+ 128,
+ 144,
+ 160,
+ 176,
+ 192,
+ 208,
+ 224,
+ 240,
+ 256,
+ 272,
+ 288,
+ 304,
+ 320,
+ 336,
+ 352,
+ 368,
+ 384,
+ 400
+ ],
+ "features": [
+ {
+ "name": "input",
+ "type": "float",
+ "data_points": [
+ 0,
+ 0.2,
+ 0.4,
+ 0.6,
+ 0.8,
+ 1,
+ 1.2,
+ 1.4000001,
+ 1.6000001,
+ 1.8000002,
+ 2.0000002,
+ 2.2000003,
+ 2.4000003,
+ 2.6000004,
+ 2.8000004,
+ 3,
+ 3,
+ 3,
+ 3,
+ 3,
+ 3,
+ 3,
+ 3,
+ 3,
+ 3,
+ 3
+ ]
+ },
+ {
+ "name": "gestureDirection",
+ "type": "string",
+ "data_points": [
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max",
+ "Max"
+ ]
+ },
+ {
+ "name": "output",
+ "type": "float",
+ "data_points": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0.0696004,
+ 0.21705192,
+ 0.38261998,
+ 0.536185,
+ 0.66517246,
+ 0.8363503,
+ 1.0600344,
+ 1.2805854,
+ 1.4724215,
+ 1.6271507,
+ 1.745441,
+ 1.8321071,
+ 1.8933039,
+ 1.9350511,
+ 1.9625617,
+ 1.9800283,
+ 1.9906485,
+ 1.996761,
+ 2,
+ 2
+ ]
+ },
+ {
+ "name": "outputTarget",
+ "type": "float",
+ "data_points": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 1,
+ 1,
+ 1,
+ 1,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2,
+ 2
+ ]
+ },
+ {
+ "name": "outputSpring",
+ "type": "springParameters",
+ "data_points": [
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 100000,
+ "dampingRatio": 1
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ },
+ {
+ "stiffness": 700,
+ "dampingRatio": 0.9
+ }
+ ]
+ },
+ {
+ "name": "isStable",
+ "type": "boolean",
+ "data_points": [
+ true,
+ true,
+ true,
+ true,
+ true,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ false,
+ true,
+ true
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/mechanics/tests/src/com/android/mechanics/MotionValueTest.kt b/mechanics/tests/src/com/android/mechanics/MotionValueTest.kt
new file mode 100644
index 0000000..c5b3e17
--- /dev/null
+++ b/mechanics/tests/src/com/android/mechanics/MotionValueTest.kt
@@ -0,0 +1,512 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package com.android.mechanics
+
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.TestMonotonicFrameClock
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.android.mechanics.spec.BreakpointKey
+import com.android.mechanics.spec.DirectionalMotionSpec
+import com.android.mechanics.spec.Guarantee
+import com.android.mechanics.spec.InputDirection
+import com.android.mechanics.spec.Mapping
+import com.android.mechanics.spec.MotionSpec
+import com.android.mechanics.spec.builder
+import com.android.mechanics.spec.reverseBuilder
+import com.android.mechanics.testing.DefaultSprings.matStandardDefault
+import com.android.mechanics.testing.DefaultSprings.matStandardFast
+import com.android.mechanics.testing.MotionValueToolkit
+import com.android.mechanics.testing.MotionValueToolkit.Companion.input
+import com.android.mechanics.testing.MotionValueToolkit.Companion.isStable
+import com.android.mechanics.testing.MotionValueToolkit.Companion.output
+import com.android.mechanics.testing.goldenTest
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestCoroutineScheduler
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.withContext
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import platform.test.motion.MotionTestRule
+import platform.test.motion.testing.createGoldenPathManager
+
+@RunWith(AndroidJUnit4::class)
+class MotionValueTest {
+ private val goldenPathManager =
+ createGoldenPathManager("frameworks/libs/systemui/mechanics/tests/goldens")
+
+ @get:Rule(order = 0) val rule = createComposeRule()
+ @get:Rule(order = 1) val motion = MotionTestRule(MotionValueToolkit(rule), goldenPathManager)
+
+ @Test
+ fun emptySpec_outputMatchesInput_withoutAnimation() =
+ motion.goldenTest(
+ spec = MotionSpec.Empty,
+ verifyTimeSeries = {
+ // Output always matches the input
+ assertThat(output).containsExactlyElementsIn(input).inOrder()
+ // There must never be an ongoing animation.
+ assertThat(isStable).doesNotContain(false)
+ },
+ ) {
+ animateValueTo(100f)
+ }
+
+ // TODO the tests should describe the expected values not only in terms of goldens, but
+ // also explicitly in verifyTimeSeries
+
+ @Test
+ fun changingInput_addsAnimationToMapping_becomesStable() =
+ motion.goldenTest(
+ spec =
+ specBuilder(Mapping.Zero)
+ .toBreakpoint(1f)
+ .completeWith(Mapping.Linear(factor = 0.5f))
+ ) {
+ animateValueTo(1.1f, changePerFrame = 0.5f)
+ while (underTest.isStable) {
+ updateValue(input + 0.5f)
+ awaitFrames()
+ }
+ }
+
+ @Test
+ fun segmentChange_inMaxDirection_animatedWhenReachingBreakpoint() =
+ motion.goldenTest(
+ spec = specBuilder(Mapping.Zero).toBreakpoint(1f).completeWith(Mapping.One)
+ ) {
+ animateValueTo(1f, changePerFrame = 0.5f)
+ awaitStable()
+ }
+
+ @Test
+ fun segmentChange_inMinDirection_animatedWhenReachingBreakpoint() =
+ motion.goldenTest(
+ initialValue = 2f,
+ initialDirection = InputDirection.Min,
+ spec = specBuilder(Mapping.Zero).toBreakpoint(1f).completeWith(Mapping.One),
+ ) {
+ animateValueTo(1f, changePerFrame = 0.5f)
+ awaitStable()
+ }
+
+ @Test
+ fun segmentChange_inMaxDirection_springAnimationStartedRetroactively() =
+ motion.goldenTest(
+ spec = specBuilder(Mapping.Zero).toBreakpoint(.75f).completeWith(Mapping.One)
+ ) {
+ animateValueTo(1f, changePerFrame = 0.5f)
+ awaitStable()
+ }
+
+ @Test
+ fun segmentChange_inMinDirection_springAnimationStartedRetroactively() =
+ motion.goldenTest(
+ initialValue = 2f,
+ initialDirection = InputDirection.Min,
+ spec = specBuilder(Mapping.Zero).toBreakpoint(1.25f).completeWith(Mapping.One),
+ ) {
+ animateValueTo(1f, changePerFrame = 0.5f)
+ awaitStable()
+ }
+
+ @Test
+ fun segmentChange_guaranteeNone_springAnimatesIndependentOfInput() =
+ motion.goldenTest(
+ spec =
+ specBuilder(Mapping.Zero)
+ .toBreakpoint(1f)
+ .completeWith(Mapping.One, guarantee = Guarantee.None)
+ ) {
+ animateValueTo(5f, changePerFrame = 0.5f)
+ awaitStable()
+ }
+
+ @Test
+ fun segmentChange_guaranteeInputDelta_springCompletesWithinDistance() =
+ motion.goldenTest(
+ spec =
+ specBuilder(Mapping.Zero)
+ .toBreakpoint(1f)
+ .completeWith(Mapping.One, guarantee = Guarantee.InputDelta(3f))
+ ) {
+ animateValueTo(4f, changePerFrame = 0.5f)
+ }
+
+ @Test
+ fun segmentChange_guaranteeGestureDistance_springCompletesWithinDistance() =
+ motion.goldenTest(
+ spec =
+ specBuilder(Mapping.Zero)
+ .toBreakpoint(1f)
+ .completeWith(Mapping.One, guarantee = Guarantee.GestureDistance(3f))
+ ) {
+ animateValueTo(1f, changePerFrame = 0.5f)
+ while (!underTest.isStable) {
+ gestureContext.distance += 0.5f
+ awaitFrames()
+ }
+ }
+
+ @Test
+ fun specChange_shiftSegmentBackwards_doesNotAnimateWithinSegment_animatesSegmentChange() {
+ fun generateSpec(offset: Float) =
+ specBuilder(Mapping.Zero)
+ .toBreakpoint(offset, B1)
+ .jumpTo(1f)
+ .continueWithTargetValue(2f)
+ .toBreakpoint(offset + 1f, B2)
+ .completeWith(Mapping.Zero)
+
+ motion.goldenTest(spec = generateSpec(0f), initialValue = .5f) {
+ var offset = 0f
+ repeat(4) {
+ offset -= .2f
+ underTest.spec = generateSpec(offset)
+ awaitFrames()
+ }
+ awaitStable()
+ }
+ }
+
+ @Test
+ fun specChange_shiftSegmentForward_doesNotAnimateWithinSegment_animatesSegmentChange() {
+ fun generateSpec(offset: Float) =
+ specBuilder(Mapping.Zero)
+ .toBreakpoint(offset, B1)
+ .jumpTo(1f)
+ .continueWithTargetValue(2f)
+ .toBreakpoint(offset + 1f, B2)
+ .completeWith(Mapping.Zero)
+
+ motion.goldenTest(spec = generateSpec(0f), initialValue = .5f) {
+ var offset = 0f
+ repeat(4) {
+ offset += .2f
+ underTest.spec = generateSpec(offset)
+ awaitFrames()
+ }
+ awaitStable()
+ }
+ }
+
+ @Test
+ fun directionChange_maxToMin_changesSegmentWithDirectionChange() =
+ motion.goldenTest(
+ spec = specBuilder(Mapping.Zero).toBreakpoint(1f).completeWith(Mapping.One),
+ initialValue = 2f,
+ initialDirection = InputDirection.Max,
+ directionChangeSlop = 3f,
+ ) {
+ animateValueTo(-2f, changePerFrame = 0.5f)
+ awaitStable()
+ }
+
+ @Test
+ fun directionChange_minToMax_changesSegmentWithDirectionChange() =
+ motion.goldenTest(
+ spec = specBuilder(Mapping.Zero).toBreakpoint(1f).completeWith(Mapping.One),
+ initialValue = 0f,
+ initialDirection = InputDirection.Min,
+ directionChangeSlop = 3f,
+ ) {
+ animateValueTo(4f, changePerFrame = 0.5f)
+ awaitStable()
+ }
+
+ @Test
+ fun directionChange_maxToMin_appliesGuarantee_afterDirectionChange() =
+ motion.goldenTest(
+ spec =
+ specBuilder(Mapping.Zero)
+ .toBreakpoint(1f)
+ .completeWith(Mapping.One, guarantee = Guarantee.InputDelta(1f)),
+ initialValue = 2f,
+ initialDirection = InputDirection.Max,
+ directionChangeSlop = 3f,
+ ) {
+ animateValueTo(-2f, changePerFrame = 0.5f)
+ awaitStable()
+ }
+
+ @Test
+ fun traverseSegments_maxDirection_noGuarantee_addsDiscontinuityToOngoingAnimation() =
+ motion.goldenTest(
+ spec =
+ specBuilder(Mapping.Zero)
+ .toBreakpoint(1f)
+ .continueWith(Mapping.One)
+ .toBreakpoint(2f)
+ .completeWith(Mapping.Two)
+ ) {
+ animateValueTo(3f, changePerFrame = 0.2f)
+ awaitStable()
+ }
+
+ @Test
+ fun traverseSegmentsInOneFrame_noGuarantee_combinesDiscontinuity() =
+ motion.goldenTest(
+ spec =
+ specBuilder(Mapping.Zero)
+ .toBreakpoint(1f)
+ .continueWith(Mapping.One)
+ .toBreakpoint(2f)
+ .completeWith(Mapping.Two)
+ ) {
+ updateValue(2.5f)
+ awaitStable()
+ }
+
+ @Test
+ fun traverseSegmentsInOneFrame_withGuarantee_appliesGuarantees() =
+ motion.goldenTest(
+ spec =
+ specBuilder(Mapping.Zero)
+ .toBreakpoint(1f)
+ .jumpBy(5f, guarantee = Guarantee.InputDelta(.9f))
+ .continueWithConstantValue()
+ .toBreakpoint(2f)
+ .jumpBy(1f, guarantee = Guarantee.InputDelta(.9f))
+ .continueWithConstantValue()
+ .complete()
+ ) {
+ updateValue(2.1f)
+ awaitStable()
+ }
+
+ @Test
+ fun traverseSegmentsInOneFrame_withDirectionChange_appliesGuarantees() =
+ motion.goldenTest(
+ spec =
+ specBuilder(Mapping.Zero)
+ .toBreakpoint(1f)
+ .continueWith(Mapping.One, guarantee = Guarantee.InputDelta(1f))
+ .toBreakpoint(2f)
+ .completeWith(Mapping.Two),
+ initialValue = 2.5f,
+ initialDirection = InputDirection.Max,
+ directionChangeSlop = 1f,
+ ) {
+ updateValue(.5f)
+ animateValueTo(0f)
+ awaitStable()
+ }
+
+ @Test
+ fun changeDirection_flipsBetweenDirectionalSegments() {
+ val spec =
+ MotionSpec(
+ maxDirection = forwardSpecBuilder(Mapping.Zero).complete(),
+ minDirection = reverseSpecBuilder(Mapping.One).complete(),
+ )
+
+ motion.goldenTest(
+ spec = spec,
+ initialValue = 2f,
+ initialDirection = InputDirection.Max,
+ directionChangeSlop = 1f,
+ ) {
+ animateValueTo(0f)
+ awaitStable()
+ }
+ }
+
+ @Test
+ fun keepRunning_concurrentInvocationThrows() = runTestWithFrameClock { testScheduler, _ ->
+ val underTest = MotionValue({ 1f }, FakeGestureContext)
+ val realJob = launch { underTest.keepRunning() }
+ testScheduler.runCurrent()
+
+ assertThat(realJob.isActive).isTrue()
+ try {
+ underTest.keepRunning()
+ // keepRunning returns Nothing, will never get here
+ } catch (e: Throwable) {
+ assertThat(e).isInstanceOf(IllegalStateException::class.java)
+ assertThat(e).hasMessageThat().contains("keepRunning() invoked while already running")
+ }
+ assertThat(realJob.isActive).isTrue()
+ realJob.cancel()
+ }
+
+ @Test
+ fun keepRunning_suspendsWithoutAnAnimation() = runTest {
+ val input = mutableFloatStateOf(0f)
+ val spec = specBuilder(Mapping.Zero).toBreakpoint(1f).completeWith(Mapping.One)
+ val underTest = MotionValue(input::value, FakeGestureContext, spec)
+ rule.setContent { LaunchedEffect(Unit) { underTest.keepRunning() } }
+
+ val inspector = underTest.debugInspector()
+ var framesCount = 0
+ backgroundScope.launch { snapshotFlow { inspector.frame }.collect { framesCount++ } }
+
+ rule.awaitIdle()
+ framesCount = 0
+ rule.mainClock.autoAdvance = false
+
+ assertThat(inspector.isActive).isTrue()
+ assertThat(inspector.isAnimating).isFalse()
+
+ // Update the value, but WITHOUT causing an animation
+ input.floatValue = 0.5f
+ rule.awaitIdle()
+
+ // Still on the old frame..
+ assertThat(framesCount).isEqualTo(0)
+ // ... [underTest] is now waiting for an animation frame
+ assertThat(inspector.isAnimating).isTrue()
+
+ rule.mainClock.advanceTimeByFrame()
+ rule.awaitIdle()
+
+ // Produces the frame..
+ assertThat(framesCount).isEqualTo(1)
+ // ... and is suspended again.
+ assertThat(inspector.isAnimating).isFalse()
+
+ rule.mainClock.autoAdvance = true
+ rule.awaitIdle()
+ // Ensure that no more frames are produced
+ assertThat(framesCount).isEqualTo(1)
+ }
+
+ @Test
+ fun keepRunning_remainsActiveWhileAnimating() = runTest {
+ val input = mutableFloatStateOf(0f)
+ val spec = specBuilder(Mapping.Zero).toBreakpoint(1f).completeWith(Mapping.One)
+ val underTest = MotionValue(input::value, FakeGestureContext, spec)
+ rule.setContent { LaunchedEffect(Unit) { underTest.keepRunning() } }
+
+ val inspector = underTest.debugInspector()
+ var framesCount = 0
+ backgroundScope.launch { snapshotFlow { inspector.frame }.collect { framesCount++ } }
+
+ rule.awaitIdle()
+ framesCount = 0
+ rule.mainClock.autoAdvance = false
+
+ assertThat(inspector.isActive).isTrue()
+ assertThat(inspector.isAnimating).isFalse()
+
+ // Update the value, WITH triggering an animation
+ input.floatValue = 1.5f
+ rule.awaitIdle()
+
+ // Still on the old frame..
+ assertThat(framesCount).isEqualTo(0)
+ // ... [underTest] is now waiting for an animation frame
+ assertThat(inspector.isAnimating).isTrue()
+
+ // A couple frames should be generated without pausing
+ repeat(5) {
+ rule.mainClock.advanceTimeByFrame()
+ rule.awaitIdle()
+
+ // The spring is still settling...
+ assertThat(inspector.frame.isStable).isFalse()
+ // ... animation keeps going ...
+ assertThat(inspector.isAnimating).isTrue()
+ // ... and frames are produces...
+ assertThat(framesCount).isEqualTo(it + 1)
+ }
+
+ // But this will stop as soon as the animation is finished. Skip forward.
+ rule.mainClock.autoAdvance = true
+ rule.awaitIdle()
+
+ // At which point the spring is stable again...
+ assertThat(inspector.frame.isStable).isTrue()
+ // ... and animations are suspended again.
+ assertThat(inspector.isAnimating).isFalse()
+ // Without too many assumptions about how long it took to settle the spring, should be
+ // more than 160ms
+ assertThat(framesCount).isGreaterThan(10)
+ }
+
+ @Test
+ fun debugInspector_sameInstance_whileInUse() {
+ val underTest = MotionValue({ 1f }, FakeGestureContext)
+
+ val originalInspector = underTest.debugInspector()
+ assertThat(underTest.debugInspector()).isSameInstanceAs(originalInspector)
+ }
+
+ @Test
+ fun debugInspector_newInstance_afterUnused() {
+ val underTest = MotionValue({ 1f }, FakeGestureContext)
+
+ val originalInspector = underTest.debugInspector()
+ originalInspector.dispose()
+ assertThat(underTest.debugInspector()).isNotSameInstanceAs(originalInspector)
+ }
+
+ @OptIn(ExperimentalTestApi::class)
+ private fun runTestWithFrameClock(
+ testBody:
+ suspend CoroutineScope.(
+ testScheduler: TestCoroutineScheduler, backgroundScope: CoroutineScope,
+ ) -> Unit
+ ) = runTest {
+ val testScope: TestScope = this
+ withContext(TestMonotonicFrameClock(testScope, FrameDelayNanos)) {
+ testBody(testScope.testScheduler, testScope.backgroundScope)
+ }
+ }
+
+ companion object {
+ val B1 = BreakpointKey("breakpoint1")
+ val B2 = BreakpointKey("breakpoint2")
+ val FakeGestureContext =
+ object : GestureContext {
+ override val direction: InputDirection
+ get() = InputDirection.Max
+
+ override val distance: Float
+ get() = 0f
+ }
+ private val FrameDelayNanos: Long = 16_000_000L
+
+ fun specBuilder(firstSegment: Mapping = Mapping.Identity) =
+ MotionSpec.builder(
+ defaultSpring = matStandardDefault,
+ resetSpring = matStandardFast,
+ initialMapping = firstSegment,
+ )
+
+ fun forwardSpecBuilder(firstSegment: Mapping = Mapping.Identity) =
+ DirectionalMotionSpec.builder(
+ defaultSpring = matStandardDefault,
+ initialMapping = firstSegment,
+ )
+
+ fun reverseSpecBuilder(firstSegment: Mapping = Mapping.Identity) =
+ DirectionalMotionSpec.reverseBuilder(
+ defaultSpring = matStandardDefault,
+ initialMapping = firstSegment,
+ )
+ }
+}
diff --git a/mechanics/tests/src/com/android/mechanics/testing/DefaultSprings.kt b/mechanics/tests/src/com/android/mechanics/testing/DefaultSprings.kt
new file mode 100644
index 0000000..3d43d34
--- /dev/null
+++ b/mechanics/tests/src/com/android/mechanics/testing/DefaultSprings.kt
@@ -0,0 +1,72 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.mechanics.testing
+
+import com.android.mechanics.spring.SpringParameters
+
+object DefaultSprings {
+ val matStandardDefault =
+ SpringParameters(
+ stiffness = StandardMotionTokens.SpringDefaultSpatialStiffness,
+ dampingRatio = StandardMotionTokens.SpringDefaultSpatialDamping,
+ )
+ val matStandardFast =
+ SpringParameters(
+ stiffness = StandardMotionTokens.SpringFastSpatialStiffness,
+ dampingRatio = StandardMotionTokens.SpringFastSpatialDamping,
+ )
+ val matExpressiveDefault =
+ SpringParameters(
+ stiffness = ExpressiveMotionTokens.SpringDefaultSpatialStiffness,
+ dampingRatio = ExpressiveMotionTokens.SpringDefaultSpatialDamping,
+ )
+ val matExpressiveFast =
+ SpringParameters(
+ stiffness = ExpressiveMotionTokens.SpringFastSpatialStiffness,
+ dampingRatio = ExpressiveMotionTokens.SpringFastSpatialDamping,
+ )
+
+ internal object StandardMotionTokens {
+ val SpringDefaultSpatialDamping = 0.9f
+ val SpringDefaultSpatialStiffness = 700.0f
+ val SpringDefaultEffectsDamping = 1.0f
+ val SpringDefaultEffectsStiffness = 1600.0f
+ val SpringFastSpatialDamping = 0.9f
+ val SpringFastSpatialStiffness = 1400.0f
+ val SpringFastEffectsDamping = 1.0f
+ val SpringFastEffectsStiffness = 3800.0f
+ val SpringSlowSpatialDamping = 0.9f
+ val SpringSlowSpatialStiffness = 300.0f
+ val SpringSlowEffectsDamping = 1.0f
+ val SpringSlowEffectsStiffness = 800.0f
+ }
+
+ internal object ExpressiveMotionTokens {
+ val SpringDefaultSpatialDamping = 0.8f
+ val SpringDefaultSpatialStiffness = 380.0f
+ val SpringDefaultEffectsDamping = 1.0f
+ val SpringDefaultEffectsStiffness = 1600.0f
+ val SpringFastSpatialDamping = 0.6f
+ val SpringFastSpatialStiffness = 800.0f
+ val SpringFastEffectsDamping = 1.0f
+ val SpringFastEffectsStiffness = 3800.0f
+ val SpringSlowSpatialDamping = 0.8f
+ val SpringSlowSpatialStiffness = 200.0f
+ val SpringSlowEffectsDamping = 1.0f
+ val SpringSlowEffectsStiffness = 800.0f
+ }
+}
diff --git a/mechanics/tests/src/com/android/mechanics/testing/MotionValueToolkit.kt b/mechanics/tests/src/com/android/mechanics/testing/MotionValueToolkit.kt
new file mode 100644
index 0000000..edf87c2
--- /dev/null
+++ b/mechanics/tests/src/com/android/mechanics/testing/MotionValueToolkit.kt
@@ -0,0 +1,250 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+@file:OptIn(ExperimentalTestApi::class, ExperimentalCoroutinesApi::class)
+
+package com.android.mechanics.testing
+
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.junit4.ComposeContentTestRule
+import com.android.mechanics.DistanceGestureContext
+import com.android.mechanics.MotionValue
+import com.android.mechanics.debug.FrameData
+import com.android.mechanics.spec.InputDirection
+import com.android.mechanics.spec.MotionSpec
+import kotlin.math.abs
+import kotlin.math.floor
+import kotlin.math.sign
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.flow.take
+import kotlinx.coroutines.flow.takeWhile
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import platform.test.motion.MotionTestRule
+import platform.test.motion.RecordedMotion.Companion.create
+import platform.test.motion.golden.Feature
+import platform.test.motion.golden.FrameId
+import platform.test.motion.golden.TimeSeries
+import platform.test.motion.golden.TimestampFrameId
+import platform.test.motion.golden.ValueDataPoint
+import platform.test.motion.golden.asDataPoint
+
+/** Toolkit to support [MotionValue] motion tests. */
+class MotionValueToolkit(val composeTestRule: ComposeContentTestRule) {
+ companion object {
+
+ val TimeSeries.input: List<Float>
+ get() = dataPoints("input")
+
+ val TimeSeries.output: List<Float>
+ get() = dataPoints("output")
+
+ val TimeSeries.outputTarget: List<Float>
+ get() = dataPoints("outputTarget")
+
+ val TimeSeries.isStable: List<Boolean>
+ get() = dataPoints("isStable")
+
+ internal const val TAG = "MotionValueToolkit"
+
+ private fun <T> TimeSeries.dataPoints(featureName: String): List<T> {
+ @Suppress("UNCHECKED_CAST")
+ return (features[featureName] as Feature<T>).dataPoints.map {
+ require(it is ValueDataPoint)
+ it.value
+ }
+ }
+ }
+}
+
+interface InputScope {
+ val input: Float
+ val gestureContext: DistanceGestureContext
+ val underTest: MotionValue
+
+ suspend fun awaitStable()
+
+ suspend fun awaitFrames(frames: Int = 1)
+
+ var directionChangeSlop: Float
+
+ fun updateValue(position: Float)
+
+ suspend fun animateValueTo(
+ targetValue: Float,
+ changePerFrame: Float = abs(input - targetValue) / 5f,
+ )
+
+ fun reset(position: Float, direction: InputDirection)
+}
+
+fun MotionTestRule<MotionValueToolkit>.goldenTest(
+ spec: MotionSpec,
+ initialValue: Float = 0f,
+ initialDirection: InputDirection = InputDirection.Max,
+ directionChangeSlop: Float = 5f,
+ stableThreshold: Float = 0.01f,
+ verifyTimeSeries: TimeSeries.() -> Unit = {},
+ testInput: suspend InputScope.() -> Unit,
+) = runTest {
+ with(toolkit.composeTestRule) {
+ val frameEmitter = MutableStateFlow<Long>(0)
+
+ val testHarness =
+ MotionValueTestHarness(
+ initialValue,
+ initialDirection,
+ spec,
+ stableThreshold,
+ directionChangeSlop,
+ frameEmitter.asStateFlow(),
+ )
+ val underTest = testHarness.underTest
+ val inspector = underTest.debugInspector()
+
+ setContent { LaunchedEffect(Unit) { underTest.keepRunning() } }
+
+ val recordingJob = launch { testInput.invoke(testHarness) }
+
+ waitForIdle()
+ mainClock.autoAdvance = false
+
+ val frameIds = mutableListOf<FrameId>()
+ val frameData = mutableListOf<FrameData>()
+
+ fun recordFrame(frameId: TimestampFrameId) {
+ frameIds.add(frameId)
+ frameData.add(inspector.frame)
+ }
+
+ val startFrameTime = mainClock.currentTime
+ recordFrame(TimestampFrameId(mainClock.currentTime - startFrameTime))
+ while (!recordingJob.isCompleted) {
+ frameEmitter.tryEmit(mainClock.currentTime + 16)
+ runCurrent()
+ mainClock.advanceTimeByFrame()
+ recordFrame(TimestampFrameId(mainClock.currentTime - startFrameTime))
+ }
+
+ val timeSeries =
+ TimeSeries(
+ frameIds.toList(),
+ listOf(
+ Feature("input", frameData.map { it.input.asDataPoint() }),
+ Feature(
+ "gestureDirection",
+ frameData.map { it.gestureDirection.name.asDataPoint() },
+ ),
+ Feature("output", frameData.map { it.output.asDataPoint() }),
+ Feature("outputTarget", frameData.map { it.outputTarget.asDataPoint() }),
+ Feature("outputSpring", frameData.map { it.springParameters.asDataPoint() }),
+ Feature("isStable", frameData.map { it.isStable.asDataPoint() }),
+ ),
+ )
+
+ inspector.dispose()
+
+ val recordedMotion = create(timeSeries, screenshots = null)
+ verifyTimeSeries.invoke(recordedMotion.timeSeries)
+ assertThat(recordedMotion).timeSeriesMatchesGolden()
+ }
+}
+
+private class MotionValueTestHarness(
+ initialInput: Float,
+ initialDirection: InputDirection,
+ spec: MotionSpec,
+ stableThreshold: Float,
+ directionChangeSlop: Float,
+ val onFrame: StateFlow<Long>,
+) : InputScope {
+
+ override var input by mutableFloatStateOf(initialInput)
+ override val gestureContext: DistanceGestureContext =
+ DistanceGestureContext(initialInput, initialDirection, directionChangeSlop)
+
+ override val underTest =
+ MotionValue(
+ { input },
+ gestureContext,
+ stableThreshold = stableThreshold,
+ initialSpec = spec,
+ )
+
+ override fun updateValue(position: Float) {
+ input = position
+ gestureContext.distance = position
+ }
+
+ override var directionChangeSlop: Float
+ get() = gestureContext.directionChangeSlop
+ set(value) {
+ gestureContext.directionChangeSlop = value
+ }
+
+ override suspend fun awaitStable() {
+ val debugInspector = underTest.debugInspector()
+ try {
+
+ onFrame
+ // Since this is a state-flow, the current frame is counted too.
+ .drop(1)
+ .takeWhile { !debugInspector.frame.isStable }
+ .collect {}
+ } finally {
+ debugInspector.dispose()
+ }
+ }
+
+ override suspend fun awaitFrames(frames: Int) {
+ onFrame
+ // Since this is a state-flow, the current frame is counted too.
+ .drop(1)
+ .take(frames)
+ .collect {}
+ }
+
+ override suspend fun animateValueTo(targetValue: Float, changePerFrame: Float) {
+ require(changePerFrame > 0f)
+ var currentValue = input
+ val delta = targetValue - currentValue
+ val step = changePerFrame * delta.sign
+
+ val stepCount = floor((abs(delta) / changePerFrame) - 1).toInt()
+ repeat(stepCount) {
+ currentValue += step
+ updateValue(currentValue)
+ awaitFrames()
+ }
+
+ updateValue(targetValue)
+ awaitFrames()
+ }
+
+ override fun reset(position: Float, direction: InputDirection) {
+ input = position
+ gestureContext.reset(position, direction)
+ }
+}
diff --git a/msdllib/src/com/google/android/msdl/data/repository/MSDLRepositoryImpl.kt b/msdllib/src/com/google/android/msdl/data/repository/MSDLRepositoryImpl.kt
index 7555907..81979a2 100644
--- a/msdllib/src/com/google/android/msdl/data/repository/MSDLRepositoryImpl.kt
+++ b/msdllib/src/com/google/android/msdl/data/repository/MSDLRepositoryImpl.kt
@@ -308,12 +308,12 @@
HapticComposition(
listOf(
HapticCompositionPrimitive(
- VibrationEffect.Composition.PRIMITIVE_TICK,
- scale = 0.7f,
+ VibrationEffect.Composition.PRIMITIVE_CLICK,
+ scale = 0.5f,
delayMillis = 0,
)
),
- VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK),
+ VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK),
)
},
HapticToken.KEYPRESS_SPACEBAR to
diff --git a/tracinglib/core/src/coroutines/TraceContextElement.kt b/tracinglib/core/src/coroutines/TraceContextElement.kt
index fce8123..bed08cd 100644
--- a/tracinglib/core/src/coroutines/TraceContextElement.kt
+++ b/tracinglib/core/src/coroutines/TraceContextElement.kt
@@ -28,8 +28,11 @@
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
+import kotlin.coroutines.AbstractCoroutineContextKey
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
+import kotlin.coroutines.getPolymorphicElement
+import kotlin.coroutines.minusPolymorphicKey
import kotlinx.coroutines.CopyableThreadContextElement
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
@@ -51,13 +54,11 @@
*/
@PublishedApi internal val traceThreadLocal: TraceDataThreadLocal = TraceDataThreadLocal()
-private val alwaysEnableStackWalker: Boolean by lazy {
+private val alwaysEnableStackWalker =
SystemProperties.getBoolean("debug.coroutine_tracing.walk_stack_override", false)
-}
-private val alwaysEnableContinuationCounting: Boolean by lazy {
+private val alwaysEnableContinuationCounting =
SystemProperties.getBoolean("debug.coroutine_tracing.count_continuations_override", false)
-}
/**
* Returns a new [TraceContextElement] (or [EmptyCoroutineContext] if `coroutine_tracing` feature is
@@ -96,40 +97,39 @@
* @param walkStackForDefaultNames whether to walk the stack and use the class name of the current
* suspending function if child does not have a name that was manually specified. Walking the
* stack is very expensive so this should not be used in production.
- * @param countContinuations whether to include an extra trace section showing the total number of
- * times a coroutine has suspended and resumed.
+ * @param countContinuations whether to include extra info in the trace section indicating the total
+ * number of times a coroutine has suspended and resumed (e.g. ";n=#")
+ * @param countDepth whether to include extra info in the trace section indicating the how far from
+ * the root trace context this coroutine is (e.g. ";d=#")
* @param testMode changes behavior is several ways: 1) parent names and sibling counts are
* concatenated with the name of the child. This can result in extremely long trace names, which
* is why it is only for testing. 2) additional strict-mode checks are added to coroutine tracing
* machinery. These checks are expensive and should only be used for testing. 3) omits "coroutine
- * execution" trace slices, and omits coroutine metadata slices
+ * execution" trace slices, and omits coroutine metadata slices. If [testMode] is enabled,
+ * [countContinuations] and [countDepth] are ignored.
* @param shouldIgnoreClassName lambda that takes binary class name (as returned from
* [StackFrame.getClassName] and returns true if it should be ignored (e.g. search for relevant
* class name should continue) or false otherwise.
*/
public fun createCoroutineTracingContext(
name: String = "UnnamedScope",
- walkStackForDefaultNames: Boolean = false,
countContinuations: Boolean = false,
+ countDepth: Boolean = false,
testMode: Boolean = false,
+ walkStackForDefaultNames: Boolean = false,
shouldIgnoreClassName: (String) -> Boolean = { false },
): CoroutineContext {
return if (Flags.coroutineTracing()) {
TraceContextElement(
name = name,
- // Minor perf optimization: no need to create TraceData() for root scopes since all
- // launches require creation of child via [copyForChild] or [mergeForChild].
- contextTraceData = null,
- config =
- TraceConfig(
- walkStackForDefaultNames = walkStackForDefaultNames || alwaysEnableStackWalker,
- testMode = testMode,
- shouldIgnoreClassName = shouldIgnoreClassName,
- countContinuations = countContinuations || alwaysEnableContinuationCounting,
- ),
+ isRoot = true,
+ countContinuations =
+ !testMode && (countContinuations || alwaysEnableContinuationCounting),
+ walkStackForDefaultNames = walkStackForDefaultNames || alwaysEnableStackWalker,
+ shouldIgnoreClassName = shouldIgnoreClassName,
parentId = null,
- inheritedTracePrefix = "",
- coroutineDepth = 0,
+ inheritedTracePrefix = if (testMode) "" else null,
+ coroutineDepth = if (!testMode && countDepth) 0 else -1,
)
} else {
EmptyCoroutineContext
@@ -163,7 +163,8 @@
/**
* Common base class of [TraceContextElement] and [CoroutineTraceName]. For internal use only.
*
- * [TraceContextElement] should be installed on the root, and [CoroutineTraceName] on the children.
+ * [TraceContextElement] should be installed on the root, and [CoroutineTraceName] should ONLY be
+ * used when launching children to give them names.
*
* @property name the name of the current coroutine
*/
@@ -173,15 +174,15 @@
* @property name the name to be used for the child under construction
* @see nameCoroutine
*/
+@OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class)
@PublishedApi
-internal open class CoroutineTraceName(internal val name: String) : CoroutineContext.Element {
- internal companion object Key : CoroutineContext.Key<CoroutineTraceName>
+internal open class CoroutineTraceName(internal val name: String) :
+ CopyableThreadContextElement<TraceData?>, CoroutineContext.Element {
+ companion object Key : CoroutineContext.Key<CoroutineTraceName>
- public override val key: CoroutineContext.Key<*>
+ override val key: CoroutineContext.Key<*>
get() = Key
- protected val currentId: Int = ThreadLocalRandom.current().nextInt(1, Int.MAX_VALUE)
-
@Deprecated(
message =
"""
@@ -194,23 +195,40 @@
level = DeprecationLevel.ERROR,
)
public operator fun plus(other: CoroutineTraceName): CoroutineTraceName {
- debug { "#plus(${other.currentId})" }
+ debug { "CTN#plus;c=$name other=${other.name}" }
return other
}
- @OptIn(ExperimentalContracts::class)
- protected inline fun debug(message: () -> String) {
- contract { callsInPlace(message, InvocationKind.AT_MOST_ONCE) }
- if (DEBUG) Log.d(TAG, "${this::class.java.simpleName}@$currentId${message()}")
- }
-}
+ @OptIn(ExperimentalStdlibApi::class)
+ override fun <E : CoroutineContext.Element> get(key: CoroutineContext.Key<E>): E? =
+ getPolymorphicElement(key)
-internal class TraceConfig(
- val walkStackForDefaultNames: Boolean,
- val testMode: Boolean,
- val shouldIgnoreClassName: (String) -> Boolean,
- val countContinuations: Boolean,
-)
+ @OptIn(ExperimentalStdlibApi::class)
+ override fun minusKey(key: CoroutineContext.Key<*>): CoroutineContext = minusPolymorphicKey(key)
+
+ override fun copyForChild(): CopyableThreadContextElement<TraceData?> {
+ // Reusing this object is okay because we aren't persisting to thread local store.
+ // This object only exists for the purpose of storing a String for future use
+ // by TraceContextElement.
+ return this
+ }
+
+ override fun mergeForChild(overwritingElement: CoroutineContext.Element): CoroutineContext {
+ return if (overwritingElement is TraceContextElement) {
+ overwritingElement.createChildContext(name)
+ } else if (overwritingElement is CoroutineTraceName) {
+ overwritingElement
+ } else {
+ EmptyCoroutineContext
+ }
+ }
+
+ override fun updateThreadContext(context: CoroutineContext): TraceData? {
+ return null
+ }
+
+ override fun restoreThreadContext(context: CoroutineContext, oldState: TraceData?) {}
+}
/**
* Used for tracking parent-child relationship of coroutines and persisting [TraceData] when
@@ -230,6 +248,7 @@
* would be used instead: `root-scope:3^`
* @param coroutineDepth How deep the coroutine is relative to the top-level [CoroutineScope]
* containing the original [TraceContextElement] from which this [TraceContextElement] was copied.
+ * If -1, counting depth is disabled
* @see createCoroutineTracingContext
* @see nameCoroutine
* @see traceCoroutine
@@ -237,31 +256,52 @@
@OptIn(DelicateCoroutinesApi::class, ExperimentalCoroutinesApi::class)
internal class TraceContextElement(
name: String,
- internal val contextTraceData: TraceData?,
- private val config: TraceConfig,
+ private val isRoot: Boolean,
+ private val countContinuations: Boolean,
+ private val walkStackForDefaultNames: Boolean,
+ private val shouldIgnoreClassName: (String) -> Boolean,
parentId: Int?,
- inheritedTracePrefix: String,
+ inheritedTracePrefix: String?,
coroutineDepth: Int,
-) : CopyableThreadContextElement<TraceData?>, CoroutineTraceName(name) {
+) : CoroutineTraceName(name) {
- private val coroutineTraceName =
- if (config.testMode) "$inheritedTracePrefix$name"
- else "$name;d=$coroutineDepth;c=$currentId;p=${parentId ?: "none"}"
+ protected val currentId: Int = ThreadLocalRandom.current().nextInt(1, Int.MAX_VALUE)
+
+ @OptIn(ExperimentalStdlibApi::class)
+ companion object Key :
+ AbstractCoroutineContextKey<CoroutineTraceName, TraceContextElement>(
+ CoroutineTraceName,
+ { it as? TraceContextElement },
+ )
init {
- debug { "#init: name=$name" }
- Trace.traceBegin(Trace.TRACE_TAG_APP, "TraceContextElement#init[$coroutineTraceName]")
+ val traceSection = "TCE#init name=$name"
+ debug { traceSection }
+ Trace.traceBegin(Trace.TRACE_TAG_APP, traceSection)
}
- private var continuationCount = 0
- private val childDepth = coroutineDepth + 1
- private var childCoroutineCount = if (config.testMode) AtomicInteger(0) else null
+ // Minor perf optimization: no need to create TraceData() for root scopes since all launches
+ // require creation of child via [copyForChild] or [mergeForChild].
+ internal val contextTraceData: TraceData? =
+ if (isRoot) null else TraceData(currentId, strictMode = inheritedTracePrefix != null)
- private val copyForChildTraceMessage = "TraceContextElement#copyForChild[$coroutineTraceName]"
- private val mergeForChildTraceMessage = "TraceContextElement#mergeForChild[$coroutineTraceName]"
+ private val coroutineTraceName =
+ if (inheritedTracePrefix == null) {
+ "coroutine execution;$name;c=$currentId;p=${parentId ?: "none"}${if (coroutineDepth == -1) "" else ";d=$coroutineDepth"}"
+ } else {
+ "$inheritedTracePrefix$name"
+ }
+
+ private var continuationCount = 0
+ private val childDepth =
+ if (inheritedTracePrefix != null || coroutineDepth == -1) -1 else coroutineDepth + 1
+ private val childCoroutineCount = if (inheritedTracePrefix != null) AtomicInteger(0) else null
+
+ private val copyForChildTraceMessage = "TCE#copy;c=$currentId]"
+ private val mergeForChildTraceMessage = "TCE#merge;c=$currentId]"
init {
- Trace.traceEnd(Trace.TRACE_TAG_APP)
+ Trace.traceEnd(Trace.TRACE_TAG_APP) // end: "TCE#init"
}
/**
@@ -282,16 +322,14 @@
@SuppressLint("UnclosedTrace")
public override fun updateThreadContext(context: CoroutineContext): TraceData? {
val oldState = traceThreadLocal.get()
- debug { "#updateThreadContext oldState=$oldState" }
+ debug { "TCE#update;c=$currentId oldState=${oldState?.currentId}" }
if (oldState !== contextTraceData) {
- if (!config.testMode) {
- Trace.traceBegin(Trace.TRACE_TAG_APP, "coroutine execution")
- }
- Trace.traceBegin(Trace.TRACE_TAG_APP, coroutineTraceName)
- if (config.countContinuations) {
- Trace.traceBegin(Trace.TRACE_TAG_APP, "continuation: #${continuationCount++}")
- }
traceThreadLocal.set(contextTraceData)
+ Trace.traceBegin(
+ Trace.TRACE_TAG_APP,
+ if (countContinuations) "$coroutineTraceName;n=${continuationCount++}"
+ else coroutineTraceName,
+ )
// Calls to `updateThreadContext` will not happen in parallel on the same context, and
// they cannot happen before the prior suspension point. Additionally,
// `restoreThreadContext` does not modify `traceData`, so it is safe to iterate over the
@@ -329,7 +367,7 @@
* ```
*/
public override fun restoreThreadContext(context: CoroutineContext, oldState: TraceData?) {
- debug { "#restoreThreadContext restoring=$oldState" }
+ debug { "TCE#restore;c=$currentId restoring=${oldState?.currentId}" }
// We not use the `TraceData` object here because it may have been modified on another
// thread after the last suspension point. This is why we use a [TraceStateHolder]:
// so we can end the correct number of trace sections, restoring the thread to its state
@@ -337,21 +375,15 @@
if (oldState !== traceThreadLocal.get()) {
contextTraceData?.endAllOnThread()
traceThreadLocal.set(oldState)
- if (!config.testMode) {
- Trace.traceEnd(Trace.TRACE_TAG_APP) // end: "coroutine execution"
- }
- Trace.traceEnd(Trace.TRACE_TAG_APP) // end: contextMetadata
- if (config.countContinuations) {
- Trace.traceEnd(Trace.TRACE_TAG_APP) // end: continuation: #
- }
+ Trace.traceEnd(Trace.TRACE_TAG_APP) // end: coroutineTraceName
}
}
public override fun copyForChild(): CopyableThreadContextElement<TraceData?> {
- debug { "#copyForChild" }
+ debug { copyForChildTraceMessage }
try {
Trace.traceBegin(Trace.TRACE_TAG_APP, copyForChildTraceMessage)
- return createChildContext()
+ return createChildContext(null)
} finally {
Trace.traceEnd(Trace.TRACE_TAG_APP) // end: copyForChildTraceMessage
}
@@ -360,16 +392,16 @@
public override fun mergeForChild(
overwritingElement: CoroutineContext.Element
): CoroutineContext {
- debug { "#mergeForChild" }
- if (DEBUG) {
+ debug {
(overwritingElement as? TraceContextElement)?.let {
- Log.e(
- TAG,
+ val msg =
"${this::class.java.simpleName}@$currentId#mergeForChild(@${it.currentId}): " +
"current name=\"$name\", overwritingElement name=\"${it.name}\". " +
- UNEXPECTED_TRACE_DATA_ERROR_MESSAGE,
- )
+ UNEXPECTED_TRACE_DATA_ERROR_MESSAGE
+ Trace.instant(Trace.TRACE_TAG_APP, msg)
+ Log.e(TAG, msg)
}
+ return@debug mergeForChildTraceMessage
}
try {
Trace.traceBegin(Trace.TRACE_TAG_APP, mergeForChildTraceMessage)
@@ -380,23 +412,25 @@
}
}
- private fun createChildContext(name: String? = null): TraceContextElement {
- val childName =
- name
- ?: if (config.walkStackForDefaultNames)
- walkStackForClassName(config.shouldIgnoreClassName)
- else ""
- debug { "#createChildContext: \"${this.name}\" has new child with name \"${childName}\"" }
+ internal fun createChildContext(childName: String?): TraceContextElement {
return TraceContextElement(
- name = childName,
- contextTraceData = TraceData(strictMode = config.testMode),
- config = config,
+ name =
+ childName
+ ?: if (walkStackForDefaultNames) {
+ walkStackForClassName(shouldIgnoreClassName)
+ } else {
+ ""
+ },
+ isRoot = false,
+ countContinuations = countContinuations,
+ walkStackForDefaultNames = walkStackForDefaultNames,
+ shouldIgnoreClassName = shouldIgnoreClassName,
parentId = currentId,
inheritedTracePrefix =
- if (config.testMode) {
- val childCount = childCoroutineCount?.incrementAndGet() ?: 0
+ if (childCoroutineCount != null) {
+ val childCount = childCoroutineCount.incrementAndGet()
"$coroutineTraceName:$childCount^"
- } else "",
+ } else null,
coroutineDepth = childDepth,
)
}
@@ -439,3 +473,13 @@
@PublishedApi internal const val TAG: String = "CoroutineTracing"
@PublishedApi internal const val DEBUG: Boolean = false
+
+@OptIn(ExperimentalContracts::class)
+private inline fun debug(message: () -> String) {
+ contract { callsInPlace(message, InvocationKind.AT_MOST_ONCE) }
+ if (DEBUG) {
+ val msg = message()
+ Trace.instant(Trace.TRACE_TAG_APP, msg)
+ Log.d(TAG, msg)
+ }
+}
diff --git a/tracinglib/core/src/coroutines/TraceData.kt b/tracinglib/core/src/coroutines/TraceData.kt
index 49cea0d..d1a4da6 100644
--- a/tracinglib/core/src/coroutines/TraceData.kt
+++ b/tracinglib/core/src/coroutines/TraceData.kt
@@ -38,13 +38,14 @@
* Used for storing trace sections so that they can be added and removed from the currently running
* thread when the coroutine is suspended and resumed.
*
+ * @property currentId ID of associated TraceContextElement
* @property strictMode Whether to add additional checks to the coroutine machinery, throwing a
* `ConcurrentModificationException` if TraceData is modified from the wrong thread. This should
* only be set for testing.
* @see traceCoroutine
*/
@PublishedApi
-internal class TraceData(private val strictMode: Boolean) {
+internal class TraceData(internal val currentId: Int, private val strictMode: Boolean) {
internal var slices: ArrayDeque<TraceSection>? = null