From 5cb1bda6aa7a5946c0a4f0d22a7708b5f80c4c87 Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Sun, 16 Nov 2025 16:55:52 +0700 Subject: [PATCH 01/19] feat: msdllib Android 16 QPR1 --- .../android/msdl/logging/MSDLHistoryLogger.kt | 2 +- .../msdl/logging/MSDLHistoryLoggerImpl.kt | 24 +++++++++++-------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/msdllib/src/com/google/android/msdl/logging/MSDLHistoryLogger.kt b/msdllib/src/com/google/android/msdl/logging/MSDLHistoryLogger.kt index 6e255af..7ce884c 100644 --- a/msdllib/src/com/google/android/msdl/logging/MSDLHistoryLogger.kt +++ b/msdllib/src/com/google/android/msdl/logging/MSDLHistoryLogger.kt @@ -34,7 +34,7 @@ interface MSDLHistoryLogger { companion object { - const val HISTORY_SIZE = 20 + const val HISTORY_SIZE = 50 val DATE_FORMAT = SimpleDateFormat("MM-dd HH:mm:ss.SSS", Locale.US) } } diff --git a/msdllib/src/com/google/android/msdl/logging/MSDLHistoryLoggerImpl.kt b/msdllib/src/com/google/android/msdl/logging/MSDLHistoryLoggerImpl.kt index bc8a810..30e5fb0 100644 --- a/msdllib/src/com/google/android/msdl/logging/MSDLHistoryLoggerImpl.kt +++ b/msdllib/src/com/google/android/msdl/logging/MSDLHistoryLoggerImpl.kt @@ -18,22 +18,26 @@ package com.google.android.msdl.logging import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting.Companion.PACKAGE_PRIVATE -import java.util.ArrayDeque -import java.util.Deque @VisibleForTesting(otherwise = PACKAGE_PRIVATE) class MSDLHistoryLoggerImpl(private val maxHistorySize: Int) : MSDLHistoryLogger { - // Use an [ArrayDequeue] with a fixed size as the history structure - private val history: Deque = ArrayDeque(maxHistorySize) + // Use an Array with a fixed size as the history structure. This will work as a ring buffer + private val history: Array = arrayOfNulls(size = maxHistorySize) + // The head will point to the next available position in the structure to add a new event + private var head = 0 override fun addEvent(event: MSDLEvent) { - // Keep the history as a FIFO structure - if (history.size == maxHistorySize) { - history.removeFirst() - } - history.addLast(event) + history[head] = event + // Move the head pointer, wrapping if necessary + head = (head + 1) % maxHistorySize } - override fun getHistory(): List = history.toList() + override fun getHistory(): List { + val result = mutableListOf() + repeat(times = maxHistorySize) { i -> + history[(i + head) % maxHistorySize]?.let { result.add(it) } + } + return result + } } From 6234d2447438becd3c6067af6723dd7a3c9361cd Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Sun, 16 Nov 2025 16:58:32 +0700 Subject: [PATCH 02/19] feat: animationlib Android 16 QPR1 --- animationlib/build.gradle | 6 +- .../com/android/app/animation/Animations.kt | 1 - .../android/app/animation/Interpolators.java | 46 +++++------ .../app/animation/InterpolatorsAndroidX.java | 46 +++++------ .../robolectric/config/robolectric.properties | 1 - .../android/app/animation/AnimationsTest.kt | 77 +++++++++++++++++++ .../animation/InterpolatorResourcesTest.kt | 6 +- .../animation/InterpolatorsAndroidXTest.kt | 4 +- 8 files changed, 133 insertions(+), 54 deletions(-) create mode 100644 animationlib/tests/src/com/android/app/animation/AnimationsTest.kt diff --git a/animationlib/build.gradle b/animationlib/build.gradle index f40669b..02036e3 100644 --- a/animationlib/build.gradle +++ b/animationlib/build.gradle @@ -15,10 +15,11 @@ android { manifest.srcFile 'AndroidManifest.xml' } androidTest { - java.srcDirs = ["tests/src"] + java.srcDirs = ["tests/src", "tests/robolectric/src"] manifest.srcFile 'tests/AndroidManifest.xml' } } + lintOptions { abortOnError false } @@ -34,4 +35,7 @@ dependencies { implementation libs.kotlin.stdlib.jdk7 implementation libs.androidx.core.animation implementation libs.androidx.core.ktx +// androidTestImplementation libs.robolectric +// androidTestImplementation "androidx.test.ext:junit:1.1.3" +// androidTestImplementation "androidx.test:rules:1.4.0" } diff --git a/animationlib/src/com/android/app/animation/Animations.kt b/animationlib/src/com/android/app/animation/Animations.kt index 140cc20..2b787fe 100644 --- a/animationlib/src/com/android/app/animation/Animations.kt +++ b/animationlib/src/com/android/app/animation/Animations.kt @@ -18,7 +18,6 @@ package com.android.app.animation import android.animation.Animator import android.view.View -import com.android.app.animation.Animations.Companion.setOngoingAnimation /** A static class for general animation-related utilities. */ class Animations { diff --git a/animationlib/src/com/android/app/animation/Interpolators.java b/animationlib/src/com/android/app/animation/Interpolators.java index 871241c..c723a12 100644 --- a/animationlib/src/com/android/app/animation/Interpolators.java +++ b/animationlib/src/com/android/app/animation/Interpolators.java @@ -58,14 +58,14 @@ public class Interpolators { * is disappearing e.g. when moving off screen. */ public static final Interpolator EMPHASIZED_ACCELERATE = new PathInterpolator( - 0.3f, 0f, 0.8f, 0.15f); + 0.3f, 0f, 0.8f, 0.15f); /** * The decelerating emphasized interpolator. Used for hero / emphasized movement of content that * is appearing e.g. when coming from off screen */ public static final Interpolator EMPHASIZED_DECELERATE = new PathInterpolator( - 0.05f, 0.7f, 0.1f, 1f); + 0.05f, 0.7f, 0.1f, 1f); public static final Interpolator EXAGGERATED_EASE; @@ -98,21 +98,21 @@ public class Interpolators { * The standard interpolator that should be used on every normal animation */ public static final Interpolator STANDARD = new PathInterpolator( - 0.2f, 0f, 0f, 1f); + 0.2f, 0f, 0f, 1f); /** * The standard accelerating interpolator that should be used on every regular movement of * content that is disappearing e.g. when moving off screen. */ public static final Interpolator STANDARD_ACCELERATE = new PathInterpolator( - 0.3f, 0f, 1f, 1f); + 0.3f, 0f, 1f, 1f); /** * The standard decelerating interpolator that should be used on every regular movement of * content that is appearing e.g. when coming from off screen. */ public static final Interpolator STANDARD_DECELERATE = new PathInterpolator( - 0f, 0f, 0f, 1f); + 0f, 0f, 0f, 1f); /* * ============================================================================================ @@ -158,7 +158,7 @@ public class Interpolators { * goes from 1 to 0 instead of 0 to 1). */ public static final Interpolator FAST_OUT_SLOW_IN_REVERSE = - new PathInterpolator(0.8f, 0f, 0.6f, 1f); + new PathInterpolator(0.8f, 0f, 0.6f, 1f); public static final Interpolator SLOW_OUT_LINEAR_IN = new PathInterpolator(0.8f, 0f, 1f, 1f); public static final Interpolator AGGRESSIVE_EASE = new PathInterpolator(0.2f, 0f, 0f, 1f); public static final Interpolator AGGRESSIVE_EASE_IN_OUT = new PathInterpolator(0.6f,0, 0.4f, 1); @@ -182,31 +182,31 @@ public class Interpolators { public static final Interpolator CUSTOM_40_40 = new PathInterpolator(0.4f, 0f, 0.6f, 1f); public static final Interpolator ICON_OVERSHOT = new PathInterpolator(0.4f, 0f, 0.2f, 1.4f); public static final Interpolator ICON_OVERSHOT_LESS = new PathInterpolator(0.4f, 0f, 0.2f, - 1.1f); + 1.1f); public static final Interpolator PANEL_CLOSE_ACCELERATED = new PathInterpolator(0.3f, 0, 0.5f, - 1); + 1); public static final Interpolator BOUNCE = new BounceInterpolator(); /** * For state transitions on the control panel that lives in GlobalActions. */ public static final Interpolator CONTROL_STATE = new PathInterpolator(0.4f, 0f, 0.2f, - 1.0f); + 1.0f); /** * Interpolator to be used when animating a move based on a click. Pair with enough duration. */ public static final Interpolator TOUCH_RESPONSE = - new PathInterpolator(0.3f, 0f, 0.1f, 1f); + new PathInterpolator(0.3f, 0f, 0.1f, 1f); /** * Like {@link #TOUCH_RESPONSE}, but used in case the animation is played in reverse (i.e. t * goes from 1 to 0 instead of 0 to 1). */ public static final Interpolator TOUCH_RESPONSE_REVERSE = - new PathInterpolator(0.9f, 0f, 0.7f, 1f); + new PathInterpolator(0.9f, 0f, 0.7f, 1f); public static final Interpolator TOUCH_RESPONSE_ACCEL_DEACCEL = - v -> ACCELERATE_DECELERATE.getInterpolation(TOUCH_RESPONSE.getInterpolation(v)); + v -> ACCELERATE_DECELERATE.getInterpolation(TOUCH_RESPONSE.getInterpolation(v)); /** @@ -236,7 +236,7 @@ public float getInterpolation(float v) { */ private float zInterpolate(float input) { return (1.0f - FOCAL_LENGTH / (FOCAL_LENGTH + input)) / - (1.0f - FOCAL_LENGTH / (FOCAL_LENGTH + 1.0f)); + (1.0f - FOCAL_LENGTH / (FOCAL_LENGTH + 1.0f)); } }; @@ -297,13 +297,13 @@ public static Interpolator overshootInterpolatorForVelocity(float velocity) { * @return the interpolated overshoot */ public static float getOvershootInterpolation(float progress, float overshootAmount, - float overshootStart) { + float overshootStart) { if (overshootAmount == 0.0f || overshootStart == 0.0f) { throw new IllegalArgumentException("Invalid values for overshoot"); } float b = MathUtils.log((overshootAmount + 1) / (overshootAmount)) / overshootStart; return MathUtils.max(0.0f, - (float) (1.0f - Math.exp(-b * progress)) * (overshootAmount + 1.0f)); + (float) (1.0f - Math.exp(-b * progress)) * (overshootAmount + 1.0f)); } /** @@ -344,11 +344,11 @@ private static PathInterpolator createEmphasizedComplement() { * 1 by upperBound. */ public static Interpolator clampToProgress(Interpolator interpolator, float lowerBound, - float upperBound) { + float upperBound) { if (upperBound < lowerBound) { throw new IllegalArgumentException( - String.format("upperBound (%f) must be greater than lowerBound (%f)", - upperBound, lowerBound)); + String.format("upperBound (%f) must be greater than lowerBound (%f)", + upperBound, lowerBound)); } return t -> clampToProgress(interpolator, t, lowerBound, upperBound); } @@ -361,11 +361,11 @@ public static Interpolator clampToProgress(Interpolator interpolator, float lowe * interpolator. */ public static float clampToProgress( - Interpolator interpolator, float progress, float lowerBound, float upperBound) { + Interpolator interpolator, float progress, float lowerBound, float upperBound) { if (upperBound < lowerBound) { throw new IllegalArgumentException( - String.format("upperBound (%f) must be greater than lowerBound (%f)", - upperBound, lowerBound)); + String.format("upperBound (%f) must be greater than lowerBound (%f)", + upperBound, lowerBound)); } if (progress == lowerBound && progress == upperBound) { @@ -398,7 +398,7 @@ private static float mapRange(float value, float min, float max) { * such as to take over a user-controlled animation when they let go. */ public static Interpolator mapToProgress(Interpolator interpolator, float lowerBound, - float upperBound) { + float upperBound) { return t -> mapRange(interpolator.getInterpolation(t), lowerBound, upperBound); } @@ -411,4 +411,4 @@ public static Interpolator mapToProgress(Interpolator interpolator, float lowerB public static Interpolator reverse(Interpolator interpolator) { return t -> 1 - interpolator.getInterpolation(1 - t); } -} +} \ No newline at end of file diff --git a/animationlib/src/com/android/app/animation/InterpolatorsAndroidX.java b/animationlib/src/com/android/app/animation/InterpolatorsAndroidX.java index 73c95f8..47f4d74 100644 --- a/animationlib/src/com/android/app/animation/InterpolatorsAndroidX.java +++ b/animationlib/src/com/android/app/animation/InterpolatorsAndroidX.java @@ -65,14 +65,14 @@ public class InterpolatorsAndroidX { * is disappearing e.g. when moving off screen. */ public static final Interpolator EMPHASIZED_ACCELERATE = new PathInterpolator( - 0.3f, 0f, 0.8f, 0.15f); + 0.3f, 0f, 0.8f, 0.15f); /** * The decelerating emphasized interpolator. Used for hero / emphasized movement of content that * is appearing e.g. when coming from off screen */ public static final Interpolator EMPHASIZED_DECELERATE = new PathInterpolator( - 0.05f, 0.7f, 0.1f, 1f); + 0.05f, 0.7f, 0.1f, 1f); public static final Interpolator EXAGGERATED_EASE; static { @@ -104,21 +104,21 @@ public class InterpolatorsAndroidX { * The standard interpolator that should be used on every normal animation */ public static final Interpolator STANDARD = new PathInterpolator( - 0.2f, 0f, 0f, 1f); + 0.2f, 0f, 0f, 1f); /** * The standard accelerating interpolator that should be used on every regular movement of * content that is disappearing e.g. when moving off screen. */ public static final Interpolator STANDARD_ACCELERATE = new PathInterpolator( - 0.3f, 0f, 1f, 1f); + 0.3f, 0f, 1f, 1f); /** * The standard decelerating interpolator that should be used on every regular movement of * content that is appearing e.g. when coming from off screen. */ public static final Interpolator STANDARD_DECELERATE = new PathInterpolator( - 0f, 0f, 0f, 1f); + 0f, 0f, 0f, 1f); /* * ============================================================================================ @@ -164,7 +164,7 @@ public class InterpolatorsAndroidX { * goes from 1 to 0 instead of 0 to 1). */ public static final Interpolator FAST_OUT_SLOW_IN_REVERSE = - new PathInterpolator(0.8f, 0f, 0.6f, 1f); + new PathInterpolator(0.8f, 0f, 0.6f, 1f); public static final Interpolator SLOW_OUT_LINEAR_IN = new PathInterpolator(0.8f, 0f, 1f, 1f); public static final Interpolator AGGRESSIVE_EASE = new PathInterpolator(0.2f, 0f, 0f, 1f); public static final Interpolator AGGRESSIVE_EASE_IN_OUT = new PathInterpolator(0.6f,0, 0.4f, 1); @@ -188,31 +188,31 @@ public class InterpolatorsAndroidX { public static final Interpolator CUSTOM_40_40 = new PathInterpolator(0.4f, 0f, 0.6f, 1f); public static final Interpolator ICON_OVERSHOT = new PathInterpolator(0.4f, 0f, 0.2f, 1.4f); public static final Interpolator ICON_OVERSHOT_LESS = new PathInterpolator(0.4f, 0f, 0.2f, - 1.1f); + 1.1f); public static final Interpolator PANEL_CLOSE_ACCELERATED = new PathInterpolator(0.3f, 0, 0.5f, - 1); + 1); public static final Interpolator BOUNCE = new BounceInterpolator(); /** * For state transitions on the control panel that lives in GlobalActions. */ public static final Interpolator CONTROL_STATE = new PathInterpolator(0.4f, 0f, 0.2f, - 1.0f); + 1.0f); /** * Interpolator to be used when animating a move based on a click. Pair with enough duration. */ public static final Interpolator TOUCH_RESPONSE = - new PathInterpolator(0.3f, 0f, 0.1f, 1f); + new PathInterpolator(0.3f, 0f, 0.1f, 1f); /** * Like {@link #TOUCH_RESPONSE}, but used in case the animation is played in reverse (i.e. t * goes from 1 to 0 instead of 0 to 1). */ public static final Interpolator TOUCH_RESPONSE_REVERSE = - new PathInterpolator(0.9f, 0f, 0.7f, 1f); + new PathInterpolator(0.9f, 0f, 0.7f, 1f); public static final Interpolator TOUCH_RESPONSE_ACCEL_DEACCEL = - v -> ACCELERATE_DECELERATE.getInterpolation(TOUCH_RESPONSE.getInterpolation(v)); + v -> ACCELERATE_DECELERATE.getInterpolation(TOUCH_RESPONSE.getInterpolation(v)); /** @@ -242,7 +242,7 @@ public float getInterpolation(float v) { */ private float zInterpolate(float input) { return (1.0f - FOCAL_LENGTH / (FOCAL_LENGTH + input)) / - (1.0f - FOCAL_LENGTH / (FOCAL_LENGTH + 1.0f)); + (1.0f - FOCAL_LENGTH / (FOCAL_LENGTH + 1.0f)); } }; @@ -303,13 +303,13 @@ public static Interpolator overshootInterpolatorForVelocity(float velocity) { * @return the interpolated overshoot */ public static float getOvershootInterpolation(float progress, float overshootAmount, - float overshootStart) { + float overshootStart) { if (overshootAmount == 0.0f || overshootStart == 0.0f) { throw new IllegalArgumentException("Invalid values for overshoot"); } float b = MathUtils.log((overshootAmount + 1) / (overshootAmount)) / overshootStart; return MathUtils.max(0.0f, - (float) (1.0f - Math.exp(-b * progress)) * (overshootAmount + 1.0f)); + (float) (1.0f - Math.exp(-b * progress)) * (overshootAmount + 1.0f)); } /** @@ -350,11 +350,11 @@ private static PathInterpolator createEmphasizedComplement() { * 1 by upperBound. */ public static Interpolator clampToProgress(Interpolator interpolator, float lowerBound, - float upperBound) { + float upperBound) { if (upperBound < lowerBound) { throw new IllegalArgumentException( - String.format("upperBound (%f) must be greater than lowerBound (%f)", - upperBound, lowerBound)); + String.format("upperBound (%f) must be greater than lowerBound (%f)", + upperBound, lowerBound)); } return t -> clampToProgress(interpolator, t, lowerBound, upperBound); } @@ -367,11 +367,11 @@ public static Interpolator clampToProgress(Interpolator interpolator, float lowe * interpolator. */ public static float clampToProgress( - Interpolator interpolator, float progress, float lowerBound, float upperBound) { + Interpolator interpolator, float progress, float lowerBound, float upperBound) { if (upperBound < lowerBound) { throw new IllegalArgumentException( - String.format("upperBound (%f) must be greater than lowerBound (%f)", - upperBound, lowerBound)); + String.format("upperBound (%f) must be greater than lowerBound (%f)", + upperBound, lowerBound)); } if (progress == lowerBound && progress == upperBound) { @@ -404,7 +404,7 @@ private static float mapRange(float value, float min, float max) { * such as to take over a user-controlled animation when they let go. */ public static Interpolator mapToProgress(Interpolator interpolator, float lowerBound, - float upperBound) { + float upperBound) { return t -> mapRange(interpolator.getInterpolation(t), lowerBound, upperBound); } @@ -417,4 +417,4 @@ public static Interpolator mapToProgress(Interpolator interpolator, float lowerB public static Interpolator reverse(Interpolator interpolator) { return t -> 1 - interpolator.getInterpolation(1 - t); } -} +} \ No newline at end of file diff --git a/animationlib/tests/robolectric/config/robolectric.properties b/animationlib/tests/robolectric/config/robolectric.properties index 527eab6..fab7251 100644 --- a/animationlib/tests/robolectric/config/robolectric.properties +++ b/animationlib/tests/robolectric/config/robolectric.properties @@ -1,2 +1 @@ sdk=NEWEST_SDK -shadows=com.android.app.animation.robolectric.ShadowAnimationUtils2 diff --git a/animationlib/tests/src/com/android/app/animation/AnimationsTest.kt b/animationlib/tests/src/com/android/app/animation/AnimationsTest.kt new file mode 100644 index 0000000..decc503 --- /dev/null +++ b/animationlib/tests/src/com/android/app/animation/AnimationsTest.kt @@ -0,0 +1,77 @@ +/* + * 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.app.animation + +import android.animation.ValueAnimator +import android.content.Context +import android.view.View +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidJUnit4::class) +class AnimationsTest { + companion object { + const val TEST_DURATION = 1000L + } + + private val context: Context = InstrumentationRegistry.getInstrumentation().context + + @Test + fun ongoingAnimationsAreStoredAndCancelledCorrectly() { + val view = View(context) + + val oldAnimation = FakeAnimator() + Animations.setOngoingAnimation(view, oldAnimation) + oldAnimation.start() + assertEquals(oldAnimation, view.getTag(R.id.ongoing_animation)) + assertTrue(oldAnimation.started) + + val newAnimation = FakeAnimator() + Animations.setOngoingAnimation(view, newAnimation) + newAnimation.start() + assertEquals(newAnimation, view.getTag(R.id.ongoing_animation)) + assertTrue(oldAnimation.cancelled) + assertTrue(newAnimation.started) + + Animations.cancelOngoingAnimation(view) + assertNull(view.getTag(R.id.ongoing_animation)) + assertTrue(newAnimation.cancelled) + } +} + +/** Test animator for tracking start and cancel signals. */ +private class FakeAnimator : ValueAnimator() { + var started = false + var cancelled = false + + override fun start() { + started = true + cancelled = false + } + + override fun cancel() { + started = false + cancelled = true + } +} diff --git a/animationlib/tests/src/com/android/app/animation/InterpolatorResourcesTest.kt b/animationlib/tests/src/com/android/app/animation/InterpolatorResourcesTest.kt index ed4670e..f54493e 100644 --- a/animationlib/tests/src/com/android/app/animation/InterpolatorResourcesTest.kt +++ b/animationlib/tests/src/com/android/app/animation/InterpolatorResourcesTest.kt @@ -3,23 +3,23 @@ package com.android.app.animation import android.annotation.InterpolatorRes import android.content.Context import android.view.animation.AnimationUtils +import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import androidx.test.platform.app.InstrumentationRegistry import junit.framework.Assert.assertEquals import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.junit.runners.JUnit4 @SmallTest -@RunWith(JUnit4::class) +@RunWith(AndroidJUnit4::class) class InterpolatorResourcesTest { private lateinit var context: Context @Before fun setup() { - context = InstrumentationRegistry.getInstrumentation().context + context = InstrumentationRegistry.getInstrumentation().targetContext } @Test diff --git a/animationlib/tests/src/com/android/app/animation/InterpolatorsAndroidXTest.kt b/animationlib/tests/src/com/android/app/animation/InterpolatorsAndroidXTest.kt index ffa706e..bed06cd 100644 --- a/animationlib/tests/src/com/android/app/animation/InterpolatorsAndroidXTest.kt +++ b/animationlib/tests/src/com/android/app/animation/InterpolatorsAndroidXTest.kt @@ -16,18 +16,18 @@ package com.android.app.animation +import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import java.lang.reflect.Modifier import junit.framework.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith -import org.junit.runners.JUnit4 private const val ANDROIDX_ANIM_PACKAGE_NAME = "androidx.core.animation." private const val ANDROID_ANIM_PACKAGE_NAME = "android.view.animation." @SmallTest -@RunWith(JUnit4::class) +@RunWith(AndroidJUnit4::class) class InterpolatorsAndroidXTest { @Test From bc4dbec3595a486fdfa72043345ed2b98a357368 Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Sun, 16 Nov 2025 16:59:10 +0700 Subject: [PATCH 03/19] feat: contextualeducation Android 16 QPR1 --- contextualeducationlib/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contextualeducationlib/build.gradle b/contextualeducationlib/build.gradle index 558b313..909304d 100644 --- a/contextualeducationlib/build.gradle +++ b/contextualeducationlib/build.gradle @@ -27,4 +27,4 @@ android { manifest.srcFile 'AndroidManifest.xml' } } -} +} \ No newline at end of file From cb3cfd418c8ec277d802b27fcffb1e78d8ea72d9 Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Sun, 16 Nov 2025 17:15:36 +0700 Subject: [PATCH 04/19] feat: iconloaderlib Android 16 QPR1 --- iconloaderlib/Android.bp | 15 +- iconloaderlib/res/values/config.xml | 3 - .../launcher3/icons/BaseIconFactory.java | 125 +++--- .../com/android/launcher3/icons/BitmapInfo.kt | 272 +++++++++++++ .../launcher3/icons/ClockDrawableWrapper.java | 34 +- .../launcher3/icons/FastBitmapDrawable.kt | 369 ++++++++++++++++++ .../android/launcher3/icons/IconProvider.java | 49 ++- .../icons/MonochromeIconFactory.java | 4 +- .../icons/PlaceHolderIconDrawable.java | 6 +- .../android/launcher3/icons/ThemedBitmap.kt | 20 +- .../launcher3/icons/cache/BaseIconCache.kt | 85 ++-- .../launcher3/icons/cache/CacheLookupFlag.kt | 24 +- .../cache/LauncherActivityCachingLogic.kt | 3 +- .../icons/mono/MonoIconThemeController.kt | 26 +- .../icons/mono/ThemedIconDrawable.kt | 15 +- 15 files changed, 900 insertions(+), 150 deletions(-) create mode 100644 iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.kt create mode 100644 iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.kt diff --git a/iconloaderlib/Android.bp b/iconloaderlib/Android.bp index 6867e6b..e991888 100644 --- a/iconloaderlib/Android.bp +++ b/iconloaderlib/Android.bp @@ -19,30 +19,41 @@ package { android_library { name: "iconloader_base", sdk_version: "current", - min_sdk_version: "26", + min_sdk_version: "31", static_libs: [ "androidx.core_core", + "com_android_launcher3_flags_lib", + "com_android_systemui_shared_flags_lib", ], resource_dirs: [ "res", ], srcs: [ "src/**/*.java", + "src/**/*.kt", ], } android_library { name: "iconloader", sdk_version: "system_current", - min_sdk_version: "26", + min_sdk_version: "31", static_libs: [ "androidx.core_core", + "com_android_launcher3_flags_lib", + "com_android_systemui_shared_flags_lib", ], resource_dirs: [ "res", ], srcs: [ "src/**/*.java", + "src/**/*.kt", "src_full_lib/**/*.java", + "src_full_lib/**/*.kt", + ], + apex_available: [ + "//apex_available:platform", + "com.android.permission", ], } diff --git a/iconloaderlib/res/values/config.xml b/iconloaderlib/res/values/config.xml index 71a38f2..893f955 100644 --- a/iconloaderlib/res/values/config.xml +++ b/iconloaderlib/res/values/config.xml @@ -27,7 +27,4 @@ - - false - \ No newline at end of file diff --git a/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java b/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java index f5a16dc..d48ff18 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java +++ b/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java @@ -7,6 +7,7 @@ import static android.graphics.drawable.AdaptiveIconDrawable.getExtraInsetFraction; import static com.android.launcher3.icons.BitmapInfo.FLAG_INSTANT; +import static com.android.launcher3.icons.IconNormalizer.ICON_VISIBLE_AREA_FACTOR; import static com.android.launcher3.icons.ShadowGenerator.BLUR_FACTOR; import static com.android.launcher3.icons.ShadowGenerator.ICON_SCALE_FOR_SHADOWS; @@ -19,21 +20,21 @@ import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.Bitmap.Config; +import android.graphics.BitmapShader; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.PaintFlagsDrawFilter; import android.graphics.Path; import android.graphics.Rect; +import android.graphics.Shader.TileMode; import android.graphics.drawable.AdaptiveIconDrawable; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; -import android.graphics.drawable.DrawableWrapper; import android.graphics.drawable.InsetDrawable; import android.os.Build; import android.os.UserHandle; -import android.util.Log; import android.util.SparseArray; import androidx.annotation.ColorInt; @@ -43,7 +44,6 @@ import com.android.launcher3.Flags; import com.android.launcher3.icons.BitmapInfo.Extender; -import com.android.launcher3.icons.mono.ThemedIconDrawable; import com.android.launcher3.util.FlagOp; import com.android.launcher3.util.UserIconInfo; @@ -60,8 +60,8 @@ */ public class BaseIconFactory implements AutoCloseable { - public static final int DEFAULT_WRAPPER_BACKGROUND = Color.WHITE; - public static final float LEGACY_ICON_SCALE = .7f * (1f / (1 + 2 * getExtraInsetFraction())); + private static final int DEFAULT_WRAPPER_BACKGROUND = Color.WHITE; + private static final float LEGACY_ICON_SCALE = .7f * (1f / (1 + 2 * getExtraInsetFraction())); public static final int MODE_DEFAULT = 0; public static final int MODE_ALPHA = 1; @@ -99,15 +99,15 @@ public class BaseIconFactory implements AutoCloseable { @Nullable private ShadowGenerator mShadowGenerator; - // Shadow bitmap used as background for theme icons + /** Shadow bitmap used as background for theme icons */ private Bitmap mWhiteShadowLayer; + /** Bitmap used for {@link BitmapShader} to mask Adaptive Icons when drawing */ + private Bitmap mShaderBitmap; private int mWrapperBackgroundColor = DEFAULT_WRAPPER_BACKGROUND; private static int PLACEHOLDER_BACKGROUND_COLOR = Color.rgb(245, 245, 245); - private final boolean mShouldForceThemeIcon; - protected BaseIconFactory(Context context, int fullResIconDpi, int iconBitmapSize, boolean unused) { this(context, fullResIconDpi, iconBitmapSize); @@ -123,9 +123,6 @@ public BaseIconFactory(Context context, int fullResIconDpi, int iconBitmapSize) mCanvas = new Canvas(); mCanvas.setDrawFilter(new PaintFlagsDrawFilter(DITHER_FLAG, FILTER_BITMAP_FLAG)); clear(); - - mShouldForceThemeIcon = mContext.getResources().getBoolean( - R.bool.enable_forced_themed_icon); } protected void clear() { @@ -178,7 +175,7 @@ public BitmapInfo createIconBitmap(String placeholder, int color) { AdaptiveIconDrawable drawable = new AdaptiveIconDrawable( new ColorDrawable(PLACEHOLDER_BACKGROUND_COLOR), new CenterTextDrawable(placeholder, color)); - Bitmap icon = createIconBitmap(drawable, IconNormalizer.ICON_VISIBLE_AREA_FACTOR); + Bitmap icon = createIconBitmap(drawable, ICON_VISIBLE_AREA_FACTOR); return BitmapInfo.of(icon, color); } @@ -198,8 +195,9 @@ public AdaptiveIconDrawable createShapedAdaptiveIcon(Bitmap iconBitmap) { Drawable drawable = new FixedSizeBitmapDrawable(iconBitmap); float inset = getExtraInsetFraction(); inset = inset / (1 + 2 * inset); - return new AdaptiveIconDrawable(new ColorDrawable(Color.BLACK), - new InsetDrawable(drawable, inset, inset, inset, inset)); + return new AdaptiveIconDrawable(new ColorDrawable(BLACK), + new InsetDrawable(drawable, inset, inset, inset, inset) + ); } @NonNull @@ -231,7 +229,6 @@ public BitmapInfo createBadgedIconBitmap(@NonNull Drawable icon, AdaptiveIconDrawable adaptiveIcon = normalizeAndWrapToAdaptiveIcon(tempIcon, scale); Bitmap bitmap = createIconBitmap(adaptiveIcon, scale[0], options == null ? MODE_WITH_SHADOW : options.mGenerationMode); - int color = (options != null && options.mExtractedColor != null) ? options.mExtractedColor : ColorExtractor.findDominantColorByHue(bitmap); BitmapInfo info = BitmapInfo.of(bitmap, color); @@ -248,7 +245,11 @@ public BitmapInfo createBadgedIconBitmap(@NonNull Drawable icon, ) ); } - info = info.withFlags(getBitmapFlagOp(options)); + FlagOp flagOp = getBitmapFlagOp(options); + if (adaptiveIcon instanceof WrappedAdaptiveIcon) { + flagOp = flagOp.addFlag(BitmapInfo.FLAG_WRAPPED_NON_ADAPTIVE); + } + info = info.withFlags(flagOp); return info; } @@ -271,13 +272,6 @@ public FlagOp getBitmapFlagOp(@Nullable IconOptions options) { return op; } - /** - * @return True if forced theme icon is enabled - */ - public boolean shouldForceThemeIcon() { - return mShouldForceThemeIcon; - } - @NonNull protected UserIconInfo getUserInfo(@NonNull UserHandle user) { int key = user.hashCode(); @@ -301,10 +295,6 @@ public Path getShapePath(AdaptiveIconDrawable drawable, Rect iconBounds) { return drawable.getIconMask(); } - public float getIconScale() { - return 1f; - } - @NonNull public Bitmap getWhiteShadowLayer() { if (mWhiteShadowLayer == null) { @@ -315,6 +305,42 @@ public Bitmap getWhiteShadowLayer() { return mWhiteShadowLayer; } + /** + * Takes an {@link AdaptiveIconDrawable} and uses it to create a new Shader Bitmap. + * {@link mShaderBitmap} will be used to create a {@link BitmapShader} for masking, + * such as for icon shapes. Will reuse underlying Bitmap where possible. + * + * @param adaptiveIcon AdaptiveIconDrawable to draw with shader + */ + @NonNull + private Bitmap getAdaptiveShaderBitmap(AdaptiveIconDrawable adaptiveIcon) { + Rect bounds = adaptiveIcon.getBounds(); + int iconWidth = bounds.width(); + int iconHeight = bounds.width(); + + BitmapRenderer shaderRenderer = new BitmapRenderer() { + @Override + public void draw(Canvas canvas) { + canvas.translate(-bounds.left, -bounds.top); + canvas.drawColor(BLACK); + if (adaptiveIcon.getBackground() != null) { + adaptiveIcon.getBackground().draw(canvas); + } + if (adaptiveIcon.getForeground() != null) { + adaptiveIcon.getForeground().draw(canvas); + } + } + }; + if (mShaderBitmap == null || iconWidth != mShaderBitmap.getWidth() + || iconHeight != mShaderBitmap.getHeight()) { + mShaderBitmap = BitmapRenderer.createSoftwareBitmap(iconWidth, iconHeight, + shaderRenderer); + } else { + shaderRenderer.draw(new Canvas(mShaderBitmap)); + } + return mShaderBitmap; + } + @NonNull public Bitmap createScaledBitmap(@NonNull Drawable icon, @BitmapGenerationMode int mode) { float[] scale = new float[1]; @@ -395,6 +421,8 @@ public AdaptiveIconDrawable wrapToAdaptiveIcon(@NonNull Drawable icon) { int wrapperBackgroundColor = IconPreferencesKt.getWrapperBackgroundColor(mContext, icon); FixedScaleDrawable foreground = new FixedScaleDrawable(); + // pE-TODO(QPR1): Investigate + // foreground = createScaledDrawable(icon, scale * LEGACY_ICON_SCALE) CustomAdaptiveIconDrawable dr = new CustomAdaptiveIconDrawable( new ColorDrawable(wrapperBackgroundColor), foreground); dr.setBounds(0, 0, 1, 1); @@ -423,7 +451,7 @@ public Bitmap createIconBitmap(@Nullable final Drawable icon, final float scale, case MODE_HARDWARE: case MODE_HARDWARE_WITH_SHADOW: { return BitmapRenderer.createHardwareBitmap(size, size, canvas -> - drawIconBitmap(canvas, icon, scale, bitmapGenerationMode, null)); + drawIconBitmap(canvas, icon, scale, bitmapGenerationMode, null)); } case MODE_WITH_SHADOW: default: @@ -465,6 +493,7 @@ private void drawIconBitmap(@NonNull Canvas canvas, @Nullable Drawable icon, } else { drawAdaptiveIcon(canvas, aid, shapePath); } + canvas.restoreToCount(count); } else { if (icon instanceof BitmapDrawable) { @@ -512,28 +541,28 @@ private void drawIconBitmap(@NonNull Canvas canvas, @Nullable Drawable icon, } /** - * Draws AdaptiveIconDrawable onto canvas. - * @param canvas canvas to draw on - * @param drawable AdaptiveIconDrawable to draw - * @param overridePath path to clip icon with for shapes + * Draws AdaptiveIconDrawable onto canvas using provided Path + * and {@link mShaderBitmap} as a shader. + * + * @param canvas canvas to draw on + * @param drawable AdaptiveIconDrawable to draw + * @param shapePath path to clip icon with for shapes */ protected void drawAdaptiveIcon( @NonNull Canvas canvas, @NonNull AdaptiveIconDrawable drawable, - @NonNull Path overridePath + @NonNull Path shapePath ) { - if (!Flags.enableLauncherIconShapes()) { + Drawable background = drawable.getBackground(); + Drawable foreground = drawable.getForeground(); + if (!Flags.enableLauncherIconShapes() || (background == null && foreground == null)) { drawable.draw(canvas); return; } - canvas.clipPath(overridePath); - canvas.drawColor(BLACK); - if (drawable.getBackground() != null) { - drawable.getBackground().draw(canvas); - } - if (drawable.getForeground() != null) { - drawable.getForeground().draw(canvas); - } + Bitmap shaderBitmap = getAdaptiveShaderBitmap(drawable); + Paint paint = new Paint(); + paint.setShader(new BitmapShader(shaderBitmap, TileMode.CLAMP, TileMode.CLAMP)); + canvas.drawPath(shapePath, paint); } @Override @@ -700,16 +729,10 @@ public void draw(Canvas canvas) { } } - private static class EmptyWrapper extends DrawableWrapper { + private static class WrappedAdaptiveIcon extends AdaptiveIconDrawable { - EmptyWrapper() { - super(new ColorDrawable()); - } - - @Override - public ConstantState getConstantState() { - Drawable d = getDrawable(); - return d == null ? null : d.getConstantState(); + WrappedAdaptiveIcon(Drawable backgroundDrawable, Drawable foregroundDrawable) { + super(backgroundDrawable, foregroundDrawable); } } } diff --git a/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.kt b/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.kt new file mode 100644 index 0000000..1b3f0fa --- /dev/null +++ b/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.kt @@ -0,0 +1,272 @@ +/* + * Copyright (C) 2017 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 + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Path +import android.graphics.drawable.Drawable +import androidx.annotation.ColorRes +import androidx.annotation.DrawableRes +import androidx.annotation.IntDef +import com.android.launcher3.icons.cache.CacheLookupFlag +import com.android.launcher3.util.FlagOp + +open class BitmapInfo( + @JvmField val icon: Bitmap, + @JvmField val color: Int, + @BitmapInfoFlags @JvmField var flags: Int = 0, + var themedBitmap: ThemedBitmap? = null, +) { + @IntDef( + flag = true, + value = [FLAG_WORK, FLAG_INSTANT, FLAG_CLONE, FLAG_PRIVATE, FLAG_WRAPPED_NON_ADAPTIVE], + ) + internal annotation class BitmapInfoFlags + + @IntDef(flag = true, value = [FLAG_THEMED, FLAG_NO_BADGE, FLAG_SKIP_USER_BADGE]) + annotation class DrawableCreationFlags + + // b/377618519: These are saved to debug why work badges sometimes don't show up on work apps + @DrawableCreationFlags @JvmField var creationFlags: Int = 0 + + private var badgeInfo: BitmapInfo? = null + + fun withBadgeInfo(badgeInfo: BitmapInfo?) = clone().also { it.badgeInfo = badgeInfo } + + /** Returns a bitmapInfo with the flagOP applied */ + fun withFlags(op: FlagOp): BitmapInfo { + if (op === FlagOp.NO_OP) { + return this + } + return clone().also { it.flags = op.apply(it.flags) } + } + + @Override + open fun clone(): BitmapInfo { + return copyInternalsTo(BitmapInfo(icon, color)) + } + + protected fun copyInternalsTo(target: BitmapInfo): BitmapInfo { + target.themedBitmap = themedBitmap + target.flags = flags + target.badgeInfo = badgeInfo + return target + } + + // TODO: rename or remove because icon can no longer be null? + val isNullOrLowRes: Boolean + get() = icon == LOW_RES_ICON + + val isLowRes: Boolean + get() = matchingLookupFlag.useLowRes() + + open val matchingLookupFlag: CacheLookupFlag + /** Returns the lookup flag to match this current state of this info */ + get() = + CacheLookupFlag.DEFAULT_LOOKUP_FLAG.withUseLowRes(LOW_RES_ICON == icon) + .withThemeIcon(themedBitmap != null) + + /** BitmapInfo can be stored on disk or other persistent storage */ + open fun canPersist(): Boolean { + return !isNullOrLowRes + } + + /** Creates a drawable for the provided BitmapInfo */ + @JvmOverloads + fun newIcon( + context: Context, + @DrawableCreationFlags creationFlags: Int = 0, + ): FastBitmapDrawable { + return newIcon(context, creationFlags, null) + } + + /** + * Creates a drawable for the provided BitmapInfo + * + * @param context Context + * @param creationFlags Flags for creating the FastBitmapDrawable + * @param badgeShape Optional Path for masking icon badges to a shape. Should be 100x100. + * @return FastBitmapDrawable + */ + open fun newIcon( + context: Context, + @DrawableCreationFlags creationFlags: Int, + badgeShape: Path?, + ): FastBitmapDrawable { + val drawable: FastBitmapDrawable = + if (isLowRes) { + PlaceHolderIconDrawable(this, context) + } else if ( + (creationFlags and FLAG_THEMED) != 0 && + themedBitmap != null && + themedBitmap !== ThemedBitmap.NOT_SUPPORTED + ) { + themedBitmap!!.newDrawable(this, context) + } else { + FastBitmapDrawable(this) + } + applyFlags(context, drawable, creationFlags, badgeShape) + return drawable + } + + protected fun applyFlags( + context: Context, drawable: FastBitmapDrawable, + @DrawableCreationFlags creationFlags: Int, badgeShape: Path? + ) { + this.creationFlags = creationFlags + drawable.disabledAlpha = GraphicsUtils.getFloat(context, R.attr.disabledIconAlpha, 1f) + drawable.creationFlags = creationFlags + if ((creationFlags and FLAG_NO_BADGE) == 0) { + val badge = getBadgeDrawable( + context, (creationFlags and FLAG_THEMED) != 0, + (creationFlags and FLAG_SKIP_USER_BADGE) != 0, badgeShape + ) + if (badge != null) { + drawable.badge = badge + } + } + } + + /** + * Gets Badge drawable based on current flags + * + * @param context Context + * @param isThemed If Drawable is themed. + * @param badgeShape Optional Path to mask badges to a shape. Should be 100x100. + * @return Drawable for the badge. + */ + fun getBadgeDrawable(context: Context, isThemed: Boolean, badgeShape: Path?): Drawable? { + return getBadgeDrawable(context, isThemed, false, badgeShape) + } + + /** + * Creates a Drawable for an icon badge for this BitmapInfo + * @param context Context + * @param isThemed If the drawable is themed. + * @param skipUserBadge If should skip User Profile badging. + * @param badgeShape Optional Path to mask badge Drawable to a shape. Should be 100x100. + * @return Drawable for an icon Badge. + */ + private fun getBadgeDrawable( + context: Context, isThemed: Boolean, skipUserBadge: Boolean, badgeShape: Path? + ): Drawable? { + if (badgeInfo != null) { + var creationFlag = if (isThemed) FLAG_THEMED else 0 + if (skipUserBadge) { + creationFlag = creationFlag or FLAG_SKIP_USER_BADGE + } + return badgeInfo!!.newIcon(context, creationFlag, badgeShape) + } + if (skipUserBadge) { + return null + } else { + getBadgeDrawableInfo()?.let { + return UserBadgeDrawable( + context, + it.drawableRes, + it.colorRes, + isThemed, + badgeShape + ) + } + } + return null + } + + /** + * Returns information about the badge to apply based on current flags. + */ + fun getBadgeDrawableInfo(): BadgeDrawableInfo? { + return when { + (flags and FLAG_INSTANT) != 0 -> BadgeDrawableInfo( + R.drawable.ic_instant_app_badge, + R.color.badge_tint_instant + ) + (flags and FLAG_WORK) != 0 -> BadgeDrawableInfo( + R.drawable.ic_work_app_badge, + R.color.badge_tint_work + ) + (flags and FLAG_CLONE) != 0 -> BadgeDrawableInfo( + R.drawable.ic_clone_app_badge, + R.color.badge_tint_clone + ) + (flags and FLAG_PRIVATE) != 0 -> BadgeDrawableInfo( + R.drawable.ic_private_profile_app_badge, + R.color.badge_tint_private + ) + else -> null + } + } + + + /** Interface to be implemented by drawables to provide a custom BitmapInfo */ + interface Extender { + /** Called for creating a custom BitmapInfo */ + fun getExtendedInfo( + bitmap: Bitmap?, + color: Int, + iconFactory: BaseIconFactory?, + normalizationScale: Float, + ): BitmapInfo? + + /** Called to draw the UI independent of any runtime configurations like time or theme */ + fun drawForPersistence(canvas: Canvas?) + } + + /** + * Drawables backing a specific badge shown on app icons. + * @param drawableRes Drawable resource for the badge. + * @param colorRes Color resource to tint the badge. + */ + @JvmRecord + data class BadgeDrawableInfo( + @field:DrawableRes @param:DrawableRes val drawableRes: Int, + @field:ColorRes @param:ColorRes val colorRes: Int + ) + + companion object { + const val TAG: String = "BitmapInfo" + + // BitmapInfo flags + const val FLAG_WORK: Int = 1 shl 0 + const val FLAG_INSTANT: Int = 1 shl 1 + const val FLAG_CLONE: Int = 1 shl 2 + const val FLAG_PRIVATE: Int = 1 shl 3 + const val FLAG_WRAPPED_NON_ADAPTIVE: Int = 1 shl 4 + + // Drawable creation flags + const val FLAG_THEMED: Int = 1 shl 0 + const val FLAG_NO_BADGE: Int = 1 shl 1 + const val FLAG_SKIP_USER_BADGE: Int = 1 shl 2 + + @JvmField + val LOW_RES_ICON: Bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ALPHA_8) + @JvmField + val LOW_RES_INFO: BitmapInfo = fromBitmap(LOW_RES_ICON) + + @JvmStatic + fun fromBitmap(bitmap: Bitmap): BitmapInfo { + return of(bitmap, 0) + } + + @JvmStatic + fun of(bitmap: Bitmap, color: Int): BitmapInfo { + return BitmapInfo(bitmap, color) + } + } +} diff --git a/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.java b/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.java index 3e8874a..12a76dd 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.java +++ b/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.java @@ -16,6 +16,7 @@ package com.android.launcher3.icons; import static com.android.launcher3.icons.IconProvider.ATLEAST_T; +import static com.android.launcher3.icons.cache.CacheLookupFlag.DEFAULT_LOOKUP_FLAG; import android.annotation.TargetApi; import android.content.Context; @@ -40,10 +41,7 @@ import android.os.SystemClock; import android.util.Log; -import androidx.annotation.NonNull; -import androidx.core.util.Supplier; -import app.lawnchair.icons.ClockMetadata; -import app.lawnchair.icons.CustomAdaptiveIconDrawable; +import com.android.launcher3.icons.cache.CacheLookupFlag; import com.android.launcher3.icons.mono.ThemedIconDrawable; import java.util.Calendar; @@ -51,6 +49,9 @@ import java.util.concurrent.TimeUnit; import java.util.function.IntFunction; +import app.lawnchair.icons.ClockMetadata; +import app.lawnchair.icons.CustomAdaptiveIconDrawable; + /** * Wrapper over {@link AdaptiveIconDrawable} to intercept icon flattening logic for dynamic * clock icons @@ -302,7 +303,7 @@ static class ClockBitmapInfo extends BitmapInfo { ClockBitmapInfo(Bitmap icon, int color, float scale, AnimationInfo animInfo, Bitmap background, AnimationInfo themeInfo, Bitmap themeBackground) { - super(icon, color); + super(icon, color, /* flags */ 0, /* themedBitmap */ null); this.boundsOffset = Math.max(ShadowGenerator.BLUR_FACTOR, (1 - scale) / 2); this.animInfo = animInfo; this.mFlattenedBackground = background; @@ -313,7 +314,7 @@ static class ClockBitmapInfo extends BitmapInfo { @Override @TargetApi(Build.VERSION_CODES.TIRAMISU) public FastBitmapDrawable newIcon(Context context, - @DrawableCreationFlags int creationFlags, Path badgeShape) { + @DrawableCreationFlags int creationFlags, Path badgeShape) { AnimationInfo info; Bitmap bg; int themedFgColor; @@ -349,8 +350,14 @@ public boolean canPersist() { @Override public BitmapInfo clone() { - return copyInternalsTo(new ClockBitmapInfo(icon, color, 1 - 2 * boundsOffset, animInfo, - mFlattenedBackground, themeData, themeBackground)); + return copyInternalsTo(new ClockBitmapInfo(icon, color, + 1 - 2 * boundsOffset, animInfo, mFlattenedBackground, + themeData, themeBackground)); + } + + @Override + public CacheLookupFlag getMatchingLookupFlag() { + return DEFAULT_LOOKUP_FLAG.withThemeIcon(themeData != null); } } @@ -371,7 +378,7 @@ private static class ClockIconDrawable extends FastBitmapDrawable implements Run private final float mCanvasScale; ClockIconDrawable(ClockConstantState cs) { - super(cs.mBitmapInfo); + super(cs.getBitmapInfo()); mBoundsOffset = cs.mBoundsOffset; mAnimInfo = cs.mAnimInfo; @@ -434,10 +441,11 @@ public boolean isThemed() { @Override protected void updateFilter() { super.updateFilter(); - int alpha = mIsDisabled ? (int) (mDisabledAlpha * FULLY_OPAQUE) : FULLY_OPAQUE; + boolean isDisabled = isDisabled(); + int alpha = isDisabled ? (int) (disabledAlpha * FULLY_OPAQUE) : FULLY_OPAQUE; setAlpha(alpha); - mBgPaint.setColorFilter(mIsDisabled ? getDisabledColorFilter() : mBgFilter); - mFG.setColorFilter(mIsDisabled ? getDisabledColorFilter() : null); + mBgPaint.setColorFilter(isDisabled ? getDisabledColorFilter() : mBgFilter); + mFG.setColorFilter(isDisabled ? getDisabledColorFilter() : null); } @Override @@ -477,7 +485,7 @@ private void reschedule() { @Override public FastBitmapConstantState newConstantState() { - return new ClockConstantState(mBitmapInfo, mThemedFgColor, mBoundsOffset, + return new ClockConstantState(bitmapInfo, mThemedFgColor, mBoundsOffset, mAnimInfo, mBG, mBgPaint.getColorFilter()); } diff --git a/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.kt b/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.kt new file mode 100644 index 0000000..670915a --- /dev/null +++ b/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.kt @@ -0,0 +1,369 @@ +/* + * Copyright (C) 2008 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 + +import android.R +import android.animation.ObjectAnimator +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.ColorFilter +import android.graphics.ColorMatrix +import android.graphics.ColorMatrixColorFilter +import android.graphics.Paint +import android.graphics.Paint.ANTI_ALIAS_FLAG +import android.graphics.Paint.FILTER_BITMAP_FLAG +import android.graphics.PixelFormat +import android.graphics.Rect +import android.graphics.drawable.Drawable +import android.graphics.drawable.Drawable.Callback +import android.util.FloatProperty +import android.view.animation.AccelerateInterpolator +import android.view.animation.DecelerateInterpolator +import android.view.animation.Interpolator +import android.view.animation.PathInterpolator +import androidx.annotation.VisibleForTesting +import androidx.core.graphics.ColorUtils +import com.android.launcher3.icons.BitmapInfo.DrawableCreationFlags +import kotlin.math.min + +open class FastBitmapDrawable(info: BitmapInfo?) : Drawable(), Callback { + + @JvmOverloads constructor(b: Bitmap, iconColor: Int = 0) : this(BitmapInfo.of(b, iconColor)) + + @JvmField val bitmapInfo: BitmapInfo = info ?: BitmapInfo.LOW_RES_INFO + var isAnimationEnabled: Boolean = true + + @JvmField protected val paint: Paint = Paint(FILTER_BITMAP_FLAG or ANTI_ALIAS_FLAG) + + @JvmField @VisibleForTesting var isPressed: Boolean = false + @JvmField @VisibleForTesting var isHovered: Boolean = false + + @JvmField var disabledAlpha: Float = 1f + + var isDisabled: Boolean = false + set(value) { + if (field != value) { + field = value + badge.let { if (it is FastBitmapDrawable) it.isDisabled = value } + updateFilter() + } + } + + @JvmField @DrawableCreationFlags var creationFlags: Int = 0 + @JvmField @VisibleForTesting var scaleAnimation: ObjectAnimator? = null + var hoverScaleEnabledForDisplay = true + + private var scale = 1f + + private var paintAlpha = 255 + private var paintFilter: ColorFilter? = null + + init { + isFilterBitmap = true + } + + var badge: Drawable? = null + set(value) { + field?.callback = null + field = value + field?.let { + it.callback = this + it.setBadgeBounds(bounds) + } + updateFilter() + } + + /** Returns true if the drawable points to the same bitmap icon object */ + fun isSameInfo(info: BitmapInfo): Boolean = bitmapInfo === info + + override fun onBoundsChange(bounds: Rect) { + super.onBoundsChange(bounds) + badge?.setBadgeBounds(bounds) + } + + override fun draw(canvas: Canvas) { + if (scale != 1f) { + val count = canvas.save() + val bounds = bounds + canvas.scale(scale, scale, bounds.exactCenterX(), bounds.exactCenterY()) + drawInternal(canvas, bounds) + badge?.draw(canvas) + canvas.restoreToCount(count) + } else { + drawInternal(canvas, bounds) + badge?.draw(canvas) + } + } + + protected open fun drawInternal(canvas: Canvas, bounds: Rect) { + canvas.drawBitmap(bitmapInfo.icon, null, bounds, paint) + } + + /** Returns the primary icon color, slightly tinted white */ + open fun getIconColor(): Int = + ColorUtils.compositeColors( + GraphicsUtils.setColorAlphaBound(Color.WHITE, WHITE_SCRIM_ALPHA), + bitmapInfo.color, + ) + + /** Returns if this represents a themed icon */ + open fun isThemed(): Boolean = false + + /** + * Returns true if the drawable was created with theme, even if it doesn't support theming + * itself. + */ + fun isCreatedForTheme(): Boolean = isThemed() || (creationFlags and BitmapInfo.FLAG_THEMED) != 0 + + override fun setColorFilter(cf: ColorFilter?) { + paintFilter = cf + updateFilter() + } + + override fun getColorFilter(): ColorFilter? = paint.colorFilter + + @Deprecated("This method is no longer used in graphics optimizations") + override fun getOpacity(): Int = PixelFormat.TRANSLUCENT + + override fun setAlpha(alpha: Int) { + if (paintAlpha != alpha) { + paintAlpha = alpha + paint.alpha = alpha + invalidateSelf() + badge?.alpha = alpha + } + } + + override fun getAlpha(): Int = paintAlpha + + override fun setFilterBitmap(filterBitmap: Boolean) { + paint.isFilterBitmap = filterBitmap + paint.isAntiAlias = filterBitmap + } + + fun resetScale() { + scaleAnimation?.cancel() + scaleAnimation = null + scale = 1f + invalidateSelf() + } + + fun getAnimatedScale(): Float = if (scaleAnimation == null) 1f else scale + + override fun getIntrinsicWidth(): Int = bitmapInfo.icon.width + + override fun getIntrinsicHeight(): Int = bitmapInfo.icon.height + + override fun getMinimumWidth(): Int = bounds.width() + + override fun getMinimumHeight(): Int = bounds.height() + + override fun isStateful(): Boolean = true + + public override fun onStateChange(state: IntArray): Boolean { + if (!isAnimationEnabled) { + return false + } + + var isPressed = false + var isHovered = false + for (s in state) { + if (s == R.attr.state_pressed) { + isPressed = true + break + } else if (s == R.attr.state_hovered && hoverScaleEnabledForDisplay) { + isHovered = true + // Do not break on hovered state, as pressed state should take precedence. + } + } + if (this.isPressed != isPressed || this.isHovered != isHovered) { + scaleAnimation?.cancel() + + val endScale = + when { + isPressed -> PRESSED_SCALE + isHovered -> HOVERED_SCALE + else -> 1f + } + if (scale != endScale) { + if (isVisible) { + scaleAnimation = + ObjectAnimator.ofFloat(this, SCALE, endScale).apply { + duration = + if (isPressed != this@FastBitmapDrawable.isPressed) + CLICK_FEEDBACK_DURATION.toLong() + else HOVER_FEEDBACK_DURATION.toLong() + + interpolator = + if (isPressed != this@FastBitmapDrawable.isPressed) + (if (isPressed) ACCEL else DEACCEL) + else HOVER_EMPHASIZED_DECELERATE_INTERPOLATOR + } + scaleAnimation?.start() + } else { + scale = endScale + invalidateSelf() + } + } + this.isPressed = isPressed + this.isHovered = isHovered + return true + } + return false + } + + /** Updates the paint to reflect the current brightness and saturation. */ + protected open fun updateFilter() { + paint.setColorFilter(if (isDisabled) getDisabledColorFilter(disabledAlpha) else paintFilter) + badge?.colorFilter = colorFilter + invalidateSelf() + } + + protected open fun newConstantState(): FastBitmapConstantState { + return FastBitmapConstantState(bitmapInfo) + } + + override fun getConstantState(): ConstantState { + val cs = newConstantState() + cs.mIsDisabled = isDisabled + cs.mBadgeConstantState = badge?.constantState + cs.mCreationFlags = creationFlags + return cs + } + + // Returns if the FastBitmapDrawable contains a badge. + fun hasBadge(): Boolean = (creationFlags and BitmapInfo.FLAG_NO_BADGE) == 0 + + override fun invalidateDrawable(who: Drawable) { + if (who === badge) { + invalidateSelf() + } + } + + override fun scheduleDrawable(who: Drawable, what: Runnable, time: Long) { + if (who === badge) { + scheduleSelf(what, time) + } + } + + override fun unscheduleDrawable(who: Drawable, what: Runnable) { + unscheduleSelf(what) + } + + open class FastBitmapConstantState(val bitmapInfo: BitmapInfo) : ConstantState() { + // These are initialized later so that subclasses don't need to + // pass everything in constructor + var mIsDisabled: Boolean = false + var mBadgeConstantState: ConstantState? = null + + @DrawableCreationFlags var mCreationFlags: Int = 0 + + constructor(bitmap: Bitmap, color: Int) : this(BitmapInfo.of(bitmap, color)) + + protected open fun createDrawable(): FastBitmapDrawable { + return FastBitmapDrawable(bitmapInfo) + } + + override fun newDrawable(): FastBitmapDrawable { + val drawable = createDrawable() + drawable.isDisabled = mIsDisabled + if (mBadgeConstantState != null) { + drawable.badge = mBadgeConstantState!!.newDrawable() + } + drawable.creationFlags = mCreationFlags + return drawable + } + + override fun getChangingConfigurations(): Int = 0 + } + + companion object { + private val ACCEL: Interpolator = AccelerateInterpolator() + private val DEACCEL: Interpolator = DecelerateInterpolator() + private val HOVER_EMPHASIZED_DECELERATE_INTERPOLATOR: Interpolator = + PathInterpolator(0.05f, 0.7f, 0.1f, 1.0f) + + @VisibleForTesting const val PRESSED_SCALE: Float = 1.1f + + @VisibleForTesting const val HOVERED_SCALE: Float = 1.1f + const val WHITE_SCRIM_ALPHA: Int = 138 + + private const val DISABLED_DESATURATION = 1f + private const val DISABLED_BRIGHTNESS = 0.5f + const val FULLY_OPAQUE: Int = 255 + + const val CLICK_FEEDBACK_DURATION: Int = 200 + const val HOVER_FEEDBACK_DURATION: Int = 300 + + // Animator and properties for the fast bitmap drawable's scale + @VisibleForTesting + @JvmField + val SCALE: FloatProperty = + object : FloatProperty("scale") { + override fun get(fastBitmapDrawable: FastBitmapDrawable): Float { + return fastBitmapDrawable.scale + } + + override fun setValue(fastBitmapDrawable: FastBitmapDrawable, value: Float) { + fastBitmapDrawable.scale = value + fastBitmapDrawable.invalidateSelf() + } + } + + @JvmStatic + @JvmOverloads + fun getDisabledColorFilter(disabledAlpha: Float = 1f): ColorFilter { + val tempBrightnessMatrix = ColorMatrix() + val tempFilterMatrix = ColorMatrix() + + tempFilterMatrix.setSaturation(1f - DISABLED_DESATURATION) + val scale = 1 - DISABLED_BRIGHTNESS + val brightnessI = (255 * DISABLED_BRIGHTNESS).toInt() + val mat = tempBrightnessMatrix.array + mat[0] = scale + mat[6] = scale + mat[12] = scale + mat[4] = brightnessI.toFloat() + mat[9] = brightnessI.toFloat() + mat[14] = brightnessI.toFloat() + mat[18] = disabledAlpha + tempFilterMatrix.preConcat(tempBrightnessMatrix) + return ColorMatrixColorFilter(tempFilterMatrix) + } + + @JvmStatic + fun getDisabledColor(color: Int): Int { + val avgComponent = (Color.red(color) + Color.green(color) + Color.blue(color)) / 3 + val scale = 1 - DISABLED_BRIGHTNESS + val brightnessI = (255 * DISABLED_BRIGHTNESS).toInt() + val component = min(Math.round(scale * avgComponent + brightnessI), FULLY_OPAQUE) + return Color.rgb(component, component, component) + } + + /** Sets the bounds for the badge drawable based on the main icon bounds */ + @JvmStatic + fun Drawable.setBadgeBounds(iconBounds: Rect) { + val size = BaseIconFactory.getBadgeSizeForIconSize(iconBounds.width()) + setBounds( + iconBounds.right - size, + iconBounds.bottom - size, + iconBounds.right, + iconBounds.bottom, + ) + } + } +} diff --git a/iconloaderlib/src/com/android/launcher3/icons/IconProvider.java b/iconloaderlib/src/com/android/launcher3/icons/IconProvider.java index 23eed3b..66057e6 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/IconProvider.java +++ b/iconloaderlib/src/com/android/launcher3/icons/IconProvider.java @@ -53,6 +53,8 @@ import androidx.annotation.Nullable; import androidx.core.os.BuildCompat; +import com.android.launcher3.icons.cache.CachingLogic; +import com.android.launcher3.util.ComponentKey; import com.android.launcher3.util.SafeCloseable; import java.util.Calendar; @@ -155,7 +157,7 @@ public Drawable getIcon(PackageItemInfo info, ApplicationInfo appInfo, int iconD icon = ClockDrawableWrapper.forPackage(mContext, mClock.getPackageName(), iconDpi); } if (icon == null) { - icon = loadPackageIcon(info, appInfo, iconDpi); + icon = loadPackageIconWithFallback(info, appInfo, iconDpi); if (ATLEAST_T && icon instanceof AdaptiveIconDrawable && td != null) { AdaptiveIconDrawable aid = (AdaptiveIconDrawable) icon; if (aid.getMonochrome() == null) { @@ -171,36 +173,39 @@ protected ThemeData getThemeDataForPackage(String packageName) { return null; } - private Drawable loadPackageIcon(PackageItemInfo info, ApplicationInfo appInfo, int density) { + private Drawable loadPackageIconWithFallback( + PackageItemInfo info, ApplicationInfo appInfo, int density) { Drawable icon = null; if (BuildCompat.isAtLeastV() && info.isArchived) { // Icons for archived apps com from system service, let the default impl handle that icon = info.loadIcon(mContext.getPackageManager()); } if (icon == null && density != 0 && (info.icon != 0 || appInfo.icon != 0)) { - try { - final Resources resources = mContext.getPackageManager() - .getResourcesForApplication(appInfo); - // Try to load the package item icon first - if (info != appInfo && info.icon != 0) { - try { - icon = resources.getDrawableForDensity(info.icon, density); - } catch (Resources.NotFoundException exc) { } - } - if (icon == null && appInfo.icon != 0) { - // Load the fallback app icon - icon = loadAppInfoIcon(appInfo, resources, density); - } - } catch (NameNotFoundException | Resources.NotFoundException exc) { } + icon = loadPackageIcon(info, appInfo, density); } return icon != null ? icon : getFullResDefaultActivityIcon(density); } @Nullable - protected Drawable loadAppInfoIcon(ApplicationInfo info, Resources resources, int density) { + protected Drawable loadPackageIcon( + @NonNull PackageItemInfo info, @NonNull ApplicationInfo appInfo, int density) { try { - return resources.getDrawableForDensity(info.icon, density); - } catch (Resources.NotFoundException exc) { } + final Resources resources = mContext.getPackageManager() + .getResourcesForApplication(appInfo); + // Try to load the package item icon first + if (info != appInfo && info.icon != 0) { + try { + Drawable icon = resources.getDrawableForDensity(info.icon, density); + if (icon != null) return icon; + } catch (Resources.NotFoundException exc) { } + } + if (appInfo.icon != 0) { + // Load the fallback app icon + try { + return resources.getDrawableForDensity(appInfo.icon, density); + } catch (Resources.NotFoundException exc) { } + } + } catch (NameNotFoundException | Resources.NotFoundException exc) { } return null; } @@ -306,6 +311,12 @@ public SafeCloseable registerIconChangeListener(IconChangeListener listener, Han return new IconChangeReceiver(listener, handler); } + /** + * Notifies the provider when an icon is loaded from cache + */ + public void notifyIconLoaded( + @NonNull BitmapInfo icon, @NonNull ComponentKey key, @NonNull CachingLogic logic) { } + public static class ThemeData { final Resources mResources; diff --git a/iconloaderlib/src/com/android/launcher3/icons/MonochromeIconFactory.java b/iconloaderlib/src/com/android/launcher3/icons/MonochromeIconFactory.java index ae71236..e6ae124 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/MonochromeIconFactory.java +++ b/iconloaderlib/src/com/android/launcher3/icons/MonochromeIconFactory.java @@ -100,12 +100,12 @@ private void drawDrawable(Drawable drawable) { * Creates a monochrome version of the provided drawable */ @WorkerThread - public Drawable wrap(AdaptiveIconDrawable icon, Path shapePath, Float iconScale) { + public Drawable wrap(AdaptiveIconDrawable icon, Path shapePath) { mFlatCanvas.drawColor(Color.BLACK); drawDrawable(icon.getBackground()); drawDrawable(icon.getForeground()); generateMono(); - return new ClippedMonoDrawable(this, shapePath, iconScale); + return new ClippedMonoDrawable(this, shapePath); } @WorkerThread diff --git a/iconloaderlib/src/com/android/launcher3/icons/PlaceHolderIconDrawable.java b/iconloaderlib/src/com/android/launcher3/icons/PlaceHolderIconDrawable.java index 00f1942..531c35a 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/PlaceHolderIconDrawable.java +++ b/iconloaderlib/src/com/android/launcher3/icons/PlaceHolderIconDrawable.java @@ -42,7 +42,7 @@ public class PlaceHolderIconDrawable extends FastBitmapDrawable { public PlaceHolderIconDrawable(BitmapInfo info, Context context) { super(info); mProgressPath = getDefaultPath(); - mPaint.setColor(ColorUtils.compositeColors( + paint.setColor(ColorUtils.compositeColors( GraphicsUtils.getAttrColor(context, R.attr.loadingIconColor), info.color)); } @@ -62,13 +62,13 @@ protected void drawInternal(Canvas canvas, Rect bounds) { int saveCount = canvas.save(); canvas.translate(bounds.left, bounds.top); canvas.scale(bounds.width() / 100f, bounds.height() / 100f); - canvas.drawPath(mProgressPath, mPaint); + canvas.drawPath(mProgressPath, paint); canvas.restoreToCount(saveCount); } /** Updates this placeholder to {@code newIcon} with animation. */ public void animateIconUpdate(Drawable newIcon) { - int placeholderColor = mPaint.getColor(); + int placeholderColor = paint.getColor(); int originalAlpha = Color.alpha(placeholderColor); ValueAnimator iconUpdateAnimation = ValueAnimator.ofInt(originalAlpha, 0); diff --git a/iconloaderlib/src/com/android/launcher3/icons/ThemedBitmap.kt b/iconloaderlib/src/com/android/launcher3/icons/ThemedBitmap.kt index 6c937db..77b34ac 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/ThemedBitmap.kt +++ b/iconloaderlib/src/com/android/launcher3/icons/ThemedBitmap.kt @@ -28,6 +28,18 @@ interface ThemedBitmap { fun newDrawable(info: BitmapInfo, context: Context): FastBitmapDrawable fun serialize(): ByteArray + + companion object { + + @JvmField + /** ThemedBitmap to be used when theming is not supported for a particular bitmap */ + val NOT_SUPPORTED = + object : ThemedBitmap { + override fun newDrawable(info: BitmapInfo, context: Context) = info.newIcon(context) + + override fun serialize() = ByteArray(0) + } + } } interface IconThemeController { @@ -46,8 +58,14 @@ interface IconThemeController { info: BitmapInfo, factory: BaseIconFactory, sourceHint: SourceHint, - ): ThemedBitmap? + ): ThemedBitmap + /** + * Creates an adaptive icon representation of the themed bitmap for various surface effects. The + * controller can return the [originalIcon] for using an un-themed icon for these effects or + * null to disable any surface effects in which can the static themed icon will be used without + * any additional effects. + */ fun createThemedAdaptiveIcon( context: Context, originalIcon: AdaptiveIconDrawable, diff --git a/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.kt b/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.kt index c305607..0e4544e 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.kt +++ b/iconloaderlib/src/com/android/launcher3/icons/cache/BaseIconCache.kt @@ -42,12 +42,15 @@ import android.util.SparseArray import androidx.annotation.VisibleForTesting import androidx.annotation.WorkerThread import com.android.launcher3.Flags +import com.android.systemui.shared.Flags.extendibleThemeManager import com.android.launcher3.icons.BaseIconFactory import com.android.launcher3.icons.BaseIconFactory.IconOptions import com.android.launcher3.icons.BitmapInfo +import com.android.launcher3.icons.BitmapInfo.Companion.LOW_RES_ICON import com.android.launcher3.icons.GraphicsUtils import com.android.launcher3.icons.IconProvider import com.android.launcher3.icons.SourceHint +import com.android.launcher3.icons.ThemedBitmap import com.android.launcher3.icons.cache.CacheLookupFlag.Companion.DEFAULT_LOOKUP_FLAG import com.android.launcher3.util.ComponentKey import com.android.launcher3.util.FlagOp @@ -223,9 +226,11 @@ constructor( } // Only add an entry in memory, if there was already something previously - if (cache[key] != null) { + val existingEntry = cache[key] + if (existingEntry != null) { val entry = CacheEntry() - entry.bitmap = bitmapInfo + entry.bitmap = + bitmapInfo.downSampleToLookupFlag(existingEntry.bitmap.matchingLookupFlag) entry.title = entryTitle entry.contentDescription = getUserBadgedLabel(entryTitle, user) cache[key] = entry @@ -292,7 +297,7 @@ constructor( obj, entry, cachingLogic, - lookupFlags.usePackageIcon(), + lookupFlags, /* usePackageTitle= */ true, componentName, user, @@ -311,7 +316,7 @@ constructor( obj: T?, entry: CacheEntry, cachingLogic: CachingLogic, - usePackageIcon: Boolean, + lookupFlag: CacheLookupFlag, usePackageTitle: Boolean, componentName: ComponentName, user: UserHandle, @@ -319,8 +324,9 @@ constructor( if (obj != null) { entry.bitmap = cachingLogic.loadIcon(context, this, obj) } else { - if (usePackageIcon) { - val packageEntry = getEntryForPackageLocked(componentName.packageName, user) + if (lookupFlag.usePackageIcon()) { + val packageEntry = + getEntryForPackageLocked(componentName.packageName, user, lookupFlag) if (DEBUG) { Log.d(TAG, "using package default icon for " + componentName.toShortString()) } @@ -331,6 +337,7 @@ constructor( entry.title = packageEntry.title } } + entry.bitmap = entry.bitmap.downSampleToLookupFlag(lookupFlag) } } @@ -442,8 +449,7 @@ constructor( // only keep the low resolution icon instead of the larger full-sized icon val iconInfo = appInfoCachingLogic.loadIcon(context, this, appInfo) entry.bitmap = - if (lookupFlags.useLowRes()) - BitmapInfo.of(BitmapInfo.LOW_RES_ICON, iconInfo.color) + if (lookupFlags.useLowRes()) BitmapInfo.of(LOW_RES_ICON, iconInfo.color) else iconInfo loadFallbackTitle(appInfo, entry, appInfoCachingLogic, user) @@ -516,7 +522,7 @@ constructor( // Set the alpha to be 255, so that we never have a wrong color entry.bitmap = BitmapInfo.of( - BitmapInfo.LOW_RES_ICON, + LOW_RES_ICON, GraphicsUtils.setColorAlphaBound(c.getInt(INDEX_COLOR), 255), ) c.getString(INDEX_TITLE).let { @@ -546,23 +552,29 @@ constructor( return false } - iconFactory.use { factory -> - val themeController = factory.themeController - val monoIconData = c.getBlob(INDEX_MONO_ICON) - if (themeController != null && monoIconData != null) { - entry.bitmap.themedBitmap = - themeController.decode( - data = monoIconData, - info = entry.bitmap, - factory = factory, - sourceHint = - SourceHint(cacheKey, logic, c.getString(INDEX_FRESHNESS_ID)), - ) + if (!extendibleThemeManager() || lookupFlags.hasThemeIcon()) { + // Always set a non-null theme bitmap if theming was requested + entry.bitmap.themedBitmap = ThemedBitmap.NOT_SUPPORTED + + iconFactory.use { factory -> + val themeController = factory.themeController + val monoIconData = c.getBlob(INDEX_MONO_ICON) + if (themeController != null && monoIconData != null) { + entry.bitmap.themedBitmap = + themeController.decode( + data = monoIconData, + info = entry.bitmap, + factory = factory, + sourceHint = + SourceHint(cacheKey, logic, c.getString(INDEX_FRESHNESS_ID)), + ) + } } } } entry.bitmap.flags = c.getInt(INDEX_FLAGS) entry.bitmap = entry.bitmap.withFlags(getUserFlagOpLocked(cacheKey.user)) + iconProvider.notifyIconLoaded(entry.bitmap, cacheKey, logic) return true } @@ -645,7 +657,7 @@ constructor( ComponentKey(ComponentName(packageName, packageName + EMPTY_CLASS_NAME), user) // Ensures themed bitmaps in the icon cache are invalidated - @JvmField val RELEASE_VERSION = if (Flags.forceMonochromeAppIcons()) 10 else 9 + @JvmField val RELEASE_VERSION = if (Flags.enableLauncherIconShapes()) 11 else 10 @JvmField val TABLE_NAME = "icons" @JvmField val COLUMN_ROWID = "rowid" @@ -662,12 +674,17 @@ constructor( val COLUMNS_LOW_RES = arrayOf(COLUMN_COMPONENT, COLUMN_LABEL, COLUMN_ICON_COLOR, COLUMN_FLAGS) + @JvmField + val COLUMNS_HIGH_RES_NO_THEME = + COLUMNS_LOW_RES.copyOf(COLUMNS_LOW_RES.size + 2).apply { + this[size - 1] = COLUMN_ICON + this[size - 2] = COLUMN_FRESHNESS_ID + } + @JvmField val COLUMNS_HIGH_RES = - COLUMNS_LOW_RES.copyOf(COLUMNS_LOW_RES.size + 3).apply { - this[size - 3] = COLUMN_ICON - this[size - 2] = COLUMN_MONO_ICON - this[size - 1] = COLUMN_FRESHNESS_ID + COLUMNS_HIGH_RES_NO_THEME.copyOf(COLUMNS_HIGH_RES_NO_THEME.size + 1).apply { + this[size - 1] = COLUMN_MONO_ICON } @JvmField val INDEX_TITLE = COLUMNS_HIGH_RES.indexOf(COLUMN_LABEL) @@ -679,6 +696,20 @@ constructor( @JvmStatic fun CacheLookupFlag.toLookupColumns() = - if (useLowRes()) COLUMNS_LOW_RES else COLUMNS_HIGH_RES + when { + useLowRes() -> COLUMNS_LOW_RES + extendibleThemeManager() && !hasThemeIcon() -> COLUMNS_HIGH_RES_NO_THEME + else -> COLUMNS_HIGH_RES + } + + @JvmStatic + protected fun BitmapInfo.downSampleToLookupFlag(flag: CacheLookupFlag) = + when { + !extendibleThemeManager() -> this + flag.useLowRes() -> BitmapInfo.of(LOW_RES_ICON, color) + !flag.hasThemeIcon() && themedBitmap != null -> + clone().apply { themedBitmap = null } + else -> this + } } } diff --git a/iconloaderlib/src/com/android/launcher3/icons/cache/CacheLookupFlag.kt b/iconloaderlib/src/com/android/launcher3/icons/cache/CacheLookupFlag.kt index 42fda24..9e56dbe 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/cache/CacheLookupFlag.kt +++ b/iconloaderlib/src/com/android/launcher3/icons/cache/CacheLookupFlag.kt @@ -16,6 +16,7 @@ package com.android.launcher3.icons.cache import androidx.annotation.IntDef +import com.android.systemui.shared.Flags.extendibleThemeManager import kotlin.annotation.AnnotationRetention.SOURCE /** Flags to control cache lookup behavior */ @@ -45,18 +46,30 @@ data class CacheLookupFlag private constructor(@LookupFlag private val flag: Int fun withSkipAddToMemCache(skipAddToMemCache: Boolean = true) = updateMask(SKIP_ADD_TO_MEM_CACHE, skipAddToMemCache) + /** Entry will include theme icon. Note that theme icon is only loaded for high-res icons */ + fun hasThemeIcon() = hasFlag(LOAD_THEME_ICON) + + @JvmOverloads + fun withThemeIcon(addThemeIcon: Boolean = true) = updateMask(LOAD_THEME_ICON, addThemeIcon) + 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())] /** Returns `true` if this flag has less UI information then [other] */ - fun isVisuallyLessThan(other: CacheLookupFlag): Boolean { - return useLowRes() && !other.useLowRes() - } + fun isVisuallyLessThan(other: CacheLookupFlag) = + when { + useLowRes() && !other.useLowRes() -> true + extendibleThemeManager() && !hasThemeIcon() && other.hasThemeIcon() -> true + else -> false + } @Retention(SOURCE) - @IntDef(value = [USE_LOW_RES, USE_PACKAGE_ICON, SKIP_ADD_TO_MEM_CACHE], flag = true) + @IntDef( + value = [USE_LOW_RES, USE_PACKAGE_ICON, SKIP_ADD_TO_MEM_CACHE, LOAD_THEME_ICON], + flag = true, + ) /** Various options to control cache lookup */ private annotation class LookupFlag @@ -64,8 +77,9 @@ data class CacheLookupFlag private constructor(@LookupFlag private val flag: Int 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 const val LOAD_THEME_ICON: Int = 1 shl 3 - private val flagCache = Array(8) { CacheLookupFlag(it) } + private val flagCache = Array(1 shl 4) { CacheLookupFlag(it) } @JvmField val DEFAULT_LOOKUP_FLAG = CacheLookupFlag(0) } diff --git a/iconloaderlib/src/com/android/launcher3/icons/cache/LauncherActivityCachingLogic.kt b/iconloaderlib/src/com/android/launcher3/icons/cache/LauncherActivityCachingLogic.kt index 8457628..c7dd470 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/cache/LauncherActivityCachingLogic.kt +++ b/iconloaderlib/src/com/android/launcher3/icons/cache/LauncherActivityCachingLogic.kt @@ -22,12 +22,13 @@ import android.content.pm.LauncherActivityInfo import android.os.Build.VERSION import android.os.UserHandle import android.util.Log -import app.lawnchair.icons.getCustomAppNameForComponent import com.android.launcher3.Flags.useNewIconForArchivedApps import com.android.launcher3.icons.BaseIconFactory.IconOptions import com.android.launcher3.icons.BitmapInfo import com.android.launcher3.icons.IconProvider +import app.lawnchair.icons.getCustomAppNameForComponent + object LauncherActivityCachingLogic : CachingLogic { const val TAG = "LauncherActivityCachingLogic" diff --git a/iconloaderlib/src/com/android/launcher3/icons/mono/MonoIconThemeController.kt b/iconloaderlib/src/com/android/launcher3/icons/mono/MonoIconThemeController.kt index 411d714..8af8fd9 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/mono/MonoIconThemeController.kt +++ b/iconloaderlib/src/com/android/launcher3/icons/mono/MonoIconThemeController.kt @@ -46,7 +46,8 @@ import java.nio.ByteBuffer @TargetApi(Build.VERSION_CODES.TIRAMISU) class MonoIconThemeController( - private val colorProvider: (Context) -> IntArray = ThemedIconDrawable.Companion::getColors + private val shouldForceThemeIcon: Boolean = false, + private val colorProvider: (Context) -> IntArray = ThemedIconDrawable.Companion::getColors, ) : IconThemeController { override val themeID = "with-theme" @@ -63,9 +64,8 @@ class MonoIconThemeController( icon, info, factory.getShapePath(icon, Rect(0, 0, info.icon.width, info.icon.height)), - factory.iconScale, sourceHint?.isFileDrawable ?: false, - factory.shouldForceThemeIcon(), + shouldForceThemeIcon, ) if (mono != null) { return MonoThemedBitmap( @@ -86,16 +86,15 @@ class MonoIconThemeController( base: AdaptiveIconDrawable, info: BitmapInfo, shapePath: Path, - iconScale: Float, isFileDrawable: Boolean, shouldForceThemeIcon: Boolean, ): Drawable? { val mono = base.monochrome if (mono != null) { - return ClippedMonoDrawable(mono, shapePath, iconScale) + return ClippedMonoDrawable(mono, shapePath) } if (Flags.forceMonochromeAppIcons() && shouldForceThemeIcon && !isFileDrawable) { - return MonochromeIconFactory(info.icon.width).wrap(base, shapePath, iconScale) + return MonochromeIconFactory(info.icon.width).wrap(base, shapePath) } return null } @@ -105,9 +104,9 @@ class MonoIconThemeController( info: BitmapInfo, factory: BaseIconFactory, sourceHint: SourceHint, - ): ThemedBitmap? { + ): ThemedBitmap { val icon = info.icon - if (data.size != icon.height * icon.width) return null + if (data.size != icon.height * icon.width) return ThemedBitmap.NOT_SUPPORTED var monoBitmap = Bitmap.createBitmap(icon.width, icon.height, ALPHA_8) monoBitmap.copyPixelsFromBuffer(ByteBuffer.wrap(data)) @@ -124,7 +123,7 @@ class MonoIconThemeController( context: Context, originalIcon: AdaptiveIconDrawable, info: BitmapInfo?, - ): AdaptiveIconDrawable? { + ): AdaptiveIconDrawable { val colors = colorProvider(context) originalIcon.mutate() var monoDrawable = originalIcon.monochrome?.apply { setTint(colors[1]) } @@ -148,13 +147,11 @@ class MonoIconThemeController( } return monoDrawable?.let { AdaptiveIconDrawable(ColorDrawable(colors[0]), it) } + ?: originalIcon } - class ClippedMonoDrawable( - base: Drawable?, - private val shapePath: Path, - private val iconScale: Float, - ) : InsetDrawable(base, -AdaptiveIconDrawable.getExtraInsetFraction()) { + class ClippedMonoDrawable(base: Drawable?, private val shapePath: Path) : + InsetDrawable(base, -AdaptiveIconDrawable.getExtraInsetFraction()) { // TODO(b/399666950): remove this after launcher icon shapes is fully enabled private val mCrop = AdaptiveIconDrawable(ColorDrawable(Color.BLACK), null) @@ -163,7 +160,6 @@ class MonoIconThemeController( val saveCount = canvas.save() if (Flags.enableLauncherIconShapes()) { canvas.clipPath(shapePath) - canvas.scale(iconScale, iconScale, bounds.width() / 2f, bounds.height() / 2f) } else { canvas.clipPath(mCrop.iconMask) } diff --git a/iconloaderlib/src/com/android/launcher3/icons/mono/ThemedIconDrawable.kt b/iconloaderlib/src/com/android/launcher3/icons/mono/ThemedIconDrawable.kt index a0cabf1..4ed5017 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/mono/ThemedIconDrawable.kt +++ b/iconloaderlib/src/com/android/launcher3/icons/mono/ThemedIconDrawable.kt @@ -29,14 +29,15 @@ import android.graphics.PorterDuffColorFilter import android.graphics.Rect import android.os.Build import androidx.core.graphics.ColorUtils -import app.lawnchair.icons.shouldTransparentBGIcons import com.android.launcher3.icons.BitmapInfo import com.android.launcher3.icons.FastBitmapDrawable import com.android.launcher3.icons.R +import app.lawnchair.icons.shouldTransparentBGIcons + /** Class to handle monochrome themed app icons */ class ThemedIconDrawable(constantState: ThemedConstantState) : - FastBitmapDrawable(constantState.getBitmapInfo()) { + FastBitmapDrawable(constantState.bitmapInfo) { private val colorFg = constantState.colorFg private val colorBg = constantState.colorBg @@ -66,10 +67,10 @@ class ThemedIconDrawable(constantState: ThemedConstantState) : override fun updateFilter() { super.updateFilter() - val alpha = if (mIsDisabled) (mDisabledAlpha * FULLY_OPAQUE).toInt() else FULLY_OPAQUE + val alpha = if (isDisabled) (disabledAlpha * FULLY_OPAQUE).toInt() else FULLY_OPAQUE mBgPaint.alpha = alpha mBgPaint.setColorFilter( - if (mIsDisabled) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + if (isDisabled) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { BlendModeColorFilter(getDisabledColor(colorBg), SRC_IN) } else { PorterDuffColorFilter(getDisabledColor(colorBg), PorterDuff.Mode.SRC_IN) @@ -78,7 +79,7 @@ class ThemedIconDrawable(constantState: ThemedConstantState) : monoPaint.alpha = alpha monoPaint.setColorFilter( - if (mIsDisabled) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + if (isDisabled) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { BlendModeColorFilter( getDisabledColor(colorFg), SRC_IN, @@ -92,7 +93,7 @@ class ThemedIconDrawable(constantState: ThemedConstantState) : override fun isThemed() = true override fun newConstantState() = - ThemedConstantState(mBitmapInfo, monoIcon, bgBitmap, colorBg, colorFg) + ThemedConstantState(bitmapInfo, monoIcon, bgBitmap, colorBg, colorFg) override fun getIconColor() = colorFg @@ -105,8 +106,6 @@ class ThemedIconDrawable(constantState: ThemedConstantState) : ) : FastBitmapConstantState(bitmapInfo) { public override fun createDrawable() = ThemedIconDrawable(this) - - fun getBitmapInfo(): BitmapInfo = mBitmapInfo } companion object { From bb5f5584fac4e74c46ac41210bfdeebeec04943d Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Sun, 16 Nov 2025 17:16:14 +0700 Subject: [PATCH 05/19] feat: searchuilib Android 16 QPR1 --- searchuilib/Android.bp | 28 ---- searchuilib/build.gradle | 10 -- .../com/android/app/search/LayoutType.java | 155 ------------------ .../com/android/app/search/ResultType.java | 96 ----------- 4 files changed, 289 deletions(-) delete mode 100644 searchuilib/Android.bp delete mode 100644 searchuilib/build.gradle delete mode 100644 searchuilib/src/com/android/app/search/LayoutType.java delete mode 100644 searchuilib/src/com/android/app/search/ResultType.java diff --git a/searchuilib/Android.bp b/searchuilib/Android.bp deleted file mode 100644 index 2b25616..0000000 --- a/searchuilib/Android.bp +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (C) 2020 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 { - default_applicable_licenses: ["Android-Apache-2.0"], -} - -android_library { - name: "search_ui", - - sdk_version: "current", - min_sdk_version: "26", - - srcs: [ - "src/**/*.java", - ], -} diff --git a/searchuilib/build.gradle b/searchuilib/build.gradle deleted file mode 100644 index 76551cf..0000000 --- a/searchuilib/build.gradle +++ /dev/null @@ -1,10 +0,0 @@ -apply plugin: 'com.android.library' - -android { - namespace "com.android.app.search" - sourceSets { - main { - java.srcDirs = ['src'] - } - } -} diff --git a/searchuilib/src/com/android/app/search/LayoutType.java b/searchuilib/src/com/android/app/search/LayoutType.java deleted file mode 100644 index 1adb0ea..0000000 --- a/searchuilib/src/com/android/app/search/LayoutType.java +++ /dev/null @@ -1,155 +0,0 @@ -/* - * Copyright (C) 2020 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.app.search; - -import androidx.annotation.StringDef; - -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; - -/** - * Constants to be used with {@link SearchTarget}. - */ -public class LayoutType { - - @StringDef(value = { - ICON_SINGLE_VERTICAL_TEXT, - ICON_HORIZONTAL_TEXT, - HORIZONTAL_MEDIUM_TEXT, - EXTRA_TALL_ICON_ROW, - SMALL_ICON_HORIZONTAL_TEXT, - SMALL_ICON_HORIZONTAL_TEXT_THUMBNAIL, - ICON_CONTAINER, - THUMBNAIL_CONTAINER, - BIG_ICON_MEDIUM_HEIGHT_ROW, - THUMBNAIL, - ICON_SLICE, - WIDGET_PREVIEW, - WIDGET_LIVE, - PEOPLE_TILE, - TEXT_HEADER, - DIVIDER, - EMPTY_DIVIDER, - CALCULATOR, - SECTION_HEADER, - TALL_CARD_WITH_IMAGE_NO_ICON, - TEXT_HEADER_ROW, - QS_TILE, - PLACEHOLDER, - RICHANSWER_PLACEHOLDER, - EMPTY_STATE, - SEARCH_SETTINGS, - }) - @Retention(RetentionPolicy.SOURCE) - public @interface SearchLayoutType {} - - // ------ - // | icon | - // ------ - // text - public static final String ICON_SINGLE_VERTICAL_TEXT = "icon"; - - // Below three layouts (to be deprecated) and two layouts render - // {@link SearchTarget}s in following layout. - // ------ ------ ------ - // | | title |(opt)| |(opt)| - // | icon | subtitle (optional) | icon| | icon| - // ------ ------ ------ - @Deprecated - public static final String ICON_SINGLE_HORIZONTAL_TEXT = "icon_text_row"; - @Deprecated - public static final String ICON_DOUBLE_HORIZONTAL_TEXT = "icon_texts_row"; - @Deprecated - public static final String ICON_DOUBLE_HORIZONTAL_TEXT_BUTTON = "icon_texts_button"; - - // will replace ICON_DOUBLE_* ICON_SINGLE_* layouts - public static final String ICON_HORIZONTAL_TEXT = "icon_row"; - public static final String HORIZONTAL_MEDIUM_TEXT = "icon_row_medium"; - public static final String EXTRA_TALL_ICON_ROW = "extra_tall_icon_row"; - public static final String SMALL_ICON_HORIZONTAL_TEXT = "short_icon_row"; - public static final String SMALL_ICON_HORIZONTAL_TEXT_THUMBNAIL = "short_icon_row_thumbnail"; - - // This layout contains a series of icon results (currently up to 4 per row). - // The container does not support stretching for its children, and can only contain - // {@link #ICON_SINGLE_VERTICAL_TEXT} layout types. - public static final String ICON_CONTAINER = "icon_container"; - - // This layout contains a series of thumbnails (currently up to 3 per row). - // The container supports stretching for its children, and can only contain {@link #THUMBNAIL} - // layout types. - public static final String THUMBNAIL_CONTAINER = "thumbnail_container"; - - // This layout creates a container for people grouping - // Only available above version code 2 - public static final String BIG_ICON_MEDIUM_HEIGHT_ROW = "big_icon_medium_row"; - - // This layout creates square thumbnail image (currently 3 column) - public static final String THUMBNAIL = "thumbnail"; - - // This layout contains an icon and slice - public static final String ICON_SLICE = "slice"; - - // Widget bitmap preview - public static final String WIDGET_PREVIEW = "widget_preview"; - - // Live widget search result - public static final String WIDGET_LIVE = "widget_live"; - - // Layout type used to display people tiles using shortcut info - public static final String PEOPLE_TILE = "people_tile"; - - // Deprecated - // text based header to group various layouts in low confidence section of the results. - public static final String TEXT_HEADER = "header"; - - // horizontal bar to be inserted between fallback search results and low confidence section - public static final String EMPTY_DIVIDER = "empty_divider"; - - @Deprecated(since = "Use EMPTY_DIVIDER instead") - public static final String DIVIDER = EMPTY_DIVIDER; - - // layout representing quick calculations - public static final String CALCULATOR = "calculator"; - - // From version code 4, if TEXT_HEADER_ROW is used, no need to insert this on-device - // section header. - public static final String SECTION_HEADER = "section_header"; - - // layout for a tall card with header and image, and no icon. - public static final String TALL_CARD_WITH_IMAGE_NO_ICON = "tall_card_with_image_no_icon"; - - // Layout for a text header - // Available for SearchUiManager proxy service to use above version code 3 - public static final String TEXT_HEADER_ROW = "text_header_row"; - - // Layout for a quick settings tile - public static final String QS_TILE = "qs_tile"; - - // Placeholder for web suggest. - public static final String PLACEHOLDER = "placeholder"; - - // Placeholder for rich answer cards. - // Only available on or above version code 3. - public static final String RICHANSWER_PLACEHOLDER = "richanswer_placeholder"; - - - // layout representing the empty, no query state - public static final String EMPTY_STATE = "empty_state"; - - // layout representing search settings - public static final String SEARCH_SETTINGS = "launcher_settings"; -} diff --git a/searchuilib/src/com/android/app/search/ResultType.java b/searchuilib/src/com/android/app/search/ResultType.java deleted file mode 100644 index 5340220..0000000 --- a/searchuilib/src/com/android/app/search/ResultType.java +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright (C) 2020 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.app.search; - -/** - * Constants to be used with {@link android.app.search.SearchContext} and - * {@link android.app.search.SearchTarget}. - * - * Note, a result type could be a of two types. - * For example, unpublished settings result type could be in slices: - * resultType = SETTING | SLICE - */ -public class ResultType { - - // published corpus by 3rd party app, supported by SystemService - public static final int APPLICATION = 1 << 0; - public static final int SHORTCUT = 1 << 1; - public static final int SLICE = 1 << 6; - public static final int WIDGETS = 1 << 7; - - // Not extracted from any of the SystemService - public static final int PEOPLE = 1 << 2; - public static final int ACTION = 1 << 3; - public static final int SETTING = 1 << 4; - public static final int IMAGE = 1 << 5; - - @Deprecated(since = "Use IMAGE") - public static final int SCREENSHOT = IMAGE; - - public static final int PLAY = 1 << 8; - public static final int SUGGEST = 1 << 9; - public static final int ASSISTANT = 1 << 10; - public static final int CHROMETAB = 1 << 11; - public static final int NAVVYSITE = 1 << 12; - public static final int TIPS = 1 << 13; - public static final int PEOPLE_TILE = 1 << 14; - public static final int LEGACY_SHORTCUT = 1 << 15; - public static final int MEMORY = 1 << 16; - public static final int WEB_SUGGEST = 1 << 17; - public static final int NO_FULFILLMENT = 1 << 18; - public static final int EDUCARD = 1 << 19; - public static final int SYSTEM_POINTER = 1 << 20; - public static final int VIDEO = 1 << 21; - - public static final int PUBLIC_DATA_TYPE = APPLICATION | SETTING | PLAY | WEB_SUGGEST; - public static final int PRIMITIVE_TYPE = APPLICATION | SLICE | SHORTCUT | WIDGETS | ACTION | - LEGACY_SHORTCUT; - public static final int CORPUS_TYPE = - PEOPLE | SETTING | IMAGE | PLAY | SUGGEST | ASSISTANT | CHROMETAB | NAVVYSITE | TIPS - | PEOPLE_TILE | MEMORY | WEB_SUGGEST | VIDEO; - public static final int RANK_TYPE = SYSTEM_POINTER; - public static final int UI_TYPE = EDUCARD | NO_FULFILLMENT; - - public static boolean isSlice(int resultType) { - return (resultType & SLICE) != 0; - } - - public static boolean isSystemPointer(int resultType) { - return (resultType & SYSTEM_POINTER) != 0; - } - - /** - * Returns result type integer where only {@code #CORPUS_TYPE} bit will turned on. - */ - public static int getCorpusType(int resultType) { - return (resultType & CORPUS_TYPE); - } - - /** - * Returns result type integer where only {@code #PRIMITIVE_TYPE} bit will be turned on. - */ - public static int getPrimitiveType(int resultType) { - return (resultType & PRIMITIVE_TYPE); - } - - /** - * Returns whether the given result type is privacy safe or not. - */ - public static boolean isPrivacySafe(int resultType) { - return (resultType & PUBLIC_DATA_TYPE) != 0; - } -} From c7ff98fc02b280a677cb04f070a25cd7c28c7db4 Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Sun, 16 Nov 2025 17:19:52 +0700 Subject: [PATCH 06/19] docs: Remove searchuilib from README.md --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 5797480..00535d5 100644 --- a/README.md +++ b/README.md @@ -7,4 +7,3 @@ A brief explanation of what each library does: * `contextualeducationlib`: Store "education" type * `iconloaderlib`: Handling all of Launcher3 and Lawnchair icons * `msdllib`: Multi-Sensory-Design-Language, handling all new vibrations in Launcher3 Android 16 -* `searchuilib`: Store search-related layout type From 2297e189faf1bf16ee1fa2467f63e343779b4ea3 Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Sun, 16 Nov 2025 19:20:20 +0700 Subject: [PATCH 07/19] fix: Duplicated Iconloaderlib file --- .../launcher3/icons/BaseIconFactory.java | 2 +- .../android/launcher3/icons/BitmapInfo.java | 268 ---------- .../launcher3/icons/FastBitmapDrawable.java | 471 ------------------ 3 files changed, 1 insertion(+), 740 deletions(-) delete mode 100644 iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.java delete mode 100644 iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.java diff --git a/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java b/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java index d48ff18..17fa5e8 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java +++ b/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java @@ -60,7 +60,7 @@ */ public class BaseIconFactory implements AutoCloseable { - private static final int DEFAULT_WRAPPER_BACKGROUND = Color.WHITE; + public static final int DEFAULT_WRAPPER_BACKGROUND = Color.WHITE; private static final float LEGACY_ICON_SCALE = .7f * (1f / (1 + 2 * getExtraInsetFraction())); public static final int MODE_DEFAULT = 0; diff --git a/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.java b/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.java deleted file mode 100644 index 62ca2ed..0000000 --- a/iconloaderlib/src/com/android/launcher3/icons/BitmapInfo.java +++ /dev/null @@ -1,268 +0,0 @@ -/* - * Copyright (C) 2017 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; - -import static com.android.launcher3.icons.cache.CacheLookupFlag.DEFAULT_LOOKUP_FLAG; - -import android.content.Context; -import android.graphics.Bitmap; -import android.graphics.Bitmap.Config; -import android.graphics.Canvas; -import android.graphics.Path; -import android.graphics.drawable.Drawable; - -import androidx.annotation.IntDef; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.android.launcher3.icons.cache.CacheLookupFlag; -import com.android.launcher3.util.FlagOp; - -public class BitmapInfo { - - public static final int FLAG_WORK = 1 << 0; - public static final int FLAG_INSTANT = 1 << 1; - public static final int FLAG_CLONE = 1 << 2; - public static final int FLAG_PRIVATE = 1 << 3; - @IntDef(flag = true, value = { - FLAG_WORK, - FLAG_INSTANT, - FLAG_CLONE, - FLAG_PRIVATE - }) - @interface BitmapInfoFlags {} - - public static final int FLAG_THEMED = 1 << 0; - public static final int FLAG_NO_BADGE = 1 << 1; - public static final int FLAG_SKIP_USER_BADGE = 1 << 2; - @IntDef(flag = true, value = { - FLAG_THEMED, - FLAG_NO_BADGE, - FLAG_SKIP_USER_BADGE, - }) - public @interface DrawableCreationFlags {} - - public static final Bitmap LOW_RES_ICON = Bitmap.createBitmap(1, 1, Config.ALPHA_8); - public static final BitmapInfo LOW_RES_INFO = fromBitmap(LOW_RES_ICON); - - public static final String TAG = "BitmapInfo"; - - @NonNull - public final Bitmap icon; - public final int color; - - @Nullable - private ThemedBitmap mThemedBitmap; - - public @BitmapInfoFlags int flags; - - // b/377618519: These are saved to debug why work badges sometimes don't show up on work apps - public @DrawableCreationFlags int creationFlags; - - private BitmapInfo badgeInfo; - - public BitmapInfo(@NonNull Bitmap icon, int color) { - this.icon = icon; - this.color = color; - } - - public BitmapInfo withBadgeInfo(BitmapInfo badgeInfo) { - BitmapInfo result = clone(); - result.badgeInfo = badgeInfo; - return result; - } - - /** - * Returns a bitmapInfo with the flagOP applied - */ - public BitmapInfo withFlags(@NonNull FlagOp op) { - if (op == FlagOp.NO_OP) { - return this; - } - BitmapInfo result = clone(); - result.flags = op.apply(result.flags); - return result; - } - - protected BitmapInfo copyInternalsTo(BitmapInfo target) { - target.mThemedBitmap = mThemedBitmap; - target.flags = flags; - target.badgeInfo = badgeInfo; - return target; - } - - @Override - public BitmapInfo clone() { - return copyInternalsTo(new BitmapInfo(icon, color)); - } - - public void setThemedBitmap(@Nullable ThemedBitmap themedBitmap) { - mThemedBitmap = themedBitmap; - } - - @Nullable - public ThemedBitmap getThemedBitmap() { - return mThemedBitmap; - } - - /** - * Ideally icon should not be null, except in cases when generating hardware bitmap failed - */ - public final boolean isNullOrLowRes() { - return icon == null || icon == LOW_RES_ICON; - } - - public final boolean isLowRes() { - return LOW_RES_ICON == icon; - } - - /** - * 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() { - return !isNullOrLowRes(); - } - - /** - * Creates a drawable for the provided BitmapInfo - */ - public FastBitmapDrawable newIcon(Context context) { - return newIcon(context, 0); - } - - /** - * Creates a drawable for the provided BitmapInfo - */ - public FastBitmapDrawable newIcon(Context context, @DrawableCreationFlags int creationFlags) { - return newIcon(context, creationFlags, null); - } - - /** - * Creates a drawable for the provided BitmapInfo - * - * @param context Context - * @param creationFlags Flags for creating the FastBitmapDrawable - * @param badgeShape Optional Path for masking icon badges to a shape. Should be 100x100. - * @return FastBitmapDrawable - */ - public FastBitmapDrawable newIcon(Context context, @DrawableCreationFlags int creationFlags, - @Nullable Path badgeShape) { - FastBitmapDrawable drawable; - if (isLowRes()) { - drawable = new PlaceHolderIconDrawable(this, context); - } else if ((creationFlags & FLAG_THEMED) != 0 && mThemedBitmap != null) { - drawable = mThemedBitmap.newDrawable(this, context); - } else { - drawable = new FastBitmapDrawable(this); - } - applyFlags(context, drawable, creationFlags, badgeShape); - return drawable; - } - - protected void applyFlags(Context context, FastBitmapDrawable drawable, - @DrawableCreationFlags int creationFlags, @Nullable Path badgeShape) { - this.creationFlags = creationFlags; - drawable.mDisabledAlpha = GraphicsUtils.getFloat(context, R.attr.disabledIconAlpha, 1f); - drawable.mCreationFlags = creationFlags; - if ((creationFlags & FLAG_NO_BADGE) == 0) { - Drawable badge = getBadgeDrawable(context, (creationFlags & FLAG_THEMED) != 0, - (creationFlags & FLAG_SKIP_USER_BADGE) != 0, badgeShape); - if (badge != null) { - drawable.setBadge(badge); - } - } - } - - /** - * Gets Badge drawable based on current flags - * @param context Context - * @param isThemed If Drawable is themed. - * @param badgeShape Optional Path to mask badges to a shape. Should be 100x100. - * @return Drawable for the badge. - */ - public Drawable getBadgeDrawable(Context context, boolean isThemed, @Nullable Path badgeShape) { - return getBadgeDrawable(context, isThemed, false, badgeShape); - } - - - /** - * Creates a Drawable for an icon badge for this BitmapInfo - * @param context Context - * @param isThemed If the drawable is themed. - * @param skipUserBadge If should skip User Profile badging. - * @param badgeShape Optional Path to mask badge Drawable to a shape. Should be 100x100. - * @return Drawable for an icon Badge. - */ - @Nullable - private Drawable getBadgeDrawable(Context context, boolean isThemed, boolean skipUserBadge, - @Nullable Path badgeShape) { - if (badgeInfo != null) { - int creationFlag = isThemed ? FLAG_THEMED : 0; - if (skipUserBadge) { - creationFlag |= FLAG_SKIP_USER_BADGE; - } - return badgeInfo.newIcon(context, creationFlag, badgeShape); - } - if (skipUserBadge) { - return null; - } else if ((flags & FLAG_INSTANT) != 0) { - return new UserBadgeDrawable(context, R.drawable.ic_instant_app_badge, - R.color.badge_tint_instant, isThemed, badgeShape); - } else if ((flags & FLAG_WORK) != 0) { - return new UserBadgeDrawable(context, R.drawable.ic_work_app_badge, - R.color.badge_tint_work, isThemed, badgeShape); - } else if ((flags & FLAG_CLONE) != 0) { - return new UserBadgeDrawable(context, R.drawable.ic_clone_app_badge, - R.color.badge_tint_clone, isThemed, badgeShape); - } else if ((flags & FLAG_PRIVATE) != 0) { - return new UserBadgeDrawable(context, R.drawable.ic_private_profile_app_badge, - R.color.badge_tint_private, isThemed, badgeShape); - } - return null; - } - - public static BitmapInfo fromBitmap(@NonNull Bitmap bitmap) { - return of(bitmap, 0); - } - - public static BitmapInfo of(@NonNull Bitmap bitmap, int color) { - return new BitmapInfo(bitmap, color); - } - - /** - * Interface to be implemented by drawables to provide a custom BitmapInfo - */ - public interface Extender { - - /** - * Called for creating a custom BitmapInfo - */ - BitmapInfo getExtendedInfo(Bitmap bitmap, int color, - BaseIconFactory iconFactory, float normalizationScale); - - /** - * Called to draw the UI independent of any runtime configurations like time or theme - */ - void drawForPersistence(Canvas canvas); - } -} diff --git a/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.java b/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.java deleted file mode 100644 index f6ad4d1..0000000 --- a/iconloaderlib/src/com/android/launcher3/icons/FastBitmapDrawable.java +++ /dev/null @@ -1,471 +0,0 @@ -/* - * Copyright (C) 2008 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; - -import static com.android.launcher3.icons.BaseIconFactory.getBadgeSizeForIconSize; -import static com.android.launcher3.icons.BitmapInfo.FLAG_NO_BADGE; -import static com.android.launcher3.icons.BitmapInfo.FLAG_THEMED; -import static com.android.launcher3.icons.GraphicsUtils.setColorAlphaBound; - -import android.animation.ObjectAnimator; -import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.ColorFilter; -import android.graphics.ColorMatrix; -import android.graphics.ColorMatrixColorFilter; -import android.graphics.Paint; -import android.graphics.PixelFormat; -import android.graphics.Rect; -import android.graphics.drawable.Drawable; -import android.util.FloatProperty; -import android.view.animation.AccelerateInterpolator; -import android.view.animation.DecelerateInterpolator; -import android.view.animation.Interpolator; -import android.view.animation.PathInterpolator; - -import androidx.annotation.Nullable; -import androidx.annotation.VisibleForTesting; -import androidx.core.graphics.ColorUtils; - -import com.android.launcher3.icons.BitmapInfo.DrawableCreationFlags; - -public class FastBitmapDrawable extends Drawable implements Drawable.Callback { - - private static final Interpolator ACCEL = new AccelerateInterpolator(); - private static final Interpolator DEACCEL = new DecelerateInterpolator(); - private static final Interpolator HOVER_EMPHASIZED_DECELERATE_INTERPOLATOR = - new PathInterpolator(0.05f, 0.7f, 0.1f, 1.0f); - - @VisibleForTesting protected static final float PRESSED_SCALE = 1.1f; - @VisibleForTesting protected static final float HOVERED_SCALE = 1.1f; - public static final int WHITE_SCRIM_ALPHA = 138; - - private static final float DISABLED_DESATURATION = 1f; - private static final float DISABLED_BRIGHTNESS = 0.5f; - protected static final int FULLY_OPAQUE = 255; - - public static final int CLICK_FEEDBACK_DURATION = 200; - public static final int HOVER_FEEDBACK_DURATION = 300; - - private static boolean sFlagHoverEnabled = false; - - protected final Paint mPaint = new Paint(Paint.FILTER_BITMAP_FLAG | Paint.ANTI_ALIAS_FLAG); - public final BitmapInfo mBitmapInfo; - - @Nullable private ColorFilter mColorFilter; - - @VisibleForTesting protected boolean mIsPressed; - @VisibleForTesting protected boolean mIsHovered; - protected boolean mIsDisabled; - protected float mDisabledAlpha = 1f; - - @DrawableCreationFlags int mCreationFlags = 0; - - // Animator and properties for the fast bitmap drawable's scale - @VisibleForTesting protected static final FloatProperty SCALE - = new FloatProperty("scale") { - @Override - public Float get(FastBitmapDrawable fastBitmapDrawable) { - return fastBitmapDrawable.mScale; - } - - @Override - public void setValue(FastBitmapDrawable fastBitmapDrawable, float value) { - fastBitmapDrawable.mScale = value; - fastBitmapDrawable.invalidateSelf(); - } - }; - @VisibleForTesting protected ObjectAnimator mScaleAnimation; - private float mScale = 1; - private int mAlpha = 255; - - private Drawable mBadge; - - private boolean mHoverScaleEnabledForDisplay = true; - - protected FastBitmapDrawable(Bitmap b, int iconColor) { - this(BitmapInfo.of(b, iconColor)); - } - - public FastBitmapDrawable(Bitmap b) { - this(BitmapInfo.fromBitmap(b)); - } - - public FastBitmapDrawable(BitmapInfo info) { - mBitmapInfo = info; - setFilterBitmap(true); - } - - /** - * Returns true if the drawable points to the same bitmap icon object - */ - public boolean isSameInfo(BitmapInfo info) { - return mBitmapInfo == info; - } - - @Override - protected void onBoundsChange(Rect bounds) { - super.onBoundsChange(bounds); - updateBadgeBounds(bounds); - } - - private void updateBadgeBounds(Rect bounds) { - if (mBadge != null) { - setBadgeBounds(mBadge, bounds); - } - } - - @Override - public final void draw(Canvas canvas) { - if (mScale != 1f) { - int count = canvas.save(); - Rect bounds = getBounds(); - canvas.scale(mScale, mScale, bounds.exactCenterX(), bounds.exactCenterY()); - drawInternal(canvas, bounds); - if (mBadge != null) { - mBadge.draw(canvas); - } - canvas.restoreToCount(count); - } else { - drawInternal(canvas, getBounds()); - if (mBadge != null) { - mBadge.draw(canvas); - } - } - } - - protected void drawInternal(Canvas canvas, Rect bounds) { - canvas.drawBitmap(mBitmapInfo.icon, null, bounds, mPaint); - } - - /** - * Returns the primary icon color, slightly tinted white - */ - public int getIconColor() { - int whiteScrim = setColorAlphaBound(Color.WHITE, WHITE_SCRIM_ALPHA); - return ColorUtils.compositeColors(whiteScrim, mBitmapInfo.color); - } - - /** - * Returns if this represents a themed icon - */ - public boolean isThemed() { - return false; - } - - /** - * Returns true if the drawable was created with theme, even if it doesn't - * support theming itself. - */ - public boolean isCreatedForTheme() { - return isThemed() || (mCreationFlags & FLAG_THEMED) != 0; - } - - @Override - public void setColorFilter(ColorFilter cf) { - mColorFilter = cf; - updateFilter(); - } - - @Override - public int getOpacity() { - return PixelFormat.TRANSLUCENT; - } - - @Override - public void setAlpha(int alpha) { - if (mAlpha != alpha) { - mAlpha = alpha; - mPaint.setAlpha(alpha); - invalidateSelf(); - if (mBadge != null) { - mBadge.setAlpha(alpha); - } - } - } - - @Override - public void setFilterBitmap(boolean filterBitmap) { - mPaint.setFilterBitmap(filterBitmap); - mPaint.setAntiAlias(filterBitmap); - } - - @Override - public int getAlpha() { - return mAlpha; - } - - public void resetScale() { - if (mScaleAnimation != null) { - mScaleAnimation.cancel(); - mScaleAnimation = null; - } - mScale = 1; - invalidateSelf(); - } - - public float getAnimatedScale() { - return mScaleAnimation == null ? 1 : mScale; - } - - @Override - public int getIntrinsicWidth() { - return mBitmapInfo.icon.getWidth(); - } - - @Override - public int getIntrinsicHeight() { - return mBitmapInfo.icon.getHeight(); - } - - @Override - public int getMinimumWidth() { - return getBounds().width(); - } - - @Override - public int getMinimumHeight() { - return getBounds().height(); - } - - @Override - public boolean isStateful() { - return true; - } - - @Override - public ColorFilter getColorFilter() { - return mPaint.getColorFilter(); - } - - @Override - protected boolean onStateChange(int[] state) { - boolean isPressed = false; - boolean isHovered = false; - for (int s : state) { - if (s == android.R.attr.state_pressed) { - isPressed = true; - break; - } else if (sFlagHoverEnabled - && s == android.R.attr.state_hovered - && mHoverScaleEnabledForDisplay) { - isHovered = true; - // Do not break on hovered state, as pressed state should take precedence. - } - } - if (mIsPressed != isPressed || mIsHovered != isHovered) { - if (mScaleAnimation != null) { - mScaleAnimation.cancel(); - } - - float endScale = isPressed ? PRESSED_SCALE : (isHovered ? HOVERED_SCALE : 1f); - if (mScale != endScale) { - if (isVisible()) { - Interpolator interpolator = - isPressed != mIsPressed ? (isPressed ? ACCEL : DEACCEL) - : HOVER_EMPHASIZED_DECELERATE_INTERPOLATOR; - int duration = - isPressed != mIsPressed ? CLICK_FEEDBACK_DURATION - : HOVER_FEEDBACK_DURATION; - mScaleAnimation = ObjectAnimator.ofFloat(this, SCALE, endScale); - mScaleAnimation.setDuration(duration); - mScaleAnimation.setInterpolator(interpolator); - mScaleAnimation.start(); - } else { - mScale = endScale; - invalidateSelf(); - } - } - mIsPressed = isPressed; - mIsHovered = isHovered; - return true; - } - return false; - } - - public void setIsDisabled(boolean isDisabled) { - if (mIsDisabled != isDisabled) { - mIsDisabled = isDisabled; - if (mBadge instanceof FastBitmapDrawable fbd) { - fbd.setIsDisabled(isDisabled); - } - updateFilter(); - } - } - - protected boolean isDisabled() { - return mIsDisabled; - } - - public void setBadge(Drawable badge) { - if (mBadge != null) { - mBadge.setCallback(null); - } - mBadge = badge; - if (mBadge != null) { - mBadge.setCallback(this); - } - updateBadgeBounds(getBounds()); - updateFilter(); - } - - @VisibleForTesting - public Drawable getBadge() { - return mBadge; - } - - /** - * Updates the paint to reflect the current brightness and saturation. - */ - protected void updateFilter() { - mPaint.setColorFilter(mIsDisabled ? getDisabledColorFilter(mDisabledAlpha) : mColorFilter); - if (mBadge != null) { - mBadge.setColorFilter(getColorFilter()); - } - invalidateSelf(); - } - - protected FastBitmapConstantState newConstantState() { - return new FastBitmapConstantState(mBitmapInfo); - } - - @Override - public final ConstantState getConstantState() { - FastBitmapConstantState cs = newConstantState(); - cs.mIsDisabled = mIsDisabled; - if (mBadge != null) { - cs.mBadgeConstantState = mBadge.getConstantState(); - } - cs.mCreationFlags = mCreationFlags; - return cs; - } - - public static ColorFilter getDisabledColorFilter() { - return getDisabledColorFilter(1); - } - - // Returns if the FastBitmapDrawable contains a badge. - public boolean hasBadge() { - return (mCreationFlags & FLAG_NO_BADGE) == 0; - } - - private static ColorFilter getDisabledColorFilter(float disabledAlpha) { - ColorMatrix tempBrightnessMatrix = new ColorMatrix(); - ColorMatrix tempFilterMatrix = new ColorMatrix(); - - tempFilterMatrix.setSaturation(1f - DISABLED_DESATURATION); - float scale = 1 - DISABLED_BRIGHTNESS; - int brightnessI = (int) (255 * DISABLED_BRIGHTNESS); - float[] mat = tempBrightnessMatrix.getArray(); - mat[0] = scale; - mat[6] = scale; - mat[12] = scale; - mat[4] = brightnessI; - mat[9] = brightnessI; - mat[14] = brightnessI; - mat[18] = disabledAlpha; - tempFilterMatrix.preConcat(tempBrightnessMatrix); - return new ColorMatrixColorFilter(tempFilterMatrix); - } - - protected static final int getDisabledColor(int color) { - int component = (Color.red(color) + Color.green(color) + Color.blue(color)) / 3; - float scale = 1 - DISABLED_BRIGHTNESS; - int brightnessI = (int) (255 * DISABLED_BRIGHTNESS); - component = Math.min(Math.round(scale * component + brightnessI), FULLY_OPAQUE); - return Color.rgb(component, component, component); - } - - /** - * Sets the bounds for the badge drawable based on the main icon bounds - */ - public static void setBadgeBounds(Drawable badge, Rect iconBounds) { - int size = getBadgeSizeForIconSize(iconBounds.width()); - badge.setBounds(iconBounds.right - size, iconBounds.bottom - size, - iconBounds.right, iconBounds.bottom); - } - - @Override - public void invalidateDrawable(Drawable who) { - if (who == mBadge) { - invalidateSelf(); - } - } - - @Override - public void scheduleDrawable(Drawable who, Runnable what, long when) { - if (who == mBadge) { - scheduleSelf(what, when); - } - } - - @Override - public void unscheduleDrawable(Drawable who, Runnable what) { - unscheduleSelf(what); - } - - /** - * Sets whether hover state functionality is enabled. - */ - public static void setFlagHoverEnabled(boolean isFlagHoverEnabled) { - sFlagHoverEnabled = isFlagHoverEnabled; - } - - public void setHoverScaleEnabledForDisplay(boolean hoverScaleEnabledForDisplay) { - mHoverScaleEnabledForDisplay = hoverScaleEnabledForDisplay; - } - - public static class FastBitmapConstantState extends ConstantState { - protected final BitmapInfo mBitmapInfo; - - // These are initialized later so that subclasses don't need to - // pass everything in constructor - protected boolean mIsDisabled; - private ConstantState mBadgeConstantState; - - @DrawableCreationFlags int mCreationFlags = 0; - - public FastBitmapConstantState(Bitmap bitmap, int color) { - this(BitmapInfo.of(bitmap, color)); - } - - public FastBitmapConstantState(BitmapInfo info) { - mBitmapInfo = info; - } - - protected FastBitmapDrawable createDrawable() { - return new FastBitmapDrawable(mBitmapInfo); - } - - @Override - public final FastBitmapDrawable newDrawable() { - FastBitmapDrawable drawable = createDrawable(); - drawable.setIsDisabled(mIsDisabled); - if (mBadgeConstantState != null) { - drawable.setBadge(mBadgeConstantState.newDrawable()); - } - drawable.mCreationFlags = mCreationFlags; - return drawable; - } - - @Override - public int getChangingConfigurations() { - return 0; - } - } -} From f4395d4718efa70358b9b38dbaef5b73c77b94fc Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Sun, 16 Nov 2025 19:24:26 +0700 Subject: [PATCH 08/19] feat: ViewCaptureLib --- viewcapturelib/.gitignore | 13 + viewcapturelib/Android.bp | 79 ++ viewcapturelib/AndroidManifest.xml | 18 + viewcapturelib/OWNERS | 3 + viewcapturelib/README.md | 11 + viewcapturelib/TEST_MAPPING | 15 + viewcapturelib/build.gradle | 42 + .../app/viewcapture/LooperExecutor.java | 59 ++ .../app/viewcapture/NoOpViewCapture.kt | 21 + .../app/viewcapture/PerfettoViewCapture.kt | 335 ++++++++ .../app/viewcapture/SimpleViewCapture.kt | 6 + .../android/app/viewcapture/ViewCapture.java | 777 ++++++++++++++++++ .../ViewCaptureAwareWindowManager.kt | 68 ++ .../ViewCaptureAwareWindowManagerFactory.kt | 63 ++ .../viewcapture/ViewCaptureDataSource.java | 78 ++ .../app/viewcapture/ViewCaptureFactory.kt | 56 ++ .../app/viewcapture/proto/view_capture.proto | 83 ++ viewcapturelib/tests/AndroidManifest.xml | 41 + .../android/app/viewcapture/TestActivity.kt | 45 + .../ViewCaptureAwareWindowManagerTest.kt | 143 ++++ .../app/viewcapture/ViewCaptureTest.kt | 111 +++ 21 files changed, 2067 insertions(+) create mode 100644 viewcapturelib/.gitignore create mode 100644 viewcapturelib/Android.bp create mode 100644 viewcapturelib/AndroidManifest.xml create mode 100644 viewcapturelib/OWNERS create mode 100644 viewcapturelib/README.md create mode 100644 viewcapturelib/TEST_MAPPING create mode 100644 viewcapturelib/build.gradle create mode 100644 viewcapturelib/src/com/android/app/viewcapture/LooperExecutor.java create mode 100644 viewcapturelib/src/com/android/app/viewcapture/NoOpViewCapture.kt create mode 100644 viewcapturelib/src/com/android/app/viewcapture/PerfettoViewCapture.kt create mode 100644 viewcapturelib/src/com/android/app/viewcapture/SimpleViewCapture.kt create mode 100644 viewcapturelib/src/com/android/app/viewcapture/ViewCapture.java create mode 100644 viewcapturelib/src/com/android/app/viewcapture/ViewCaptureAwareWindowManager.kt create mode 100644 viewcapturelib/src/com/android/app/viewcapture/ViewCaptureAwareWindowManagerFactory.kt create mode 100644 viewcapturelib/src/com/android/app/viewcapture/ViewCaptureDataSource.java create mode 100644 viewcapturelib/src/com/android/app/viewcapture/ViewCaptureFactory.kt create mode 100644 viewcapturelib/src/com/android/app/viewcapture/proto/view_capture.proto create mode 100644 viewcapturelib/tests/AndroidManifest.xml create mode 100644 viewcapturelib/tests/com/android/app/viewcapture/TestActivity.kt create mode 100644 viewcapturelib/tests/com/android/app/viewcapture/ViewCaptureAwareWindowManagerTest.kt create mode 100644 viewcapturelib/tests/com/android/app/viewcapture/ViewCaptureTest.kt diff --git a/viewcapturelib/.gitignore b/viewcapturelib/.gitignore new file mode 100644 index 0000000..6213826 --- /dev/null +++ b/viewcapturelib/.gitignore @@ -0,0 +1,13 @@ +*.iml +.project +.classpath +.project.properties +gen/ +bin/ +.idea/ +.gradle/ +local.properties +gradle/ +build/ +gradlew* +.DS_Store diff --git a/viewcapturelib/Android.bp b/viewcapturelib/Android.bp new file mode 100644 index 0000000..899f60e --- /dev/null +++ b/viewcapturelib/Android.bp @@ -0,0 +1,79 @@ +// Copyright (C) 2022 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 { + default_team: "trendy_team_launcher", + default_applicable_licenses: ["Android-Apache-2.0"], +} + +java_library { + name: "view_capture_proto", + srcs: ["src/com/android/app/viewcapture/proto/*.proto"], + proto: { + type: "lite", + local_include_dirs: [ + "src/com/android/app/viewcapture/proto", + ], + }, + static_libs: ["libprotobuf-java-lite"], + java_version: "1.8", +} + +android_library { + name: "view_capture", + manifest: "AndroidManifest.xml", + platform_apis: true, + min_sdk_version: "30", + + static_libs: [ + "androidx.core_core", + "view_capture_proto", + ], + + srcs: [ + "src/**/*.java", + "src/**/*.kt", + ], +} + +android_test { + name: "view_capture_tests", + manifest: "tests/AndroidManifest.xml", + platform_apis: true, + min_sdk_version: "30", + + static_libs: [ + "androidx.core_core", + "view_capture", + "androidx.test.ext.junit", + "androidx.test.rules", + "flag-junit", + "testables", + "mockito-kotlin2", + "mockito-target-extended-minus-junit4", + ], + srcs: [ + "**/*.java", + "**/*.kt", + ], + libs: [ + "android.test.runner.stubs.system", + "android.test.base.stubs.system", + "android.test.mock.stubs.system", + ], + jni_libs: [ + "libdexmakerjvmtiagent", + ], + test_suites: ["device-tests"], +} diff --git a/viewcapturelib/AndroidManifest.xml b/viewcapturelib/AndroidManifest.xml new file mode 100644 index 0000000..d86f1c5 --- /dev/null +++ b/viewcapturelib/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + \ No newline at end of file diff --git a/viewcapturelib/OWNERS b/viewcapturelib/OWNERS new file mode 100644 index 0000000..2f30b7c --- /dev/null +++ b/viewcapturelib/OWNERS @@ -0,0 +1,3 @@ +sunnygoyal@google.com +andonian@google.com +include platform/development:/tools/winscope/OWNERS diff --git a/viewcapturelib/README.md b/viewcapturelib/README.md new file mode 100644 index 0000000..4a6993f --- /dev/null +++ b/viewcapturelib/README.md @@ -0,0 +1,11 @@ +###ViewCapture Library Readme + +ViewCapture.java is extremely performance sensitive. Any changes should be carried out with great caution not to hurt performance. + +The following measurements should serve as a performance baseline (as of 02.10.2022): + + +The onDraw() function invocation time in WindowListener within ViewCapture is measured with System.nanoTime(). The following scenario was measured: + +1. Capturing the notification shade window root view on a freshly rebooted bluejay device (2 notifications present) -> avg. time = 204237ns (0.2ms) + diff --git a/viewcapturelib/TEST_MAPPING b/viewcapturelib/TEST_MAPPING new file mode 100644 index 0000000..ecd3e96 --- /dev/null +++ b/viewcapturelib/TEST_MAPPING @@ -0,0 +1,15 @@ +{ + "presubmit": [ + { + "name": "view_capture_tests", + "options": [ + { + "exclude-annotation": "org.junit.Ignore" + }, + { + "exclude-annotation": "androidx.test.filters.FlakyTest" + } + ] + } + ] +} diff --git a/viewcapturelib/build.gradle b/viewcapturelib/build.gradle new file mode 100644 index 0000000..87b7efd --- /dev/null +++ b/viewcapturelib/build.gradle @@ -0,0 +1,42 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.google.protobuf) +} + +android { + namespace = "com.android.app.viewcapture" + testNamespace = "com.android.app.viewcapture.test" + defaultConfig { + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + sourceSets { + main { + java.srcDirs = ['src'] + manifest.srcFile 'AndroidManifest.xml' + proto.srcDirs = ['src'] + } + androidTest { + java.srcDirs = ["tests"] + manifest.srcFile "tests/AndroidManifest.xml" + } + } + lint { + abortOnError false + } + +} + + +addFrameworkJar('framework-16.jar') +compileOnlyCommonJars() + +dependencies { + implementation libs.androidx.core +// implementation project(":frameworks:libs:systemui:viewcapturelib:view_capture_proto") +// api project(":frameworks:base:core:FrameworkFlags") +// androidTestImplementation project(':SharedTestLib') +// androidTestImplementation 'androidx.test.ext:junit:1.1.3' +// androidTestImplementation "androidx.test:rules:1.4.0" +} diff --git a/viewcapturelib/src/com/android/app/viewcapture/LooperExecutor.java b/viewcapturelib/src/com/android/app/viewcapture/LooperExecutor.java new file mode 100644 index 0000000..e3450f6 --- /dev/null +++ b/viewcapturelib/src/com/android/app/viewcapture/LooperExecutor.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2022 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.app.viewcapture; + +import android.os.Handler; +import android.os.Looper; + +import java.util.concurrent.Callable; +import java.util.concurrent.Executor; +import java.util.concurrent.Future; +import java.util.concurrent.FutureTask; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.RunnableFuture; + +/** + * Implementation of {@link Executor} which executes on a provided looper. + */ +public class LooperExecutor implements Executor { + + private final Handler mHandler; + + public LooperExecutor(Looper looper) { + mHandler = new Handler(looper); + } + + @Override + public void execute(Runnable runnable) { + if (mHandler.getLooper() == Looper.myLooper()) { + runnable.run(); + } else { + mHandler.post(runnable); + } + } + + /** + * @throws RejectedExecutionException {@inheritDoc} + * @throws NullPointerException {@inheritDoc} + */ + public Future submit(Callable task) { + if (task == null) throw new NullPointerException(); + RunnableFuture ftask = new FutureTask(task); + execute(ftask); + return ftask; + } + +} diff --git a/viewcapturelib/src/com/android/app/viewcapture/NoOpViewCapture.kt b/viewcapturelib/src/com/android/app/viewcapture/NoOpViewCapture.kt new file mode 100644 index 0000000..795212d --- /dev/null +++ b/viewcapturelib/src/com/android/app/viewcapture/NoOpViewCapture.kt @@ -0,0 +1,21 @@ +package com.android.app.viewcapture + +import android.media.permission.SafeCloseable +import android.os.HandlerThread +import android.view.View +import android.view.Window + +/** + * We don't want to enable the ViewCapture for release builds, since it currently only serves + * 1p apps, and has memory / cpu load that we don't want to risk negatively impacting release builds + */ +class NoOpViewCapture: ViewCapture(0, 0, + createAndStartNewLooperExecutor("NoOpViewCapture", HandlerThread.MIN_PRIORITY)) { + override fun startCapture(view: View, name: String): SafeCloseable { + return SafeCloseable { } + } + + override fun startCapture(window: Window): SafeCloseable { + return SafeCloseable { } + } +} \ No newline at end of file diff --git a/viewcapturelib/src/com/android/app/viewcapture/PerfettoViewCapture.kt b/viewcapturelib/src/com/android/app/viewcapture/PerfettoViewCapture.kt new file mode 100644 index 0000000..9154e50 --- /dev/null +++ b/viewcapturelib/src/com/android/app/viewcapture/PerfettoViewCapture.kt @@ -0,0 +1,335 @@ +/* + * 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.app.viewcapture + +import android.content.Context +import android.internal.perfetto.protos.InternedDataOuterClass.InternedData +import android.internal.perfetto.protos.ProfileCommon.InternedString +import android.internal.perfetto.protos.TracePacketOuterClass.TracePacket +import android.internal.perfetto.protos.Viewcapture.ViewCapture as ViewCaptureMessage +import android.internal.perfetto.protos.WinscopeExtensionsImplOuterClass.WinscopeExtensionsImpl +import android.os.Trace +import android.tracing.perfetto.DataSourceParams +import android.tracing.perfetto.InitArguments +import android.tracing.perfetto.Producer +import android.util.proto.ProtoOutputStream +import androidx.annotation.WorkerThread +import java.util.concurrent.Executor +import java.util.concurrent.atomic.AtomicInteger + +/** + * ViewCapture that listens to Perfetto events (OnStart, OnStop, OnFlush) and continuously writes + * captured frames to the Perfetto service (traced). + */ +internal class PerfettoViewCapture +internal constructor(private val context: Context, executor: Executor) : + ViewCapture(RING_BUFFER_SIZE, DEFAULT_INIT_POOL_SIZE, executor) { + + private val mDataSource = ViewCaptureDataSource({ onStart() }, {}, { onStop() }) + + private val mActiveSessions = AtomicInteger(0) + + private val mViewIdProvider = ViewIdProvider(context.getResources()) + + private var mSerializationCurrentId: Int = 0 + private var mSerializationCurrentView: ViewPropertyRef? = null + + inner class NewInternedStrings { + val packageNames = mutableListOf() + val windowNames = mutableListOf() + val viewIds = mutableListOf() + val classNames = mutableListOf() + } + + init { + enableOrDisableWindowListeners(false) + + Producer.init(InitArguments.DEFAULTS) + + val dataSourceParams = + DataSourceParams.Builder() + .setBufferExhaustedPolicy( + DataSourceParams.PERFETTO_DS_BUFFER_EXHAUSTED_POLICY_STALL_AND_ABORT + ) + .setNoFlush(true) + .setWillNotifyOnStop(false) + .build() + mDataSource.register(dataSourceParams) + } + + fun onStart() { + if (mActiveSessions.incrementAndGet() == 1) { + enableOrDisableWindowListeners(true) + } + } + + fun onStop() { + if (mActiveSessions.decrementAndGet() == 0) { + enableOrDisableWindowListeners(false) + } + } + + @WorkerThread + override fun onCapturedViewPropertiesBg( + elapsedRealtimeNanos: Long, + windowName: String, + startFlattenedTree: ViewPropertyRef, + ) { + Trace.beginSection("vc#onCapturedViewPropertiesBg") + + mDataSource.trace { ctx -> + val newInternedStrings = NewInternedStrings() + val os = ctx.newTracePacket() + os.write(TracePacket.TIMESTAMP, elapsedRealtimeNanos) + serializeViews( + os, + windowName, + startFlattenedTree, + ctx.incrementalState, + newInternedStrings, + ) + serializeIncrementalState(os, ctx.incrementalState, newInternedStrings) + } + + Trace.endSection() + } + + private fun serializeViews( + os: ProtoOutputStream, + windowName: String, + startFlattenedTree: ViewPropertyRef, + incrementalState: ViewCaptureDataSource.IncrementalState, + newInternedStrings: NewInternedStrings, + ) { + mSerializationCurrentView = startFlattenedTree + mSerializationCurrentId = 0 + + val tokenExtensions = os.start(TracePacket.WINSCOPE_EXTENSIONS) + val tokenViewCapture = os.start(WinscopeExtensionsImpl.VIEWCAPTURE) + os.write( + ViewCaptureMessage.PACKAGE_NAME_IID, + internPackageName(context.packageName, incrementalState, newInternedStrings), + ) + os.write( + ViewCaptureMessage.WINDOW_NAME_IID, + internWindowName(windowName, incrementalState, newInternedStrings), + ) + serializeViewsRec(os, -1, incrementalState, newInternedStrings) + os.end(tokenViewCapture) + os.end(tokenExtensions) + } + + private fun serializeViewsRec( + os: ProtoOutputStream, + parentId: Int, + incrementalState: ViewCaptureDataSource.IncrementalState, + newInternedStrings: NewInternedStrings, + ) { + if (mSerializationCurrentView == null) { + return + } + + val id = mSerializationCurrentId + val childCount = mSerializationCurrentView!!.childCount + + serializeView( + os, + mSerializationCurrentView!!, + mSerializationCurrentId, + parentId, + incrementalState, + newInternedStrings, + ) + + ++mSerializationCurrentId + mSerializationCurrentView = mSerializationCurrentView!!.next + + for (i in 0..childCount - 1) { + serializeViewsRec(os, id, incrementalState, newInternedStrings) + } + } + + private fun serializeView( + os: ProtoOutputStream, + view: ViewPropertyRef, + id: Int, + parentId: Int, + incrementalState: ViewCaptureDataSource.IncrementalState, + newInternedStrings: NewInternedStrings, + ) { + val token = os.start(ViewCaptureMessage.VIEWS) + + os.write(ViewCaptureMessage.View.ID, id) + os.write(ViewCaptureMessage.View.PARENT_ID, parentId) + os.write(ViewCaptureMessage.View.HASHCODE, view.hashCode) + os.write( + ViewCaptureMessage.View.VIEW_ID_IID, + internViewId(mViewIdProvider.getName(view.id), incrementalState, newInternedStrings), + ) + os.write( + ViewCaptureMessage.View.CLASS_NAME_IID, + internClassName(view.clazz.name, incrementalState, newInternedStrings), + ) + + os.write(ViewCaptureMessage.View.LEFT, view.left) + os.write(ViewCaptureMessage.View.TOP, view.top) + os.write(ViewCaptureMessage.View.WIDTH, view.right - view.left) + os.write(ViewCaptureMessage.View.HEIGHT, view.bottom - view.top) + os.write(ViewCaptureMessage.View.SCROLL_X, view.scrollX) + os.write(ViewCaptureMessage.View.SCROLL_Y, view.scrollY) + + os.write(ViewCaptureMessage.View.TRANSLATION_X, view.translateX) + os.write(ViewCaptureMessage.View.TRANSLATION_Y, view.translateY) + os.write(ViewCaptureMessage.View.SCALE_X, view.scaleX) + os.write(ViewCaptureMessage.View.SCALE_Y, view.scaleY) + os.write(ViewCaptureMessage.View.ALPHA, view.alpha) + + os.write(ViewCaptureMessage.View.WILL_NOT_DRAW, view.willNotDraw) + os.write(ViewCaptureMessage.View.CLIP_CHILDREN, view.clipChildren) + os.write(ViewCaptureMessage.View.VISIBILITY, view.visibility) + + os.write(ViewCaptureMessage.View.ELEVATION, view.elevation) + + os.end(token) + } + + private fun internClassName( + string: String, + incrementalState: ViewCaptureDataSource.IncrementalState, + newInternedStrings: NewInternedStrings, + ): Int { + return internString( + string, + incrementalState.mInternMapClassName, + newInternedStrings.classNames, + ) + } + + private fun internPackageName( + string: String, + incrementalState: ViewCaptureDataSource.IncrementalState, + newInternedStrings: NewInternedStrings, + ): Int { + return internString( + string, + incrementalState.mInternMapPackageName, + newInternedStrings.packageNames, + ) + } + + private fun internViewId( + string: String, + incrementalState: ViewCaptureDataSource.IncrementalState, + newInternedStrings: NewInternedStrings, + ): Int { + return internString(string, incrementalState.mInternMapViewId, newInternedStrings.viewIds) + } + + private fun internWindowName( + string: String, + incrementalState: ViewCaptureDataSource.IncrementalState, + newInternedStrings: NewInternedStrings, + ): Int { + return internString( + string, + incrementalState.mInternMapWindowName, + newInternedStrings.windowNames, + ) + } + + private fun internString( + string: String, + internMap: MutableMap, + newInternedStrings: MutableList, + ): Int { + if (internMap.containsKey(string)) { + return internMap[string]!! + } + + // +1 to avoid intern ID = 0, because javastream optimizes out zero values + // and the perfetto trace processor would not de-intern that string. + val internId = internMap.size + 1 + + internMap.put(string, internId) + newInternedStrings.add(string) + return internId + } + + private fun serializeIncrementalState( + os: ProtoOutputStream, + incrementalState: ViewCaptureDataSource.IncrementalState, + newInternedStrings: NewInternedStrings, + ) { + var flags = TracePacket.SEQ_NEEDS_INCREMENTAL_STATE + if (!incrementalState.mHasNotifiedClearedState) { + flags = flags or TracePacket.SEQ_INCREMENTAL_STATE_CLEARED + incrementalState.mHasNotifiedClearedState = true + } + os.write(TracePacket.SEQUENCE_FLAGS, flags) + + val token = os.start(TracePacket.INTERNED_DATA) + serializeInternMap( + os, + InternedData.VIEWCAPTURE_CLASS_NAME, + incrementalState.mInternMapClassName, + newInternedStrings.classNames, + ) + serializeInternMap( + os, + InternedData.VIEWCAPTURE_PACKAGE_NAME, + incrementalState.mInternMapPackageName, + newInternedStrings.packageNames, + ) + serializeInternMap( + os, + InternedData.VIEWCAPTURE_VIEW_ID, + incrementalState.mInternMapViewId, + newInternedStrings.viewIds, + ) + serializeInternMap( + os, + InternedData.VIEWCAPTURE_WINDOW_NAME, + incrementalState.mInternMapWindowName, + newInternedStrings.windowNames, + ) + os.end(token) + } + + private fun serializeInternMap( + os: ProtoOutputStream, + fieldId: Long, + map: Map, + newInternedStrings: List, + ) { + if (newInternedStrings.isEmpty()) { + return + } + + var currentInternId = map.size - newInternedStrings.size + 1 + for (internedString in newInternedStrings) { + val token = os.start(fieldId) + os.write(InternedString.IID, currentInternId++) + os.write(InternedString.STR, internedString.toByteArray()) + os.end(token) + } + } + + companion object { + // Keep two frames in the base class' ring buffer. + // This is the minimum required by the current implementation to work. + private val RING_BUFFER_SIZE = 2 + } +} diff --git a/viewcapturelib/src/com/android/app/viewcapture/SimpleViewCapture.kt b/viewcapturelib/src/com/android/app/viewcapture/SimpleViewCapture.kt new file mode 100644 index 0000000..420faca --- /dev/null +++ b/viewcapturelib/src/com/android/app/viewcapture/SimpleViewCapture.kt @@ -0,0 +1,6 @@ +package com.android.app.viewcapture + +import android.os.Process + +open class SimpleViewCapture(threadName: String) : ViewCapture(DEFAULT_MEMORY_SIZE, DEFAULT_INIT_POOL_SIZE, + createAndStartNewLooperExecutor(threadName, Process.THREAD_PRIORITY_FOREGROUND)) \ No newline at end of file diff --git a/viewcapturelib/src/com/android/app/viewcapture/ViewCapture.java b/viewcapturelib/src/com/android/app/viewcapture/ViewCapture.java new file mode 100644 index 0000000..32bced0 --- /dev/null +++ b/viewcapturelib/src/com/android/app/viewcapture/ViewCapture.java @@ -0,0 +1,777 @@ +/* + * Copyright (C) 2022 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.app.viewcapture; + +import static com.android.app.viewcapture.data.ExportedData.MagicNumber.MAGIC_NUMBER_H; +import static com.android.app.viewcapture.data.ExportedData.MagicNumber.MAGIC_NUMBER_L; + +import android.content.ComponentCallbacks2; +import android.content.Context; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.media.permission.SafeCloseable; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.os.SystemClock; +import android.os.Trace; +import android.text.TextUtils; +import android.util.Log; +import android.util.SparseArray; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.view.Window; + +import androidx.annotation.AnyThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import androidx.annotation.VisibleForTesting; +import androidx.annotation.WorkerThread; + +import com.android.app.viewcapture.data.ExportedData; +import com.android.app.viewcapture.data.FrameData; +import com.android.app.viewcapture.data.MotionWindowData; +import com.android.app.viewcapture.data.ViewNode; +import com.android.app.viewcapture.data.WindowData; + +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +/** + * Utility class for capturing view data every frame + */ +public abstract class ViewCapture { + + private static final String TAG = "ViewCapture"; + + // These flags are copies of two private flags in the View class. + private static final int PFLAG_INVALIDATED = 0x80000000; + private static final int PFLAG_DIRTY_MASK = 0x00200000; + + private static final long MAGIC_NUMBER_FOR_WINSCOPE = + ((long) MAGIC_NUMBER_H.getNumber() << 32) | MAGIC_NUMBER_L.getNumber(); + + // Number of frames to keep in memory + private final int mMemorySize; + + // Number of ViewPropertyRef to preallocate per window + private final int mInitPoolSize; + + protected static final int DEFAULT_MEMORY_SIZE = 2000; + // Initial size of the reference pool. This is at least be 5 * total number of views in + // Launcher. This allows the first free frames avoid object allocation during view capture. + protected static final int DEFAULT_INIT_POOL_SIZE = 300; + + public static final LooperExecutor MAIN_EXECUTOR = new LooperExecutor(Looper.getMainLooper()); + + private final List mListeners = Collections.synchronizedList(new ArrayList<>()); + + protected final Executor mBgExecutor; + + private boolean mIsEnabled = true; + + @VisibleForTesting + public boolean mIsStarted = false; + + protected ViewCapture(int memorySize, int initPoolSize, Executor bgExecutor) { + mMemorySize = memorySize; + mBgExecutor = bgExecutor; + mInitPoolSize = initPoolSize; + } + + public static LooperExecutor createAndStartNewLooperExecutor(String name, int priority) { + HandlerThread thread = new HandlerThread(name, priority); + thread.start(); + return new LooperExecutor(thread.getLooper()); + } + + /** + * Attaches the ViewCapture to the provided window and returns a handle to detach the listener + */ + @AnyThread + @NonNull + public SafeCloseable startCapture(@NonNull Window window) { + String title = window.getAttributes().getTitle().toString(); + String name = TextUtils.isEmpty(title) ? window.toString() : title; + return startCapture(window.getDecorView(), name); + } + + /** + * Attaches the ViewCapture to the provided window and returns a handle to detach the listener. + * Verifies that ViewCapture is enabled before actually attaching an onDrawListener. + */ + @AnyThread + @NonNull + public SafeCloseable startCapture(@NonNull View view, @NonNull String name) { + mIsStarted = true; + WindowListener listener = new WindowListener(view, name); + + if (mIsEnabled) { + listener.attachToRoot(); + } + + mListeners.add(listener); + + view.getContext().registerComponentCallbacks(listener); + + return () -> { + if (listener.mRoot != null && listener.mRoot.getContext() != null) { + listener.mRoot.getContext().unregisterComponentCallbacks(listener); + } + mListeners.remove(listener); + + listener.detachFromRoot(); + }; + } + + /** + * Launcher checks for leaks in many spots during its instrumented tests. The WindowListeners + * appear to have leaks because they store mRoot views. In reality, attached views close their + * respective window listeners when they are destroyed. + *

+ * This method deletes detaches and deletes mRoot views from windowListeners. This makes the + * WindowListeners unusable for anything except dumping previously captured information. They + * are still technically enabled to allow for dumping. + */ + @VisibleForTesting + @AnyThread + public void stopCapture(@NonNull View rootView) { + mIsStarted = false; + mListeners.forEach(it -> { + if (rootView == it.mRoot) { + runOnUiThread(() -> { + if (it.mRoot != null) { + it.mRoot.getViewTreeObserver().removeOnDrawListener(it); + it.mRoot = null; + } + }, it.mRoot); + } + }); + } + + @AnyThread + protected void enableOrDisableWindowListeners(boolean isEnabled) { + mIsEnabled = isEnabled; + mListeners.forEach(WindowListener::detachFromRoot); + if (mIsEnabled) mListeners.forEach(WindowListener::attachToRoot); + } + + @AnyThread + protected void dumpTo(OutputStream os, Context context) + throws InterruptedException, ExecutionException, IOException { + if (mIsEnabled) { + DataOutputStream dataOutputStream = new DataOutputStream(os); + ExportedData ex = getExportedData(context); + dataOutputStream.writeInt(ex.getSerializedSize()); + ex.writeTo(dataOutputStream); + } + } + + @VisibleForTesting + public ExportedData getExportedData(Context context) + throws InterruptedException, ExecutionException { + ArrayList classList = new ArrayList<>(); + return ExportedData.newBuilder() + .setMagicNumber(MAGIC_NUMBER_FOR_WINSCOPE) + .setPackage(context.getPackageName()) + .addAllWindowData(getWindowData(context, classList, l -> l.mIsActive).get()) + .addAllClassname(toStringList(classList)) + .setRealToElapsedTimeOffsetNanos(TimeUnit.MILLISECONDS + .toNanos(System.currentTimeMillis()) - SystemClock.elapsedRealtimeNanos()) + .build(); + } + + private static List toStringList(List classList) { + return classList.stream().map(Class::getName).collect(Collectors.toList()); + } + + public CompletableFuture> getDumpTask(View view) { + ArrayList classList = new ArrayList<>(); + return getWindowData(view.getContext().getApplicationContext(), classList, + l -> l.mRoot.equals(view)).thenApply(list -> list.stream().findFirst().map(w -> + MotionWindowData.newBuilder() + .addAllFrameData(w.getFrameDataList()) + .addAllClassname(toStringList(classList)) + .build())); + } + + @AnyThread + private CompletableFuture> getWindowData(Context context, + ArrayList outClassList, Predicate filter) { + ViewIdProvider idProvider = new ViewIdProvider(context.getResources()); + return CompletableFuture.supplyAsync( + () -> mListeners.stream() + .filter(filter) + .collect(Collectors.toList()), + MAIN_EXECUTOR).thenApplyAsync( + it -> it.stream() + .map(l -> l.dumpToProto(idProvider, outClassList)) + .collect(Collectors.toList()), + mBgExecutor); + } + + @WorkerThread + protected void onCapturedViewPropertiesBg(long elapsedRealtimeNanos, String windowName, + ViewPropertyRef startFlattenedViewTree) { + } + + @AnyThread + void runOnUiThread(Runnable action, View view) { + if (view == null) { + // Corner case. E.g.: the capture is stopped (root view set to null), + // but the bg thread is still processing work. + Log.i(TAG, "Skipping run on UI thread. Provided view == null."); + return; + } + + Handler handlerUi = view.getHandler(); + if (handlerUi != null && handlerUi.getLooper().getThread() == Thread.currentThread()) { + action.run(); + return; + } + + view.post(action); + } + + /** + * Once this window listener is attached to a window's root view, it traverses the entire + * view tree on the main thread every time onDraw is called. It then saves the state of the view + * tree traversed in a local list of nodes, so that this list of nodes can be processed on a + * background thread, and prepared for being dumped into a bugreport. + *

+ * Since some of the work needs to be done on the main thread after every draw, this piece of + * code needs to be hyper optimized. That is why we are recycling ViewPropertyRef objects + * and storing the list of nodes as a flat LinkedList, rather than as a tree. This data + * structure allows recycling to happen in O(1) time via pointer assignment. Without this + * optimization, a lot of time is wasted creating ViewPropertyRef objects, or finding + * ViewPropertyRef objects to recycle. + *

+ * Another optimization is to only traverse view nodes on the main thread that have potentially + * changed since the last frame was drawn. This can be determined via a combination of private + * flags inside the View class. + *

+ * Another optimization is to not store or manipulate any string objects on the main thread. + * While this might seem trivial, using Strings in any form causes the ViewCapture to hog the + * main thread for up to an additional 6-7ms. It must be avoided at all costs. + *

+ * Another optimization is to only store the class names of the Views in the view hierarchy one + * time. They are then referenced via a classNameIndex value stored in each ViewPropertyRef. + *

+ * TODO: b/262585897: If further memory optimization is required, an effective one would be to + * only store the changes between frames, rather than the entire node tree for each frame. + * The go/web-hv UX already does this, and has reaped significant memory improves because of it. + *

+ * TODO: b/262585897: Another memory optimization could be to store all integer, float, and + * boolean information via single integer values via the Chinese remainder theorem, or a similar + * algorithm, which enables multiple numerical values to be stored inside 1 number. Doing this + * would allow each ViewPropertyRef to slim down its memory footprint significantly. + *

+ * One important thing to remember is that bugs related to recycling will usually only appear + * after at least 2000 frames have been rendered. If that code is changed, the tester can + * use hard-coded logs to verify that recycling is happening, and test view capturing at least + * ~8000 frames or so to verify the recycling functionality is working properly. + *

+ * Each WindowListener is memory aware and will both stop collecting view capture information, + * as well as delete their current stash of information upon a signal from the system that + * memory resources are scarce. The user will need to restart the app process before + * more ViewCapture information is captured. + */ + private class WindowListener implements ViewTreeObserver.OnDrawListener, ComponentCallbacks2 { + + @Nullable + public View mRoot; + public final String name; + + // Pool used for capturing view tree on the UI thread. + private ViewPropertyRef mPool = new ViewPropertyRef(); + private final ViewPropertyRef mViewPropertyRef = new ViewPropertyRef(); + + private int mFrameIndexBg = -1; + private boolean mIsFirstFrame = true; + private AtomicReference mFrameTimesNanosBg = + new AtomicReference<>(new long[mMemorySize]); + private AtomicReference mNodesBg = + new AtomicReference<>(new ViewPropertyRef[mMemorySize]); + + private boolean mIsActive = true; + private final Consumer mCaptureCallback = + this::copyCleanViewsFromLastFrameBg; + + WindowListener(View view, String name) { + mRoot = view; + this.name = name; + initPool(mInitPoolSize); + } + + /** + * Every time onDraw is called, it does the minimal set of work required on the main thread, + * i.e. capturing potentially dirty / invalidated views, and then immediately offloads the + * rest of the processing work (extracting the captured view properties) to a background + * thread via mExecutor. + */ + @Override + @UiThread + public void onDraw() { + Trace.beginSection("vc#onDraw"); + try { + View root = mRoot; + if (root == null) { + // Handle the corner case where another (non-UI) thread + // concurrently stopped the capture and set mRoot = null + return; + } + captureViewTree(root, mViewPropertyRef); + ViewPropertyRef captured = mViewPropertyRef.next; + if (captured != null) { + captured.callback = mCaptureCallback; + captured.elapsedRealtimeNanos = SystemClock.elapsedRealtimeNanos(); + mBgExecutor.execute(captured); + } + mIsFirstFrame = false; + } finally { + Trace.endSection(); + } + } + + /** + * Copy clean views from the last frame on the background thread. Clean views are + * the remaining part of the view hierarchy that was not already copied by the UI thread. + * Then transfer the received ViewPropertyRef objects back to the UI thread's pool. + */ + @WorkerThread + private void copyCleanViewsFromLastFrameBg(ViewPropertyRef start) { + Trace.beginSection("vc#copyCleanViewsFromLastFrameBg"); + + // onTrimMemory() might concurrently modify mFrameTimesNanosBg and mNodesBg (set new + // arrays with length = 0). So let's atomically acquire the array references and if any + // of the array lengths is 0, then a memory trim has been performed and this method + // must do nothing. + long[] frameTimesNanosBg = mFrameTimesNanosBg.get(); + ViewPropertyRef[] nodesBg = mNodesBg.get(); + if (frameTimesNanosBg.length == 0 || nodesBg.length == 0) { + Trace.endSection(); + return; + } + + long elapsedRealtimeNanos = start.elapsedRealtimeNanos; + mFrameIndexBg++; + if (mFrameIndexBg >= mMemorySize) { + mFrameIndexBg = 0; + } + frameTimesNanosBg[mFrameIndexBg] = elapsedRealtimeNanos; + + ViewPropertyRef recycle = nodesBg[mFrameIndexBg]; + + ViewPropertyRef resultStart = null; + ViewPropertyRef resultEnd = null; + + ViewPropertyRef end = start; + + while (end != null) { + end.completeTransferFromViewBg(); + + ViewPropertyRef propertyRef = recycle; + if (propertyRef == null) { + propertyRef = new ViewPropertyRef(); + } else { + recycle = recycle.next; + propertyRef.next = null; + } + + ViewPropertyRef copy = null; + if (end.childCount < 0) { + copy = findInLastFrame(nodesBg, end.hashCode); + if (copy != null) { + copy.transferTo(end); + } else { + end.childCount = 0; + } + } + end.transferTo(propertyRef); + + if (resultStart == null) { + resultStart = propertyRef; + resultEnd = resultStart; + } else { + resultEnd.next = propertyRef; + resultEnd = resultEnd.next; + } + + if (copy != null) { + int pending = copy.childCount; + while (pending > 0) { + copy = copy.next; + pending = pending - 1 + copy.childCount; + + propertyRef = recycle; + if (propertyRef == null) { + propertyRef = new ViewPropertyRef(); + } else { + recycle = recycle.next; + propertyRef.next = null; + } + + copy.transferTo(propertyRef); + + resultEnd.next = propertyRef; + resultEnd = resultEnd.next; + } + } + + if (end.next == null) { + // The compiler will complain about using a non-final variable from + // an outer class in a lambda if we pass in 'end' directly. + final ViewPropertyRef finalEnd = end; + runOnUiThread(() -> addToPool(start, finalEnd), mRoot); + break; + } + end = end.next; + } + nodesBg[mFrameIndexBg] = resultStart; + + onCapturedViewPropertiesBg(elapsedRealtimeNanos, name, resultStart); + + Trace.endSection(); + } + + @WorkerThread + private @Nullable ViewPropertyRef findInLastFrame(ViewPropertyRef[] nodesBg, int hashCode) { + int lastFrameIndex = (mFrameIndexBg == 0) ? mMemorySize - 1 : mFrameIndexBg - 1; + ViewPropertyRef viewPropertyRef = nodesBg[lastFrameIndex]; + while (viewPropertyRef != null && viewPropertyRef.hashCode != hashCode) { + viewPropertyRef = viewPropertyRef.next; + } + return viewPropertyRef; + } + + private void initPool(int initPoolSize) { + ViewPropertyRef start = new ViewPropertyRef(); + ViewPropertyRef current = start; + + for (int i = 0; i < initPoolSize; i++) { + current.next = new ViewPropertyRef(); + current = current.next; + } + + ViewPropertyRef finalCurrent = current; + addToPool(start, finalCurrent); + } + + private void addToPool(ViewPropertyRef start, ViewPropertyRef end) { + end.next = mPool; + mPool = start; + } + + @UiThread + private ViewPropertyRef getFromPool() { + ViewPropertyRef ref = mPool; + if (ref != null) { + mPool = ref.next; + ref.next = null; + } else { + ref = new ViewPropertyRef(); + } + return ref; + } + + @AnyThread + void attachToRoot() { + if (mRoot == null) return; + mIsActive = true; + runOnUiThread(() -> { + if (mRoot.isAttachedToWindow()) { + safelyEnableOnDrawListener(); + } else { + mRoot.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() { + @Override + public void onViewAttachedToWindow(View v) { + if (mIsActive) { + safelyEnableOnDrawListener(); + } + mRoot.removeOnAttachStateChangeListener(this); + } + + @Override + public void onViewDetachedFromWindow(View v) { + } + }); + } + }, mRoot); + } + + @AnyThread + void detachFromRoot() { + mIsActive = false; + runOnUiThread(() -> { + if (mRoot != null) { + mRoot.getViewTreeObserver().removeOnDrawListener(this); + } + }, mRoot); + } + + @UiThread + private void safelyEnableOnDrawListener() { + if (mRoot != null) { + mRoot.getViewTreeObserver().removeOnDrawListener(this); + mRoot.getViewTreeObserver().addOnDrawListener(this); + } + } + + @WorkerThread + private WindowData dumpToProto(ViewIdProvider idProvider, ArrayList classList) { + ViewPropertyRef[] nodesBg = mNodesBg.get(); + long[] frameTimesNanosBg = mFrameTimesNanosBg.get(); + + WindowData.Builder builder = WindowData.newBuilder().setTitle(name); + int size = (nodesBg[mMemorySize - 1] == null) ? mFrameIndexBg + 1 : mMemorySize; + for (int i = size - 1; i >= 0; i--) { + int index = (mMemorySize + mFrameIndexBg - i) % mMemorySize; + ViewNode.Builder nodeBuilder = ViewNode.newBuilder(); + nodesBg[index].toProto(idProvider, classList, nodeBuilder); + FrameData.Builder frameDataBuilder = FrameData.newBuilder() + .setNode(nodeBuilder) + .setTimestamp(frameTimesNanosBg[index]); + builder.addFrameData(frameDataBuilder); + } + return builder.build(); + } + + @UiThread + private ViewPropertyRef captureViewTree(View view, ViewPropertyRef start) { + ViewPropertyRef ref = getFromPool(); + start.next = ref; + if (view instanceof ViewGroup) { + ViewGroup parent = (ViewGroup) view; + // If a view has not changed since the last frame, we will copy + // its children from the last processed frame's data. + if ((view.mPrivateFlags & (PFLAG_INVALIDATED | PFLAG_DIRTY_MASK)) == 0 + && !mIsFirstFrame) { + // A negative child count is the signal to copy this view from the last frame. + ref.childCount = -1; + ref.view = view; + return ref; + } + ViewPropertyRef result = ref; + int childCount = ref.childCount = parent.getChildCount(); + ref.transferFrom(view); + for (int i = 0; i < childCount; i++) { + result = captureViewTree(parent.getChildAt(i), result); + } + return result; + } else { + ref.childCount = 0; + ref.transferFrom(view); + return ref; + } + } + + @Override + public void onTrimMemory(int level) { + if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) { + mNodesBg.set(new ViewPropertyRef[0]); + mFrameTimesNanosBg.set(new long[0]); + if (mRoot != null && mRoot.getContext() != null) { + mRoot.getContext().unregisterComponentCallbacks(this); + } + detachFromRoot(); + mRoot = null; + } + } + + @Override + public void onConfigurationChanged(Configuration configuration) { + // No Operation + } + + @Override + public void onLowMemory() { + onTrimMemory(ComponentCallbacks2.TRIM_MEMORY_BACKGROUND); + } + } + + protected static class ViewPropertyRef implements Runnable { + public View view; + + // We store reference in memory to avoid generating and storing too many strings + public Class clazz; + public int hashCode; + + public int id; + public int left, top, right, bottom; + public int scrollX, scrollY; + + public float translateX, translateY; + public float scaleX, scaleY; + public float alpha; + public float elevation; + + public int visibility; + public boolean willNotDraw; + public boolean clipChildren; + public int childCount = 0; + + public ViewPropertyRef next; + + public Consumer callback = null; + public long elapsedRealtimeNanos = 0; + + + public void transferFrom(View in) { + view = in; + + left = in.getLeft(); + top = in.getTop(); + right = in.getRight(); + bottom = in.getBottom(); + scrollX = in.getScrollX(); + scrollY = in.getScrollY(); + + translateX = in.getTranslationX(); + translateY = in.getTranslationY(); + scaleX = in.getScaleX(); + scaleY = in.getScaleY(); + alpha = in.getAlpha(); + elevation = in.getElevation(); + + visibility = in.getVisibility(); + willNotDraw = in.willNotDraw(); + } + + /** + * Transfer in backgroup thread view properties that remain unchanged between frames. + */ + public void completeTransferFromViewBg() { + clazz = view.getClass(); + hashCode = view.hashCode(); + id = view.getId(); + view = null; + } + + public void transferTo(ViewPropertyRef out) { + out.clazz = this.clazz; + out.hashCode = this.hashCode; + out.childCount = this.childCount; + out.id = this.id; + out.left = this.left; + out.top = this.top; + out.right = this.right; + out.bottom = this.bottom; + out.scrollX = this.scrollX; + out.scrollY = this.scrollY; + out.scaleX = this.scaleX; + out.scaleY = this.scaleY; + out.translateX = this.translateX; + out.translateY = this.translateY; + out.alpha = this.alpha; + out.visibility = this.visibility; + out.willNotDraw = this.willNotDraw; + out.clipChildren = this.clipChildren; + out.elevation = this.elevation; + } + + /** + * Converts the data to the proto representation and returns the next property ref + * at the end of the iteration. + */ + public ViewPropertyRef toProto(ViewIdProvider idProvider, ArrayList classList, + ViewNode.Builder viewNode) { + int classnameIndex = classList.indexOf(clazz); + if (classnameIndex < 0) { + classnameIndex = classList.size(); + classList.add(clazz); + } + + viewNode.setClassnameIndex(classnameIndex) + .setHashcode(hashCode) + .setId(idProvider.getName(id)) + .setLeft(left) + .setTop(top) + .setWidth(right - left) + .setHeight(bottom - top) + .setTranslationX(translateX) + .setTranslationY(translateY) + .setScrollX(scrollX) + .setScrollY(scrollY) + .setScaleX(scaleX) + .setScaleY(scaleY) + .setAlpha(alpha) + .setVisibility(visibility) + .setWillNotDraw(willNotDraw) + .setElevation(elevation) + .setClipChildren(clipChildren); + + ViewPropertyRef result = next; + for (int i = 0; (i < childCount) && (result != null); i++) { + ViewNode.Builder childViewNode = ViewNode.newBuilder(); + result = result.toProto(idProvider, classList, childViewNode); + viewNode.addChildren(childViewNode); + } + return result; + } + + @Override + public void run() { + Consumer oldCallback = callback; + callback = null; + if (oldCallback != null) { + oldCallback.accept(this); + } + } + } + + protected static final class ViewIdProvider { + + private final SparseArray mNames = new SparseArray<>(); + private final Resources mRes; + + ViewIdProvider(Resources res) { + mRes = res; + } + + String getName(int id) { + String name = mNames.get(id); + if (name == null) { + if (id >= 0) { + try { + name = mRes.getResourceTypeName(id) + '/' + mRes.getResourceEntryName(id); + } catch (Resources.NotFoundException e) { + name = "id/" + "0x" + Integer.toHexString(id).toUpperCase(); + } + } else { + name = "NO_ID"; + } + mNames.put(id, name); + } + return name; + } + } +} diff --git a/viewcapturelib/src/com/android/app/viewcapture/ViewCaptureAwareWindowManager.kt b/viewcapturelib/src/com/android/app/viewcapture/ViewCaptureAwareWindowManager.kt new file mode 100644 index 0000000..578c89c --- /dev/null +++ b/viewcapturelib/src/com/android/app/viewcapture/ViewCaptureAwareWindowManager.kt @@ -0,0 +1,68 @@ +/* + * 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.app.viewcapture + +import android.content.Context +import android.media.permission.SafeCloseable +import android.view.View +import android.view.ViewGroup +import android.view.Window +import android.view.WindowManager +import android.view.WindowManagerWrapper + +/** + * [WindowManager] implementation to enable view tracing. Adds [ViewCapture] to associated window + * when it is added to view hierarchy. Use [ViewCaptureAwareWindowManagerFactory] to create an + * instance of this class. + */ +internal class ViewCaptureAwareWindowManager( + private val context: Context, + private val base: WindowManager, +) : WindowManagerWrapper(base) { + + private var viewCaptureCloseableMap: MutableMap = mutableMapOf() + + override fun addView(view: View, params: ViewGroup.LayoutParams) { + super.addView(view, params) + val viewCaptureCloseable: SafeCloseable = + ViewCaptureFactory.getInstance(context).startCapture(view, getViewName(view)) + viewCaptureCloseableMap[view] = viewCaptureCloseable + } + + override fun removeView(view: View?) { + removeViewFromCloseableMap(view) + super.removeView(view) + } + + override fun removeViewImmediate(view: View?) { + removeViewFromCloseableMap(view) + super.removeViewImmediate(view) + } + + override fun createLocalWindowManager(parentWindow: Window): WindowManager { + return ViewCaptureAwareWindowManager(context, base.createLocalWindowManager(parentWindow)) + } + + private fun getViewName(view: View) = "." + view.javaClass.name + + private fun removeViewFromCloseableMap(view: View?) { + if (viewCaptureCloseableMap.containsKey(view)) { + viewCaptureCloseableMap[view]?.close() + viewCaptureCloseableMap.remove(view) + } + } +} diff --git a/viewcapturelib/src/com/android/app/viewcapture/ViewCaptureAwareWindowManagerFactory.kt b/viewcapturelib/src/com/android/app/viewcapture/ViewCaptureAwareWindowManagerFactory.kt new file mode 100644 index 0000000..1125d4d --- /dev/null +++ b/viewcapturelib/src/com/android/app/viewcapture/ViewCaptureAwareWindowManagerFactory.kt @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2025 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.app.viewcapture + +import android.content.Context +import android.os.Trace +import android.os.Trace.TRACE_TAG_APP +import android.view.WindowManager +import java.lang.ref.WeakReference +import java.util.Collections +import java.util.WeakHashMap + +/** Factory to create [Context] specific instances of [ViewCaptureAwareWindowManager]. */ +object ViewCaptureAwareWindowManagerFactory { + + /** + * Keeps track of [ViewCaptureAwareWindowManager] instance for a [Context]. It is a + * [WeakHashMap] to ensure that if a [Context] mapped in the [instanceMap] is destroyed, the map + * entry is garbage collected as well. + */ + private val instanceMap = + Collections.synchronizedMap(WeakHashMap>()) + + /** + * Returns the weakly cached [ViewCaptureAwareWindowManager] instance for a given [Context]. If + * no instance is cached; it creates, caches and returns a new instance. + */ + @JvmStatic + fun getInstance(context: Context): WindowManager { + Trace.traceCounter( + TRACE_TAG_APP, + "ViewCaptureAwareWindowManagerFactory#instanceMap.size", + instanceMap.size, + ) + + val cachedWindowManager = instanceMap[context]?.get() + if (cachedWindowManager != null) { + return cachedWindowManager + } else { + val windowManager = + ViewCaptureAwareWindowManager( + context, + context.getSystemService(WindowManager::class.java), + ) + instanceMap[context] = WeakReference(windowManager) + return windowManager + } + } +} diff --git a/viewcapturelib/src/com/android/app/viewcapture/ViewCaptureDataSource.java b/viewcapturelib/src/com/android/app/viewcapture/ViewCaptureDataSource.java new file mode 100644 index 0000000..61a2c03 --- /dev/null +++ b/viewcapturelib/src/com/android/app/viewcapture/ViewCaptureDataSource.java @@ -0,0 +1,78 @@ +/* + * 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.app.viewcapture; + +import android.tracing.perfetto.CreateIncrementalStateArgs; +import android.tracing.perfetto.DataSource; +import android.tracing.perfetto.DataSourceInstance; +import android.tracing.perfetto.FlushCallbackArguments; +import android.tracing.perfetto.StartCallbackArguments; +import android.tracing.perfetto.StopCallbackArguments; +import android.util.proto.ProtoInputStream; + +import java.util.HashMap; +import java.util.Map; + +class ViewCaptureDataSource + extends DataSource { + public static String DATA_SOURCE_NAME = "android.viewcapture"; + + private final Runnable mOnStartStaticCallback; + private final Runnable mOnFlushStaticCallback; + private final Runnable mOnStopStaticCallback; + + ViewCaptureDataSource(Runnable onStart, Runnable onFlush, Runnable onStop) { + super(DATA_SOURCE_NAME); + this.mOnStartStaticCallback = onStart; + this.mOnFlushStaticCallback = onFlush; + this.mOnStopStaticCallback = onStop; + } + + @Override + public IncrementalState createIncrementalState( + CreateIncrementalStateArgs args) { + return new IncrementalState(); + } + + public static class IncrementalState { + public final Map mInternMapPackageName = new HashMap<>(); + public final Map mInternMapWindowName = new HashMap<>(); + public final Map mInternMapViewId = new HashMap<>(); + public final Map mInternMapClassName = new HashMap<>(); + public boolean mHasNotifiedClearedState = false; + } + + @Override + public DataSourceInstance createInstance(ProtoInputStream configStream, int instanceIndex) { + return new DataSourceInstance(this, instanceIndex) { + @Override + protected void onStart(StartCallbackArguments args) { + mOnStartStaticCallback.run(); + } + + @Override + protected void onFlush(FlushCallbackArguments args) { + mOnFlushStaticCallback.run(); + } + + @Override + protected void onStop(StopCallbackArguments args) { + mOnStopStaticCallback.run(); + } + }; + } +} diff --git a/viewcapturelib/src/com/android/app/viewcapture/ViewCaptureFactory.kt b/viewcapturelib/src/com/android/app/viewcapture/ViewCaptureFactory.kt new file mode 100644 index 0000000..b0ec569 --- /dev/null +++ b/viewcapturelib/src/com/android/app/viewcapture/ViewCaptureFactory.kt @@ -0,0 +1,56 @@ +/* + * 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.app.viewcapture + +import android.content.Context +import android.os.Process +import android.util.Log + +/** + * Factory to create polymorphic instances of ViewCapture according to build configurations and + * flags. + */ +object ViewCaptureFactory { + private val TAG = ViewCaptureFactory::class.java.simpleName + private val instance: ViewCapture by lazy { createInstance() } + private lateinit var appContext: Context + + private fun createInstance(): ViewCapture { + return if (!android.os.Build.IS_DEBUGGABLE) { + Log.i(TAG, "instantiating ${NoOpViewCapture::class.java.simpleName}") + NoOpViewCapture() + } else { + Log.i(TAG, "instantiating ${PerfettoViewCapture::class.java.simpleName}") + PerfettoViewCapture( + appContext, + ViewCapture.createAndStartNewLooperExecutor( + "PerfettoViewCapture", + Process.THREAD_PRIORITY_FOREGROUND, + ), + ) + } + } + + /** Returns an instance of [ViewCapture]. */ + @JvmStatic + fun getInstance(context: Context): ViewCapture { + if (!this::appContext.isInitialized) { + synchronized(this) { appContext = context.applicationContext } + } + return instance + } +} diff --git a/viewcapturelib/src/com/android/app/viewcapture/proto/view_capture.proto b/viewcapturelib/src/com/android/app/viewcapture/proto/view_capture.proto new file mode 100644 index 0000000..c73ce8b --- /dev/null +++ b/viewcapturelib/src/com/android/app/viewcapture/proto/view_capture.proto @@ -0,0 +1,83 @@ +/* + * Copyright (C) 2022 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. + */ + +syntax = "proto2"; + +package com.android.app.viewcapture.data; + +option java_multiple_files = true; + +message ExportedData { + /* constant; MAGIC_NUMBER = (long) MAGIC_NUMBER_H << 32 | MagicNumber.MAGIC_NUMBER_L + (this is needed because enums have to be 32 bits and there's no nice way to put 64bit + constants into .proto files. */ + enum MagicNumber { + INVALID = 0; + MAGIC_NUMBER_L = 0x65906578; /* AZAN (ASCII) */ + MAGIC_NUMBER_H = 0x68658273; /* DARI (ASCII) */ + } + + optional fixed64 magic_number = 1; /* Must be the first field, set to value in MagicNumber */ + repeated WindowData windowData = 2; + optional string package = 3; + repeated string classname = 4; + + /* offset between real-time clock and elapsed time clock in nanoseconds. + Calculated as: 1000000 * System.currentTimeMillis() - SystemClock.elapsedRealtimeNanos() */ + optional fixed64 real_to_elapsed_time_offset_nanos = 5; +} + +message WindowData { + repeated FrameData frameData = 1; + optional string title = 2; +} + +message MotionWindowData { + repeated FrameData frameData = 1; + repeated string classname = 2; +} + +message FrameData { + optional int64 timestamp = 1; // unit is elapsed realtime nanos + optional ViewNode node = 2; +} + +message ViewNode { + optional int32 classname_index = 1; + optional int32 hashcode = 2; + + repeated ViewNode children = 3; + + optional string id = 4; + optional int32 left = 5; + optional int32 top = 6; + optional int32 width = 7; + optional int32 height = 8; + optional int32 scrollX = 9; + optional int32 scrollY = 10; + + optional float translationX = 11; + optional float translationY = 12; + optional float scaleX = 13 [default = 1]; + optional float scaleY = 14 [default = 1]; + optional float alpha = 15 [default = 1]; + + optional bool willNotDraw = 16; + optional bool clipChildren = 17; + optional int32 visibility = 18; + + optional float elevation = 19; +} diff --git a/viewcapturelib/tests/AndroidManifest.xml b/viewcapturelib/tests/AndroidManifest.xml new file mode 100644 index 0000000..f32f93c --- /dev/null +++ b/viewcapturelib/tests/AndroidManifest.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + diff --git a/viewcapturelib/tests/com/android/app/viewcapture/TestActivity.kt b/viewcapturelib/tests/com/android/app/viewcapture/TestActivity.kt new file mode 100644 index 0000000..749327e --- /dev/null +++ b/viewcapturelib/tests/com/android/app/viewcapture/TestActivity.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2023 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.app.viewcapture + +import android.app.Activity +import android.os.Bundle +import android.widget.LinearLayout +import android.widget.TextView + +/** + * Activity with the content set to a [LinearLayout] with [TextView] children. + */ +class TestActivity : Activity() { + + companion object { + const val TEXT_VIEW_COUNT = 1000 + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(createContentView()) + } + + private fun createContentView(): LinearLayout { + val root = LinearLayout(this) + for (i in 0 until TEXT_VIEW_COUNT) { + root.addView(TextView(this)) + } + return root + } +} \ No newline at end of file diff --git a/viewcapturelib/tests/com/android/app/viewcapture/ViewCaptureAwareWindowManagerTest.kt b/viewcapturelib/tests/com/android/app/viewcapture/ViewCaptureAwareWindowManagerTest.kt new file mode 100644 index 0000000..378f355 --- /dev/null +++ b/viewcapturelib/tests/com/android/app/viewcapture/ViewCaptureAwareWindowManagerTest.kt @@ -0,0 +1,143 @@ +/* + * 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.app.viewcapture + +import android.content.Context +import android.content.Intent +import android.hardware.display.DisplayManager +import android.platform.test.annotations.EnableFlags +import android.platform.test.flag.junit.SetFlagsRule +import android.testing.AndroidTestingRunner +import android.view.Display.DEFAULT_DISPLAY +import android.view.View +import android.view.WindowManager +import android.view.WindowManager.LayoutParams.TYPE_APPLICATION +import android.view.WindowManager.LayoutParams.TYPE_APPLICATION_ATTACHED_DIALOG +import android.window.WindowContext +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import com.android.window.flags.Flags +import com.google.common.truth.Truth.assertWithMessage +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidTestingRunner::class) +@SmallTest +class ViewCaptureAwareWindowManagerTest { + private val mContext: Context = InstrumentationRegistry.getInstrumentation().context + private lateinit var mViewCaptureAwareWindowManager: ViewCaptureAwareWindowManager + + private val activityIntent = Intent(mContext, TestActivity::class.java) + + @get:Rule val activityScenarioRule = ActivityScenarioRule(activityIntent) + + @get:Rule val mSetFlagsRule: SetFlagsRule = SetFlagsRule() + + @Test + fun testAddView_verifyStartCaptureCall() { + activityScenarioRule.scenario.onActivity { activity -> + mViewCaptureAwareWindowManager = + ViewCaptureAwareWindowManager( + mContext, + mContext.getSystemService(WindowManager::class.java), + ) + + val activityDecorView = activity.window.decorView + // removing view since it is already added to view hierarchy on declaration + mViewCaptureAwareWindowManager.removeView(activityDecorView) + val viewCapture = ViewCaptureFactory.getInstance(mContext) + + mViewCaptureAwareWindowManager.addView( + activityDecorView, + activityDecorView.layoutParams as WindowManager.LayoutParams, + ) + assertTrue(viewCapture.mIsStarted) + } + } + + @EnableFlags(Flags.FLAG_ENABLE_WINDOW_CONTEXT_OVERRIDE_TYPE) + @Test + fun useWithWindowContext_attachWindow_attachToViewCaptureAwareWm() { + val windowContext = + mContext.createWindowContext( + mContext.getSystemService(DisplayManager::class.java).getDisplay(DEFAULT_DISPLAY), + TYPE_APPLICATION, + null, /* options */ + ) as WindowContext + + // Obtain ViewCaptureAwareWindowManager with WindowContext. + mViewCaptureAwareWindowManager = + ViewCaptureAwareWindowManagerFactory.getInstance(windowContext) + as ViewCaptureAwareWindowManager + + // Attach to an Activity so that we can add an application parent window. + val params = WindowManager.LayoutParams() + activityScenarioRule.scenario.onActivity { activity -> + params.token = activity.activityToken + } + + // Create and attach an application window, and listen to OnAttachStateChangeListener. + // We need to know when the parent window is attached and then we can add the attached + // dialog. + val listener = AttachStateListener() + val parentWindow = View(windowContext) + parentWindow.addOnAttachStateChangeListener(listener) + windowContext.attachWindow(parentWindow) + + // Attach the parent window to ViewCaptureAwareWm + activityScenarioRule.scenario.onActivity { + mViewCaptureAwareWindowManager.addView(parentWindow, params) + } + + // Wait for parent window to be attached. + listener.mLatch.await(TIMEOUT_IN_SECONDS, TimeUnit.SECONDS) + assertWithMessage("The WindowContext token must be attached.") + .that(params.mWindowContextToken) + .isEqualTo(windowContext.windowContextToken) + + val subWindow = View(windowContext) + val subParams = WindowManager.LayoutParams(TYPE_APPLICATION_ATTACHED_DIALOG) + + // Attach the sub-window + activityScenarioRule.scenario.onActivity { + mViewCaptureAwareWindowManager.addView(subWindow, subParams) + } + + assertWithMessage("The sub-window must be attached to the parent window") + .that(subParams.token) + .isEqualTo(parentWindow.windowToken) + } + + private class AttachStateListener : View.OnAttachStateChangeListener { + val mLatch: CountDownLatch = CountDownLatch(1) + + override fun onViewAttachedToWindow(v: View) { + mLatch.countDown() + } + + override fun onViewDetachedFromWindow(v: View) {} + } + + companion object { + private const val TIMEOUT_IN_SECONDS = 4L + } +} diff --git a/viewcapturelib/tests/com/android/app/viewcapture/ViewCaptureTest.kt b/viewcapturelib/tests/com/android/app/viewcapture/ViewCaptureTest.kt new file mode 100644 index 0000000..e3272c4 --- /dev/null +++ b/viewcapturelib/tests/com/android/app/viewcapture/ViewCaptureTest.kt @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2022 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.app.viewcapture + +import android.content.Intent +import android.media.permission.SafeCloseable +import android.testing.AndroidTestingRunner +import android.view.View +import android.widget.LinearLayout +import android.widget.TextView +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import com.android.app.viewcapture.TestActivity.Companion.TEXT_VIEW_COUNT +import com.android.app.viewcapture.data.MotionWindowData +import junit.framework.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@SmallTest +@RunWith(AndroidTestingRunner::class) +class ViewCaptureTest { + + private val memorySize = 100 + private val initPoolSize = 15 + private val viewCapture by lazy { + object : + ViewCapture(memorySize, initPoolSize, MAIN_EXECUTOR) {} + } + + private val activityIntent = + Intent(InstrumentationRegistry.getInstrumentation().context, TestActivity::class.java) + + @get:Rule val activityScenarioRule = ActivityScenarioRule(activityIntent) + + @Test + fun testWindowListenerDumpsOneFrameAfterInvalidate() { + activityScenarioRule.scenario.onActivity { activity -> + val closeable = startViewCaptureAndInvalidateNTimes(1, activity) + val rootView = activity.requireViewById(android.R.id.content) + val data = viewCapture.getDumpTask(rootView).get().get() + + assertEquals(1, data.frameDataList.size) + verifyTestActivityViewHierarchy(data) + closeable.close() + } + } + + @Test + fun testWindowListenerDumpsCorrectlyAfterRecyclingStarted() { + activityScenarioRule.scenario.onActivity { activity -> + val closeable = startViewCaptureAndInvalidateNTimes(memorySize + 5, activity) + val rootView = activity.requireViewById(android.R.id.content) + val data = viewCapture.getDumpTask(rootView).get().get() + + // since ViewCapture MEMORY_SIZE is [viewCaptureMemorySize], only + // [viewCaptureMemorySize] frames are exported, although the view is invalidated + // [viewCaptureMemorySize + 5] times + assertEquals(memorySize, data.frameDataList.size) + verifyTestActivityViewHierarchy(data) + closeable.close() + } + } + + private fun startViewCaptureAndInvalidateNTimes(n: Int, activity: TestActivity): SafeCloseable { + val rootView: View = activity.requireViewById(android.R.id.content) + val closeable: SafeCloseable = viewCapture.startCapture(rootView, "rootViewId") + dispatchOnDraw(rootView, times = n) + return closeable + } + + private fun dispatchOnDraw(view: View, times: Int) { + if (times > 0) { + view.viewTreeObserver.dispatchOnDraw() + dispatchOnDraw(view, times - 1) + } + } + + private fun verifyTestActivityViewHierarchy(exportedData: MotionWindowData) { + for (frame in exportedData.frameDataList) { + val testActivityRoot = + frame.node // FrameLayout (android.R.id.content) + .childrenList + .first() // LinearLayout (set by setContentView()) + assertEquals(TEXT_VIEW_COUNT, testActivityRoot.childrenList.size) + assertEquals( + LinearLayout::class.qualifiedName, + exportedData.getClassname(testActivityRoot.classnameIndex) + ) + assertEquals( + TextView::class.qualifiedName, + exportedData.getClassname(testActivityRoot.childrenList.first().classnameIndex) + ) + } + } +} From 17fb63135b2e9508b3e7ef2c71c60c54332c5fb9 Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Sun, 16 Nov 2025 19:25:40 +0700 Subject: [PATCH 09/19] fix: Classes in iconloaderlib --- .../src/com/android/launcher3/icons/BaseIconFactory.java | 2 +- .../src/com/android/launcher3/icons/ClockDrawableWrapper.java | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java b/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java index 17fa5e8..5523ecb 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java +++ b/iconloaderlib/src/com/android/launcher3/icons/BaseIconFactory.java @@ -61,7 +61,7 @@ public class BaseIconFactory implements AutoCloseable { public static final int DEFAULT_WRAPPER_BACKGROUND = Color.WHITE; - private static final float LEGACY_ICON_SCALE = .7f * (1f / (1 + 2 * getExtraInsetFraction())); + public static final float LEGACY_ICON_SCALE = .7f * (1f / (1 + 2 * getExtraInsetFraction())); public static final int MODE_DEFAULT = 0; public static final int MODE_ALPHA = 1; diff --git a/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.java b/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.java index 12a76dd..33fc4ee 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.java +++ b/iconloaderlib/src/com/android/launcher3/icons/ClockDrawableWrapper.java @@ -41,6 +41,8 @@ import android.os.SystemClock; import android.util.Log; +import androidx.annotation.NonNull; +import androidx.core.util.Supplier; import com.android.launcher3.icons.cache.CacheLookupFlag; import com.android.launcher3.icons.mono.ThemedIconDrawable; From 7a21c06e3f8290078854f1e14431b6e6d236b6a8 Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Sun, 16 Nov 2025 20:15:36 +0700 Subject: [PATCH 10/19] feat: Displaylib Android 16 QPR1 --- displaylib/Android.bp | 30 ++ displaylib/AndroidManifest.xml | 20 + displaylib/README.MD | 4 + displaylib/TEST_MAPPING | 7 + .../app/displaylib/DisplayLibComponent.kt | 88 ++++ .../app/displaylib/DisplayRepository.kt | 489 ++++++++++++++++++ .../DisplaysWithDecorationsRepository.kt | 119 +++++ ...DisplaysWithDecorationsRepositoryCompat.kt | 131 +++++ .../displaylib/InstanceLifecycleManager.kt | 37 ++ .../app/displaylib/PerDisplayRepository.kt | 291 +++++++++++ .../fakes/FakePerDisplayRepository.kt | 50 ++ displaylib/tests/Android.bp | 34 ++ displaylib/tests/AndroidManifest.xml | 23 + .../app/displaylib/DisplayRepositoryTest.kt | 31 ++ 14 files changed, 1354 insertions(+) create mode 100644 displaylib/Android.bp create mode 100644 displaylib/AndroidManifest.xml create mode 100644 displaylib/README.MD create mode 100644 displaylib/TEST_MAPPING create mode 100644 displaylib/src/com/android/app/displaylib/DisplayLibComponent.kt create mode 100644 displaylib/src/com/android/app/displaylib/DisplayRepository.kt create mode 100644 displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepository.kt create mode 100644 displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepositoryCompat.kt create mode 100644 displaylib/src/com/android/app/displaylib/InstanceLifecycleManager.kt create mode 100644 displaylib/src/com/android/app/displaylib/PerDisplayRepository.kt create mode 100644 displaylib/src/com/android/app/displaylib/fakes/FakePerDisplayRepository.kt create mode 100644 displaylib/tests/Android.bp create mode 100644 displaylib/tests/AndroidManifest.xml create mode 100644 displaylib/tests/src/com/android/app/displaylib/DisplayRepositoryTest.kt diff --git a/displaylib/Android.bp b/displaylib/Android.bp new file mode 100644 index 0000000..85eefb8 --- /dev/null +++ b/displaylib/Android.bp @@ -0,0 +1,30 @@ +// Copyright (C) 2025 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 { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +java_library { + name: "displaylib", + manifest: "AndroidManifest.xml", + static_libs: [ + "kotlinx_coroutines_android", + "dagger2", + "jsr330", + "//frameworks/libs/systemui:tracinglib-platform", + ], + plugins: ["dagger2-compiler"], + srcs: ["src/**/*.kt"], +} diff --git a/displaylib/AndroidManifest.xml b/displaylib/AndroidManifest.xml new file mode 100644 index 0000000..4f3234b --- /dev/null +++ b/displaylib/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + diff --git a/displaylib/README.MD b/displaylib/README.MD new file mode 100644 index 0000000..2739a46 --- /dev/null +++ b/displaylib/README.MD @@ -0,0 +1,4 @@ +# displaylib + +This library contains utilities that make the management of multiple displays easier, more +performant and elegant. \ No newline at end of file diff --git a/displaylib/TEST_MAPPING b/displaylib/TEST_MAPPING new file mode 100644 index 0000000..31260e9 --- /dev/null +++ b/displaylib/TEST_MAPPING @@ -0,0 +1,7 @@ +{ + "presubmit": [ + { + "name": "displaylib_tests" + } + ] +} diff --git a/displaylib/src/com/android/app/displaylib/DisplayLibComponent.kt b/displaylib/src/com/android/app/displaylib/DisplayLibComponent.kt new file mode 100644 index 0000000..e40c1ca --- /dev/null +++ b/displaylib/src/com/android/app/displaylib/DisplayLibComponent.kt @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2025 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.app.displaylib + +import android.hardware.display.DisplayManager +import android.os.Handler +import android.view.IWindowManager +import dagger.Binds +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope + +/** + * Component that creates all classes in displaylib. + * + * Each user of this library will bind the required element in the factory constructor. It's advised + * to use this component through [createDisplayLibComponent], which wraps the dagger generated + * method. + */ +@Component(modules = [DisplayLibModule::class]) +@Singleton +interface DisplayLibComponent { + + @Component.Factory + interface Factory { + fun create( + @BindsInstance displayManager: DisplayManager, + @BindsInstance windowManager: IWindowManager, + @BindsInstance bgHandler: Handler, + @BindsInstance bgApplicationScope: CoroutineScope, + @BindsInstance backgroundCoroutineDispatcher: CoroutineDispatcher, + ): DisplayLibComponent + } + + val displayRepository: DisplayRepository + val displaysWithDecorationsRepository: DisplaysWithDecorationsRepository + val displaysWithDecorationsRepositoryCompat: DisplaysWithDecorationsRepositoryCompat +} + +@Module +interface DisplayLibModule { + @Binds fun bindDisplayManagerImpl(impl: DisplayRepositoryImpl): DisplayRepository + + @Binds + fun bindDisplaysWithDecorationsRepositoryImpl( + impl: DisplaysWithDecorationsRepositoryImpl + ): DisplaysWithDecorationsRepository +} + +/** + * Just a wrapper to make the generated code to create the component more explicit. + * + * This should be called only once per process. Note that [bgHandler], [bgApplicationScope] and + * [backgroundCoroutineDispatcher] are expected to be backed by background threads. In the future + * this might throw an exception if they are tied to the main thread! + */ +fun createDisplayLibComponent( + displayManager: DisplayManager, + windowManager: IWindowManager, + bgHandler: Handler, + bgApplicationScope: CoroutineScope, + backgroundCoroutineDispatcher: CoroutineDispatcher, +): DisplayLibComponent { + return DaggerDisplayLibComponent.factory() + .create( + displayManager, + windowManager, + bgHandler, + bgApplicationScope, + backgroundCoroutineDispatcher, + ) +} diff --git a/displaylib/src/com/android/app/displaylib/DisplayRepository.kt b/displaylib/src/com/android/app/displaylib/DisplayRepository.kt new file mode 100644 index 0000000..7b43355 --- /dev/null +++ b/displaylib/src/com/android/app/displaylib/DisplayRepository.kt @@ -0,0 +1,489 @@ +/* + * Copyright (C) 2025 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.app.displaylib + +import android.hardware.display.DisplayManager +import android.hardware.display.DisplayManager.DISPLAY_CATEGORY_ALL_INCLUDING_DISABLED +import android.hardware.display.DisplayManager.DisplayListener +import android.hardware.display.DisplayManager.EVENT_TYPE_DISPLAY_ADDED +import android.hardware.display.DisplayManager.EVENT_TYPE_DISPLAY_CHANGED +import android.hardware.display.DisplayManager.EVENT_TYPE_DISPLAY_REMOVED +import android.os.Handler +import android.util.Log +import android.view.Display +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.scan +import kotlinx.coroutines.flow.stateIn + +/** Repository for providing access to display related information and events. */ +interface DisplayRepository { + /** Provides the current set of displays. */ + val displays: StateFlow> + + /** Display change event indicating a change to the given displayId has occurred. */ + val displayChangeEvent: Flow + + /** Display addition event indicating a new display has been added. */ + val displayAdditionEvent: Flow + + /** Display removal event indicating a display has been removed. */ + val displayRemovalEvent: Flow + + /** + * Provides the current set of display ids. + * + * Note that it is preferred to use this instead of [displays] if only the + * [Display.getDisplayId] is needed. + */ + val displayIds: StateFlow> + + /** + * Pending display id that can be enabled/disabled. + * + * When `null`, it means there is no pending display waiting to be enabled. + */ + val pendingDisplay: Flow + + /** Whether the default display is currently off. */ + val defaultDisplayOff: Flow + + /** + * Given a display ID int, return the corresponding Display object, or null if none exist. + * + * This method will not result in a binder call in most cases. The only exception is if there is + * an existing binder call ongoing to get the [Display] instance already. In that case, this + * will wait for the end of the binder call. + */ + fun getDisplay(displayId: Int): Display? + + /** + * As [getDisplay], but it's always guaranteed to not block on any binder call. + * + * This might return null if the display id was not mapped to a [Display] object yet. + */ + fun getCachedDisplay(displayId: Int): Display? = + displays.value.firstOrNull { it.displayId == displayId } + + /** + * Returns whether the given displayId is in the set of enabled displays. + * + * This is guaranteed to not cause a binder call. Use this instead of [getDisplay] (see its docs + * for why) + */ + fun containsDisplay(displayId: Int): Boolean = displayIds.value.contains(displayId) + + /** Represents a connected display that has not been enabled yet. */ + interface PendingDisplay { + /** Id of the pending display. */ + val id: Int + + /** Enables the display, making it available to the system. */ + suspend fun enable() + + /** + * Ignores the pending display. When called, this specific display id doesn't appear as + * pending anymore until the display is disconnected and reconnected again. + */ + suspend fun ignore() + + /** Disables the display, making it unavailable to the system. */ + suspend fun disable() + } +} + +@Singleton +class DisplayRepositoryImpl +@Inject +constructor( + private val displayManager: DisplayManager, + backgroundHandler: Handler, + bgApplicationScope: CoroutineScope, + backgroundCoroutineDispatcher: CoroutineDispatcher, +) : DisplayRepository { + private val allDisplayEvents: Flow = + callbackFlow { + val callback = + object : DisplayListener { + override fun onDisplayAdded(displayId: Int) { + trySend(DisplayEvent.Added(displayId)) + } + + override fun onDisplayRemoved(displayId: Int) { + trySend(DisplayEvent.Removed(displayId)) + } + + override fun onDisplayChanged(displayId: Int) { + trySend(DisplayEvent.Changed(displayId)) + } + } + displayManager.registerDisplayListener( + callback, + backgroundHandler, + EVENT_TYPE_DISPLAY_ADDED or + EVENT_TYPE_DISPLAY_CHANGED or + EVENT_TYPE_DISPLAY_REMOVED, + ) + awaitClose { displayManager.unregisterDisplayListener(callback) } + } + .conflate() + .onStart { emit(DisplayEvent.Changed(Display.DEFAULT_DISPLAY)) } + .debugLog("allDisplayEvents") + .flowOn(backgroundCoroutineDispatcher) + + override val displayChangeEvent: Flow = + allDisplayEvents.filterIsInstance().map { event -> event.displayId } + + override val displayRemovalEvent: Flow = + allDisplayEvents.filterIsInstance().map { it.displayId } + + // This is necessary because there might be multiple displays, and we could + // have missed events for those added before this process or flow started. + // Note it causes a binder call from the main thread (it's traced). + private val initialDisplays: Set = displayManager.displays?.toSet() ?: emptySet() + private val initialDisplayIds = initialDisplays.map { display -> display.displayId }.toSet() + + /** Propagate to the listeners only enabled displays */ + private val enabledDisplayIds: StateFlow> = + allDisplayEvents + .scan(initial = initialDisplayIds) { previousIds: Set, event: DisplayEvent -> + val id = event.displayId + when (event) { + is DisplayEvent.Removed -> previousIds - id + is DisplayEvent.Added, + is DisplayEvent.Changed -> previousIds + id + } + } + .distinctUntilChanged() + .debugLog("enabledDisplayIds") + .stateIn(bgApplicationScope, SharingStarted.WhileSubscribed(), initialDisplayIds) + + private val defaultDisplay by lazy { + getDisplayFromDisplayManager(Display.DEFAULT_DISPLAY) + ?: error("Unable to get default display.") + } + /** + * Represents displays that went though the [DisplayListener.onDisplayAdded] callback. + * + * Those are commonly the ones provided by [DisplayManager.getDisplays] by default. + */ + private val enabledDisplays: StateFlow> = + enabledDisplayIds + .mapElementsLazily { displayId -> getDisplayFromDisplayManager(displayId) } + .onEach { + if (it.isEmpty()) Log.wtf(TAG, "No enabled displays. This should never happen.") + } + .flowOn(backgroundCoroutineDispatcher) + .debugLog("enabledDisplays") + .stateIn( + bgApplicationScope, + started = SharingStarted.WhileSubscribed(), + // This triggers a single binder call on the UI thread per process. The + // alternative would be to use sharedFlows, but they are prohibited due to + // performance concerns. + // Ultimately, this is a trade-off between a one-time UI thread binder call and + // the constant overhead of sharedFlows. + initialValue = initialDisplays, + ) + + /** + * Represents displays that went though the [DisplayListener.onDisplayAdded] callback. + * + * Those are commonly the ones provided by [DisplayManager.getDisplays] by default. + */ + override val displays: StateFlow> = enabledDisplays + + override val displayIds: StateFlow> = enabledDisplayIds + + /** + * Implementation that maps from [displays], instead of [allDisplayEvents] for 2 reasons: + * 1. Guarantee that it emits __after__ [displays] emitted. This way it is guaranteed that + * calling [getDisplay] for the newly added display will be non-null. + * 2. Reuse the existing instance of [Display] without a new call to [DisplayManager]. + */ + override val displayAdditionEvent: Flow = + displays + .pairwiseBy { previousDisplays, currentDisplays -> currentDisplays - previousDisplays } + .flatMapLatest { it.asFlow() } + + val _ignoredDisplayIds = MutableStateFlow>(emptySet()) + private val ignoredDisplayIds: Flow> = _ignoredDisplayIds.debugLog("ignoredDisplayIds") + + private fun getInitialConnectedDisplays(): Set = + displayManager + .getDisplays(DISPLAY_CATEGORY_ALL_INCLUDING_DISABLED) + .map { it.displayId } + .toSet() + .also { + if (DEBUG) { + Log.d(TAG, "getInitialConnectedDisplays: $it") + } + } + + /* keeps connected displays until they are disconnected. */ + private val connectedDisplayIds: StateFlow> = + callbackFlow { + val connectedIds = getInitialConnectedDisplays().toMutableSet() + val callback = + object : DisplayConnectionListener { + override fun onDisplayConnected(id: Int) { + if (DEBUG) { + Log.d(TAG, "display with id=$id connected.") + } + connectedIds += id + _ignoredDisplayIds.value -= id + trySend(connectedIds.toSet()) + } + + override fun onDisplayDisconnected(id: Int) { + connectedIds -= id + if (DEBUG) { + Log.d(TAG, "display with id=$id disconnected.") + } + _ignoredDisplayIds.value -= id + trySend(connectedIds.toSet()) + } + } + trySend(connectedIds.toSet()) + displayManager.registerDisplayListener( + callback, + backgroundHandler, + /* eventFlags */ 0, + DisplayManager.PRIVATE_EVENT_TYPE_DISPLAY_CONNECTION_CHANGED, + ) + awaitClose { displayManager.unregisterDisplayListener(callback) } + } + .conflate() + .distinctUntilChanged() + .debugLog("connectedDisplayIds") + .stateIn( + bgApplicationScope, + started = SharingStarted.WhileSubscribed(), + // The initial value is set to empty, but connected displays are gathered as soon as + // the flow starts being collected. This is to ensure the call to get displays (an + // IPC) happens in the background instead of when this object + // is instantiated. + initialValue = emptySet(), + ) + + private val connectedExternalDisplayIds: Flow> = + connectedDisplayIds + .map { connectedDisplayIds -> + connectedDisplayIds + .filter { id -> getDisplayType(id) == Display.TYPE_EXTERNAL } + .toSet() + } + .flowOn(backgroundCoroutineDispatcher) + .debugLog("connectedExternalDisplayIds") + + private fun getDisplayType(displayId: Int): Int? = displayManager.getDisplay(displayId)?.type + + private fun getDisplayFromDisplayManager(displayId: Int): Display? = displayManager.getDisplay(displayId) + + /** + * Pending displays are the ones connected, but not enabled and not ignored. + * + * A connected display is ignored after the user makes the decision to use it or not. For now, + * the initial decision from the user is final and not reversible. + */ + private val pendingDisplayIds: Flow> = + combine(enabledDisplayIds, connectedExternalDisplayIds, ignoredDisplayIds) { + enabledDisplaysIds, + connectedExternalDisplayIds, + ignoredDisplayIds -> + if (DEBUG) { + Log.d( + TAG, + "combining enabled=$enabledDisplaysIds, " + + "connectedExternalDisplayIds=$connectedExternalDisplayIds, " + + "ignored=$ignoredDisplayIds", + ) + } + connectedExternalDisplayIds - enabledDisplaysIds - ignoredDisplayIds + } + .debugLog("allPendingDisplayIds") + + /** Which display id should be enabled among the pending ones. */ + private val pendingDisplayId: Flow = + pendingDisplayIds.map { it.maxOrNull() }.distinctUntilChanged().debugLog("pendingDisplayId") + + override val pendingDisplay: Flow = + pendingDisplayId + .map { displayId -> + val id = displayId ?: return@map null + object : DisplayRepository.PendingDisplay { + override val id = id + + override suspend fun enable() { + if (DEBUG) { + Log.d(TAG, "Enabling display with id=$id") + } + displayManager.enableConnectedDisplay(id) + // After the display has been enabled, it is automatically ignored. + ignore() + } + + override suspend fun ignore() { + _ignoredDisplayIds.value += id + } + + override suspend fun disable() { + ignore() + if (DEBUG) { + Log.d(TAG, "Disabling display with id=$id") + } + displayManager.disableConnectedDisplay(id) + } + } + } + .debugLog("pendingDisplay") + + override val defaultDisplayOff: Flow = + displayChangeEvent + .filter { it == Display.DEFAULT_DISPLAY } + .map { defaultDisplay.state == Display.STATE_OFF } + .distinctUntilChanged() + + override fun getDisplay(displayId: Int): Display? { + val cachedDisplay = getCachedDisplay(displayId) + if (cachedDisplay != null) return cachedDisplay + // cachedDisplay could be null for 2 reasons: + // 1. the displayId is being mapped to a display in the background, but the binder call is + // not done + // 2. the display is not there + // In case of option one, let's get it synchronously from display manager to make sure for + // this to be consistent. + return if (displayIds.value.contains(displayId)) { + getDisplayFromDisplayManager(displayId) + } else { + null + } + } + + private fun Flow.debugLog(flowName: String): Flow { + return if (DEBUG) { + // LC-Ignored + this + } else { + this + } + } + + /** + * Maps a set of T to a set of V, minimizing the number of `createValue` calls taking into + * account the diff between each root flow emission. + * + * This is needed to minimize the number of [getDisplayFromDisplayManager] in this class. Note + * that if the [createValue] returns a null element, it will not be added in the output set. + */ + private fun Flow>.mapElementsLazily(createValue: (T) -> V?): Flow> { + data class State( + val previousSet: Set, + // Caches T values from the previousSet that were already converted to V + val valueMap: Map, + val resultSet: Set, + ) + + val emptyInitialState = State(emptySet(), emptyMap(), emptySet()) + return this.scan(emptyInitialState) { state, currentSet -> + if (currentSet == state.previousSet) { + state + } else { + val removed = state.previousSet - currentSet + val added = currentSet - state.previousSet + val newMap = state.valueMap.toMutableMap() + + added.forEach { key -> createValue(key)?.let { newMap[key] = it } } + removed.forEach { key -> newMap.remove(key) } + + val resultSet = newMap.values.toSet() + State(currentSet, newMap, resultSet) + } + } + .filter { it != emptyInitialState } + .map { it.resultSet } + } + + private companion object { + const val TAG = "DisplayRepository" + val DEBUG = Log.isLoggable(TAG, Log.DEBUG) + } +} + +/** Used to provide default implementations for all methods. */ +private interface DisplayConnectionListener : DisplayListener { + + override fun onDisplayConnected(id: Int) {} + + override fun onDisplayDisconnected(id: Int) {} + + override fun onDisplayAdded(id: Int) {} + + override fun onDisplayRemoved(id: Int) {} + + override fun onDisplayChanged(id: Int) {} +} + +private sealed interface DisplayEvent { + val displayId: Int + + data class Added(override val displayId: Int) : DisplayEvent + + data class Removed(override val displayId: Int) : DisplayEvent + + data class Changed(override val displayId: Int) : DisplayEvent +} + +/** + * Returns a new [Flow] that combines the two most recent emissions from [this] using [transform]. + * Note that the new Flow will not start emitting until it has received two emissions from the + * upstream Flow. + * + * Useful for code that needs to compare the current value to the previous value. + * + * Note this has been taken from com.android.systemui.util.kotlin. It was copied to keep deps of + * displaylib minimal (and avoid creating a new shared lib for it). + */ +fun Flow.pairwiseBy(transform: suspend (old: T, new: T) -> R): Flow = flow { + val noVal = Any() + var previousValue: Any? = noVal + collect { newVal -> + if (previousValue != noVal) { + @Suppress("UNCHECKED_CAST") emit(transform(previousValue as T, newVal)) + } + previousValue = newVal + } +} diff --git a/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepository.kt b/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepository.kt new file mode 100644 index 0000000..b184bd9 --- /dev/null +++ b/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepository.kt @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2025 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.app.displaylib + +import android.content.res.Configuration +import android.graphics.Rect +import android.view.IDisplayWindowListener +import android.view.IWindowManager +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.flow.scan +import kotlinx.coroutines.flow.stateIn + +/** Provides the displays with decorations. */ +interface DisplaysWithDecorationsRepository { + /** A [StateFlow] that maintains a set of display IDs that should have system decorations. */ + val displayIdsWithSystemDecorations: StateFlow> +} + +@Singleton +class DisplaysWithDecorationsRepositoryImpl +@Inject +constructor( + private val windowManager: IWindowManager, + bgApplicationScope: CoroutineScope, + displayRepository: DisplayRepository, +) : DisplaysWithDecorationsRepository { + + private val decorationEvents: Flow = callbackFlow { + val callback = + object : IDisplayWindowListener.Stub() { + override fun onDisplayAddSystemDecorations(displayId: Int) { + trySend(Event.Add(displayId)) + } + + override fun onDisplayRemoveSystemDecorations(displayId: Int) { + trySend(Event.Remove(displayId)) + } + + override fun onDesktopModeEligibleChanged(displayId: Int) {} + + override fun onDisplayAdded(p0: Int) {} + + override fun onDisplayConfigurationChanged(p0: Int, p1: Configuration?) {} + + override fun onDisplayRemoved(p0: Int) {} + + override fun onFixedRotationStarted(p0: Int, p1: Int) {} + + override fun onFixedRotationFinished(p0: Int) {} + + override fun onKeepClearAreasChanged( + p0: Int, + p1: MutableList?, + p2: MutableList?, + ) {} + } + windowManager.registerDisplayWindowListener(callback) + awaitClose { windowManager.unregisterDisplayWindowListener(callback) } + } + + private val initialDisplayIdsWithDecorations: Set = + displayRepository.displayIds.value + .filter { windowManager.shouldShowSystemDecors(it) } + .toSet() + + /** + * A [StateFlow] that maintains a set of display IDs that should have system decorations. + * + * Updates to the set are triggered by: + * - Removing displays via [displayRemovalEvent] emissions. + * + * The set is initialized with displays that qualify for system decorations based on + * [WindowManager.shouldShowSystemDecors]. + */ + override val displayIdsWithSystemDecorations: StateFlow> = + merge(decorationEvents, displayRepository.displayRemovalEvent.map { Event.Remove(it) }) + .scan(initialDisplayIdsWithDecorations) { displayIds: Set, event: Event -> + when (event) { + is Event.Add -> displayIds + event.displayId + is Event.Remove -> displayIds - event.displayId + } + } + .distinctUntilChanged() + .stateIn( + scope = bgApplicationScope, + started = SharingStarted.WhileSubscribed(), + initialValue = initialDisplayIdsWithDecorations, + ) + + private sealed class Event(val displayId: Int) { + class Add(displayId: Int) : Event(displayId) + + class Remove(displayId: Int) : Event(displayId) + } +} diff --git a/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepositoryCompat.kt b/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepositoryCompat.kt new file mode 100644 index 0000000..66aa7cc --- /dev/null +++ b/displaylib/src/com/android/app/displaylib/DisplaysWithDecorationsRepositoryCompat.kt @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2025 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.app.displaylib + +import com.android.internal.annotations.GuardedBy +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +/** Listener for display system decorations changes. */ +interface DisplayDecorationListener { + /** Called when system decorations should be added to the display.* */ + fun onDisplayAddSystemDecorations(displayId: Int) + + /** Called when a display is removed. */ + fun onDisplayRemoved(displayId: Int) + + /** Called when system decorations should be removed from the display. */ + fun onDisplayRemoveSystemDecorations(displayId: Int) +} + +/** + * This class is a compatibility layer that allows to register and unregister listeners for display + * decorations changes. It uses a [DisplaysWithDecorationsRepository] to get the current list of + * displays with decorations and notifies the listeners when the list changes. + */ +@Singleton +class DisplaysWithDecorationsRepositoryCompat +@Inject +constructor( + private val bgApplicationScope: CoroutineScope, + private val displayRepository: DisplaysWithDecorationsRepository, +) { + private val mutex = Mutex() + private var collectorJob: Job? = null + private val displayDecorationListenersWithDispatcher = + ConcurrentHashMap() + + /** + * Registers a [DisplayDecorationListener] to be notified when the list of displays with + * decorations changes. + * + * @param listener The listener to register. + * @param dispatcher The dispatcher to use when notifying the listener. + */ + fun registerDisplayDecorationListener( + listener: DisplayDecorationListener, + dispatcher: CoroutineDispatcher, + ) { + var initialDisplayIdsForListener: Set = emptySet() + bgApplicationScope.launch { + mutex.withLock { + displayDecorationListenersWithDispatcher[listener] = dispatcher + initialDisplayIdsForListener = + displayRepository.displayIdsWithSystemDecorations.value + startCollectingIfNeeded(initialDisplayIdsForListener) + } + // Emit all the existing displays with decorations when registering. + initialDisplayIdsForListener.forEach { displayId -> + withContext(dispatcher) { listener.onDisplayAddSystemDecorations(displayId) } + } + } + } + + /** + * Unregisters a [DisplayDecorationListener]. + * + * @param listener The listener to unregister. + */ + fun unregisterDisplayDecorationListener(listener: DisplayDecorationListener) { + bgApplicationScope.launch { + mutex.withLock { + displayDecorationListenersWithDispatcher.remove(listener) + // stop collecting if no listeners + if (displayDecorationListenersWithDispatcher.isEmpty()) { + collectorJob?.cancel() + collectorJob = null + } + } + } + } + + @GuardedBy("mutex") + private fun startCollectingIfNeeded(lastDisplaysWithDecorations: Set) { + if (collectorJob?.isActive == true) { + return + } + var oldDisplays: Set = lastDisplaysWithDecorations + collectorJob = + bgApplicationScope.launch { + displayRepository.displayIdsWithSystemDecorations.collect { currentDisplays -> + val previous = oldDisplays + oldDisplays = currentDisplays + + val newDisplaysWithDecorations = currentDisplays - previous + val removedDisplays = previous - currentDisplays + displayDecorationListenersWithDispatcher.forEach { (listener, dispatcher) -> + withContext(dispatcher) { + newDisplaysWithDecorations.forEach { displayId -> + listener.onDisplayAddSystemDecorations(displayId) + } + removedDisplays.forEach { displayId -> + listener.onDisplayRemoveSystemDecorations(displayId) + } + } + } + } + } + } +} diff --git a/displaylib/src/com/android/app/displaylib/InstanceLifecycleManager.kt b/displaylib/src/com/android/app/displaylib/InstanceLifecycleManager.kt new file mode 100644 index 0000000..c80315b --- /dev/null +++ b/displaylib/src/com/android/app/displaylib/InstanceLifecycleManager.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2025 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.app.displaylib + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Reports the display ids that should have a per-display instance, if any. + * + * This can be overridden to support different policies (e.g. display being connected, display + * having decorations, etc..). A [PerDisplayRepository] instance is expected to be cleaned up when a + * displayId is removed from this set. + */ +interface DisplayInstanceLifecycleManager { + /** Set of display ids that are allowed to have an instance. */ + val displayIds: StateFlow> +} + +/** Meant to be used in tests. */ +class FakeDisplayInstanceLifecycleManager : DisplayInstanceLifecycleManager { + override val displayIds = MutableStateFlow>(emptySet()) +} diff --git a/displaylib/src/com/android/app/displaylib/PerDisplayRepository.kt b/displaylib/src/com/android/app/displaylib/PerDisplayRepository.kt new file mode 100644 index 0000000..74bc572 --- /dev/null +++ b/displaylib/src/com/android/app/displaylib/PerDisplayRepository.kt @@ -0,0 +1,291 @@ +/* + * Copyright (C) 2025 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.app.displaylib + +import android.util.Log +import android.view.Display +import android.view.Display.DEFAULT_DISPLAY +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import java.util.concurrent.ConcurrentHashMap +import java.util.function.Consumer +import javax.inject.Qualifier +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +/** + * Used to create instances of type `T` for a specific display. + * + * This is useful for resources or objects that need to be managed independently for each connected + * display (e.g., UI state, rendering contexts, or display-specific configurations). + * + * Note that in most cases this can be implemented by a simple `@AssistedFactory` with `displayId` + * parameter + * + * ```kotlin + * class SomeType @AssistedInject constructor(@Assisted displayId: Int,..) + * @AssistedFactory + * interface Factory { + * fun create(displayId: Int): SomeType + * } + * } + * ``` + * + * Then it can be used to create a [PerDisplayRepository] as follows: + * ```kotlin + * // Injected: + * val repositoryFactory: PerDisplayRepositoryImpl.Factory + * val instanceFactory: PerDisplayRepositoryImpl.Factory + * // repository creation: + * repositoryFactory.create(instanceFactory::create) + * ``` + * + * @see PerDisplayRepository For how to retrieve and manage instances created by this factory. + */ +fun interface PerDisplayInstanceProvider { + /** Creates an instance for a display. */ + fun createInstance(displayId: Int): T? +} + +/** + * Extends [PerDisplayInstanceProvider], adding support for destroying the instance. + * + * This is useful for releasing resources associated with a display when it is disconnected or when + * the per-display instance is no longer needed. + */ +interface PerDisplayInstanceProviderWithTeardown : PerDisplayInstanceProvider { + /** Destroys a previously created instance of `T` forever. */ + fun destroyInstance(instance: T) +} + +/** + * Provides access to per-display instances of type `T`. + * + * Acts as a repository, managing the caching and retrieval of instances created by a + * [PerDisplayInstanceProvider]. It ensures that only one instance of `T` exists per display ID. + */ +interface PerDisplayRepository { + /** Gets the cached instance or create a new one for a given display. */ + operator fun get(displayId: Int): T? + + /** Debug name for this repository, mainly for tracing and logging. */ + val debugName: String + + /** + * Callback to run when a given repository is initialized. + * + * This allows the caller to perform custom logic when the repository is ready to be used, e.g. + * register to dumpManager. + * + * Note that the instance is *leaked* outside of this class, so it should only be done when + * repository is meant to live as long as the caller. In systemUI this is ok because the + * repository lives as long as the process itself. + */ + fun interface InitCallback { + fun onInit(debugName: String, instance: Any) + } + + /** + * Iterate over all the available displays performing the action on each object of type T. + * + * @param createIfAbsent If true, create instances of T if they are not already created. If + * false, do not and skip calling action.. + * @param action The action to perform on each instance. + */ + fun forEach(createIfAbsent: Boolean, action: Consumer) +} + +/** Qualifier for [CoroutineScope] used for displaylib background tasks. */ +@Qualifier @Retention(AnnotationRetention.RUNTIME) annotation class DisplayLibBackground + +/** + * Default implementation of [PerDisplayRepository]. + * + * This class manages a cache of per-display instances of type `T`, creating them using a provided + * [PerDisplayInstanceProvider] and optionally tearing them down using a + * [PerDisplayInstanceProviderWithTeardown] when based on [lifecycleManager]. + * + * An instance will be destroyed when either + * - The display is not connected anymore + * - or based on [lifecycleManager]. If no lifecycle manager is provided, instances are destroyed + * when the display is disconnected. + * + * [DisplayInstanceLifecycleManager] can decide to delete instances for a display even before it is + * disconnected. An example of usecase for it, is to delete instances when screen decorations are + * removed. + * + * Note that this is a [PerDisplayStoreImpl] 2.0 that doesn't require [CoreStartable] bindings, + * providing all args in the constructor. + */ +class PerDisplayInstanceRepositoryImpl +@AssistedInject +constructor( + @Assisted override val debugName: String, + @Assisted private val instanceProvider: PerDisplayInstanceProvider, + @Assisted lifecycleManager: DisplayInstanceLifecycleManager? = null, + @DisplayLibBackground bgApplicationScope: CoroutineScope, + private val displayRepository: DisplayRepository, + private val initCallback: PerDisplayRepository.InitCallback, +) : PerDisplayRepository { + + private val perDisplayInstances = ConcurrentHashMap() + + private val allowedDisplays: StateFlow> = + (if (lifecycleManager == null) { + displayRepository.displayIds + } else { + // If there is a lifecycle manager, we still consider the smallest subset between + // the ones connected and the ones from the lifecycle. This is to safeguard against + // leaks, in case of lifecycle manager misbehaving (as it's provided by clients, and + // we can't guarantee it's correct). + combine(lifecycleManager.displayIds, displayRepository.displayIds) { + lifecycleAllowedDisplayIds, + connectedDisplays -> + lifecycleAllowedDisplayIds.intersect(connectedDisplays) + } + }) as StateFlow> + + init { + bgApplicationScope.launch { start() } + } + + private suspend fun start() { + initCallback.onInit(debugName, this) + allowedDisplays.collectLatest { displayIds -> + val toRemove = perDisplayInstances.keys - displayIds + toRemove.forEach { displayId -> + Log.d(TAG, "<$debugName> destroying instance for displayId=$displayId.") + perDisplayInstances.remove(displayId)?.let { instance -> + (instanceProvider as? PerDisplayInstanceProviderWithTeardown)?.destroyInstance( + instance + ) + } + } + } + } + + override fun get(displayId: Int): T? { + if (!displayRepository.containsDisplay(displayId)) { + Log.e(TAG, "<$debugName: Display with id $displayId doesn't exist.") + return null + } + + if (displayId !in allowedDisplays.value) { + Log.e( + TAG, + "<$debugName: Display with id $displayId exists but it's not " + + "allowed by lifecycle manager.", + ) + return null + } + + // If it doesn't exist, create it and put it in the map. + return perDisplayInstances.computeIfAbsent(displayId) { key -> + Log.d(TAG, "<$debugName> creating instance for displayId=$key, as it wasn't available.") + val instance = instanceProvider.createInstance(key) + if (instance == null) { + Log.e( + TAG, + "<$debugName> returning null because createInstance($key) returned null.", + ) + } + instance + } + } + + @AssistedFactory + interface Factory { + fun create( + debugName: String, + instanceProvider: PerDisplayInstanceProvider, + overrideLifecycleManager: DisplayInstanceLifecycleManager? = null, + ): PerDisplayInstanceRepositoryImpl + } + + companion object { + private const val TAG = "PerDisplayInstanceRepo" + } + + override fun toString(): String { + return "PerDisplayInstanceRepositoryImpl(" + + "debugName='$debugName', instances=$perDisplayInstances)" + } + + override fun forEach(createIfAbsent: Boolean, action: Consumer) { + if (createIfAbsent) { + allowedDisplays.value.forEach { displayId -> get(displayId)?.let { action.accept(it) } } + } else { + perDisplayInstances.forEach { (_, instance) -> instance?.let { action.accept(it) } } + } + } +} + +/** + * Provides an instance of a given class **only** for the default display, even if asked for another + * display. + * + * This is useful in case of **flag refactors**: it can be provided instead of an instance of + * [PerDisplayInstanceRepositoryImpl] when a flag related to multi display refactoring is off. + * + * Note that this still requires all instances to be provided by a [PerDisplayInstanceProvider]. If + * you want to provide an existing instance instead for the default display, either implement it in + * a custom [PerDisplayInstanceProvider] (e.g. inject it in the constructor and return it if the + * displayId is zero), or use [SingleInstanceRepositoryImpl]. + */ +class DefaultDisplayOnlyInstanceRepositoryImpl( + override val debugName: String, + private val instanceProvider: PerDisplayInstanceProvider, +) : PerDisplayRepository { + private val lazyDefaultDisplayInstanceDelegate = lazy { + instanceProvider.createInstance(Display.DEFAULT_DISPLAY) + } + private val lazyDefaultDisplayInstance by lazyDefaultDisplayInstanceDelegate + + override fun get(displayId: Int): T? = lazyDefaultDisplayInstance + + override fun forEach(createIfAbsent: Boolean, action: Consumer) { + if (createIfAbsent) { + get(DEFAULT_DISPLAY)?.let { action.accept(it) } + } else { + if (lazyDefaultDisplayInstanceDelegate.isInitialized()) { + lazyDefaultDisplayInstance?.let { action.accept(it) } + } + } + } +} + +/** + * Always returns [instance] for any display. + * + * This can be used to provide a single instance based on a flag value during a refactor. Similar to + * [DefaultDisplayOnlyInstanceRepositoryImpl], but also avoids creating the + * [PerDisplayInstanceProvider]. This is useful when you want to provide an existing instance only, + * without even instantiating a [PerDisplayInstanceProvider]. + */ +class SingleInstanceRepositoryImpl(override val debugName: String, private val instance: T) : + PerDisplayRepository { + override fun get(displayId: Int): T? = instance + + override fun forEach(createIfAbsent: Boolean, action: Consumer) { + action.accept(instance) + } +} diff --git a/displaylib/src/com/android/app/displaylib/fakes/FakePerDisplayRepository.kt b/displaylib/src/com/android/app/displaylib/fakes/FakePerDisplayRepository.kt new file mode 100644 index 0000000..c832462 --- /dev/null +++ b/displaylib/src/com/android/app/displaylib/fakes/FakePerDisplayRepository.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2025 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.app.displaylib.fakes + +import com.android.app.displaylib.PerDisplayRepository +import java.util.function.Consumer + +/** Fake version of [PerDisplayRepository], to be used in tests. */ +class FakePerDisplayRepository(private val defaultIfAbsent: ((Int) -> T)? = null) : + PerDisplayRepository { + + private val instances = mutableMapOf() + + fun add(displayId: Int, instance: T) { + instances[displayId] = instance + } + + fun remove(displayId: Int) { + instances.remove(displayId) + } + + override fun get(displayId: Int): T? { + return if (defaultIfAbsent != null) { + instances.getOrPut(displayId) { defaultIfAbsent(displayId) } + } else { + instances[displayId] + } + } + + override val debugName: String + get() = "FakePerDisplayRepository" + + override fun forEach(createIfAbsent: Boolean, action: Consumer) { + instances.forEach { (_, t) -> action.accept(t) } + } +} diff --git a/displaylib/tests/Android.bp b/displaylib/tests/Android.bp new file mode 100644 index 0000000..2c7d115 --- /dev/null +++ b/displaylib/tests/Android.bp @@ -0,0 +1,34 @@ +// Copyright (C) 2025 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 { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +android_test { + name: "displaylib_tests", + manifest: "AndroidManifest.xml", + static_libs: [ + "displaylib", + "androidx.test.ext.junit", + "androidx.test.rules", + "truth", + "//frameworks/libs/systemui:tracinglib-platform", + ], + srcs: [ + "tests/src/**/*.kt", + ], + kotlincflags: ["-Xjvm-default=all"], + test_suites: ["device-tests"], +} diff --git a/displaylib/tests/AndroidManifest.xml b/displaylib/tests/AndroidManifest.xml new file mode 100644 index 0000000..b45a4ec --- /dev/null +++ b/displaylib/tests/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + diff --git a/displaylib/tests/src/com/android/app/displaylib/DisplayRepositoryTest.kt b/displaylib/tests/src/com/android/app/displaylib/DisplayRepositoryTest.kt new file mode 100644 index 0000000..81a26cb --- /dev/null +++ b/displaylib/tests/src/com/android/app/displaylib/DisplayRepositoryTest.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2025 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.app.displaylib + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import org.junit.runner.RunWith + +/** + * Tests for display repository are in SystemUI: + * frameworks/base/packages/SystemUI/multivalentTestsForDevice/src/com/android/systemui/display/data/repository/DisplayRepositoryTest.kt + * + * This is because the repository was initially there, and tests depend on kosmos for dependency + * injection (which is sysui-specific). + * + * In case of changes, update tests in sysui. + */ +@SmallTest @RunWith(AndroidJUnit4::class) class DisplayRepositoryTest From 8721978d35e71f631199f961ff79ddcc275cfdb3 Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Sun, 16 Nov 2025 20:15:50 +0700 Subject: [PATCH 11/19] feat: Displaylib Android 16 QPR1 --- README.md | 1 + displaylib/build.gradle | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 displaylib/build.gradle diff --git a/README.md b/README.md index 00535d5..47ceb44 100644 --- a/README.md +++ b/README.md @@ -5,5 +5,6 @@ This repository contains multiple libraries in SystemUI used by Lawnchair. A brief explanation of what each library does: * `animationlib`: Handling playback and interpolator of the animations * `contextualeducationlib`: Store "education" type +* `displaylib`: Handling presumably desktop displays * `iconloaderlib`: Handling all of Launcher3 and Lawnchair icons * `msdllib`: Multi-Sensory-Design-Language, handling all new vibrations in Launcher3 Android 16 diff --git a/displaylib/build.gradle b/displaylib/build.gradle new file mode 100644 index 0000000..1135b1e --- /dev/null +++ b/displaylib/build.gradle @@ -0,0 +1,28 @@ +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' + alias(libs.plugins.google.ksp) +} + +android { + namespace "com.android.app.displaylib" + sourceSets { + main { + java.srcDirs = ['src'] + } + } +} + +addFrameworkJar('framework-16.jar') + +dependencies { + implementation libs.javax.inject + implementation libs.kotlinx.coroutines.android + ksp libs.dagger.compiler + implementation libs.dagger.hilt.android + ksp libs.dagger.hilt.compiler +} + +ksp { + arg("dagger.hilt.disableModulesHaveInstallInCheck", "true") +} From 012c074584fef2cdd2816bfa9c7f1c4ca3e99410 Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Tue, 18 Nov 2025 21:11:56 +0700 Subject: [PATCH 12/19] feat: Mechanics --- mechanics/Android.bp | 35 + mechanics/AndroidManifest.xml | 19 + mechanics/TEST_MAPPING | 38 + mechanics/benchmark/AndroidManifest.xml | 15 + .../benchmark/benchmark-proguard-rules.pro | 37 + .../benchmark/ComposeBaselineBenchmark.kt | 105 + .../mechanics/benchmark/ComposeStateTest.kt | 168 ++ .../benchmark/MotionValueBenchmark.kt | 260 ++ mechanics/build.gradle | 50 + mechanics/compose/Android.bp | 33 + mechanics/compose/AndroidManifest.xml | 20 + .../VerticalFadeContentRevealModifier.kt | 229 ++ .../VerticalTactileSurfaceRevealModifier.kt | 250 ++ mechanics/compose/tests/AndroidManifest.xml | 28 + .../com/android/mechanics/GestureContext.kt | 205 ++ .../src/com/android/mechanics/MotionValue.kt | 466 ++++ .../VerticalExpandContainerBackground.kt | 196 ++ .../behavior/VerticalExpandContainerSpec.kt | 161 ++ .../android/mechanics/debug/DebugInspector.kt | 90 + .../mechanics/debug/DebugVisualization.kt | 510 ++++ .../mechanics/debug/MotionValueDebugger.kt | 130 + .../com/android/mechanics/effects/Fixed.kt | 55 + .../mechanics/effects/MagneticDetach.kt | 259 ++ .../com/android/mechanics/effects/Overdrag.kt | 69 + .../mechanics/effects/RevealOnThreshold.kt | 56 + .../mechanics/impl/ComputationInput.kt | 101 + .../android/mechanics/impl/Computations.kt | 576 +++++ .../mechanics/impl/DiscontinuityAnimation.kt | 44 + .../android/mechanics/impl/GuaranteeState.kt | 77 + .../mechanics/impl/SegmentChangeType.kt | 67 + .../com/android/mechanics/spec/Breakpoint.kt | 120 + .../com/android/mechanics/spec/Guarantee.kt | 45 + .../android/mechanics/spec/InputDirection.kt | 31 + .../com/android/mechanics/spec/MotionSpec.kt | 239 ++ .../spec/MotionSpecDebugFormatter.kt | 121 + .../src/com/android/mechanics/spec/Segment.kt | 155 ++ .../mechanics/spec/SegmentChangeHandler.kt | 48 + .../android/mechanics/spec/SemanticValue.kt | 74 + .../spec/builder/DirectionalBuilderImpl.kt | 388 +++ .../spec/builder/DirectionalBuilderScope.kt | 273 +++ .../spec/builder/DirectionalSpecBuilder.kt | 128 + .../android/mechanics/spec/builder/Effect.kt | 68 + .../spec/builder/EffectApplyScope.kt | 182 ++ .../mechanics/spec/builder/EffectPlacement.kt | 111 + .../spec/builder/MotionBuilderContext.kt | 104 + .../spec/builder/MotionSpecBuilder.kt | 162 ++ .../spec/builder/MotionSpecBuilderImpl.kt | 589 +++++ .../spring/MaterialSpringParameters.kt | 49 + .../mechanics/spring/SpringParameters.kt | 79 + .../android/mechanics/spring/SpringState.kt | 134 ++ .../mechanics/view/ViewGestureContext.kt | 135 ++ .../view/ViewMotionBuilderContext.kt | 127 + .../android/mechanics/view/ViewMotionValue.kt | 342 +++ mechanics/testing/Android.bp | 37 + mechanics/testing/AndroidManifest.xml | 20 + .../testing/ComposeMotionValueToolkit.kt | 176 ++ .../mechanics/testing/DataPointTypes.kt | 69 + .../testing/FakeMotionSpecBuilderContext.kt | 54 + .../mechanics/testing/FeatureCaptures.kt | 71 + .../mechanics/testing/MotionSpecSubject.kt | 308 +++ .../mechanics/testing/MotionValueToolkit.kt | 230 ++ .../android/mechanics/testing/TimeSeries.kt | 55 + .../testing/ViewMotionValueToolkit.kt | 167 ++ mechanics/tests/Android.bp | 50 + mechanics/tests/AndroidManifest.xml | 28 + ...placedAfter_afterAttach_detachesAgain.json | 662 +++++ .../placedAfter_attach_snapsToOrigin.json | 392 +++ ...foreAttach_suppressesDirectionReverse.json | 162 ++ ...foreDetach_suppressesDirectionReverse.json | 162 ++ .../placedAfter_detach_animatesDetach.json | 432 ++++ ...dAfter_placedWithDifferentBaseMapping.json | 392 +++ ...lacedBefore_afterAttach_detachesAgain.json | 662 +++++ .../placedBefore_attach_snapsToOrigin.json | 392 +++ ...foreAttach_suppressesDirectionReverse.json | 162 ++ ...foreDetach_suppressesDirectionReverse.json | 202 ++ .../placedBefore_detach_animatesDetach.json | 432 ++++ ...Before_placedWithDifferentBaseMapping.json | 392 +++ ...ction_flipsBetweenDirectionalSegments.json | 192 ++ ..._addsAnimationToMapping_becomesStable.json | 72 + .../criticallyDamped_matchesGolden.json | 814 +++++++ ...edValue_hasAnimationLifecycleOnItsOwn.json | 534 ++++ ...dValue_reflectsInputChangeInSameFrame.json | 458 ++++ ...appliesGuarantee_afterDirectionChange.json | 172 ++ ...Min_changesSegmentWithDirectionChange.json | 252 ++ ...Max_changesSegmentWithDirectionChange.json | 252 ++ .../goldens/doNothingBeforeThreshold.json | 92 + ...c_outputMatchesInput_withoutAnimation.json | 102 + mechanics/tests/goldens/hideAnimation.json | 322 +++ .../goldens/hideAnimationOnThreshold.json | 322 +++ .../goldens/overDamped_matchesGolden.json | 2142 +++++++++++++++++ ..._maxDirection_neverExceedsMaxOverdrag.json | 279 +++ ..._minDirection_neverExceedsMaxOverdrag.json | 279 +++ .../overdrag_nonStandardBaseFunction.json | 268 +++ mechanics/tests/goldens/revealAnimation.json | 292 +++ .../revealAnimation_afterFixedValue.json | 292 +++ ...animationAtRest_doesNotAffectVelocity.json | 312 +++ ...e_appliesOutputVelocity_atSpringStart.json | 272 +++ ...ocity_springVelocityIsNotAppliedTwice.json | 312 +++ ...y_velocityAddedOnDiscontinuousSegment.json | 372 +++ ...y_velocityNotAddedOnContinuousSegment.json | 262 ++ ...agDelta_springCompletesWithinDistance.json | 122 + ...utDelta_springCompletesWithinDistance.json | 132 + ...None_springAnimatesIndependentOfInput.json | 212 ++ ...ection_animatedWhenReachingBreakpoint.json | 212 ++ ...n_springAnimationStartedRetroactively.json | 202 ++ ...egmentChange_inMaxDirection_zeroDelta.json | 82 + ...ection_animatedWhenReachingBreakpoint.json | 212 ++ ...n_springAnimationStartedRetroactively.json | 202 ++ ...ntics_flipsBetweenDirectionalSegments.json | 323 +++ ...ring_updatesImmediately_matchesGolden.json | 54 + ...teWithinSegment_animatesSegmentChange.json | 182 ++ ...teWithinSegment_animatesSegmentChange.json | 162 ++ .../stiffeningSpring_matchesGolden.json | 182 ++ ...ame_noGuarantee_combinesDiscontinuity.json | 202 ++ ...withDirectionChange_appliesGuarantees.json | 142 ++ ...Frame_withGuarantee_appliesGuarantees.json | 172 ++ ...e_addsDiscontinuityToOngoingAnimation.json | 292 +++ .../goldens/underDamped_matchesGolden.json | 1870 ++++++++++++++ ...c_outputMatchesInput_withoutAnimation.json | 102 + ...ontext_listensToGestureContextUpdates.json | 122 + ...Change_animatedWhenReachingBreakpoint.json | 212 ++ .../view/specChange_triggersAnimation.json | 152 ++ ...acement_initialVelocity_matchesGolden.json | 1318 ++++++++++ .../mechanics/DistanceGestureContextTest.kt | 151 ++ .../mechanics/MotionValueLifecycleTest.kt | 176 ++ .../com/android/mechanics/MotionValueTest.kt | 675 ++++++ .../debug/MotionValueDebuggerTest.kt | 94 + .../mechanics/effects/MagneticDetachTest.kt | 252 ++ .../android/mechanics/effects/OverdragTest.kt | 129 + .../effects/RevealOnThresholdTest.kt | 118 + .../spec/DirectionalMotionSpecTest.kt | 200 ++ .../spec/MotionSpecDebugFormatterTest.kt | 145 ++ .../android/mechanics/spec/MotionSpecTest.kt | 320 +++ .../com/android/mechanics/spec/SegmentTest.kt | 106 + .../builder/DirectionalBuilderImplTest.kt | 295 +++ .../spec/builder/MotionSpecBuilderTest.kt | 740 ++++++ ...poseAndMechanicsSpringCompatibilityTest.kt | 152 ++ .../mechanics/spring/SpringParameterTest.kt | 58 + .../mechanics/spring/SpringStateTest.kt | 144 ++ .../mechanics/view/ViewGestureContextTest.kt | 202 ++ .../view/ViewMotionBuilderContextTest.kt | 77 + .../mechanics/view/ViewMotionValueTest.kt | 261 ++ 142 files changed, 33586 insertions(+) create mode 100644 mechanics/Android.bp create mode 100644 mechanics/AndroidManifest.xml create mode 100644 mechanics/TEST_MAPPING create mode 100644 mechanics/benchmark/AndroidManifest.xml create mode 100644 mechanics/benchmark/benchmark-proguard-rules.pro create mode 100644 mechanics/benchmark/tests/src/com/android/mechanics/benchmark/ComposeBaselineBenchmark.kt create mode 100644 mechanics/benchmark/tests/src/com/android/mechanics/benchmark/ComposeStateTest.kt create mode 100644 mechanics/benchmark/tests/src/com/android/mechanics/benchmark/MotionValueBenchmark.kt create mode 100644 mechanics/build.gradle create mode 100644 mechanics/compose/Android.bp create mode 100644 mechanics/compose/AndroidManifest.xml create mode 100644 mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalFadeContentRevealModifier.kt create mode 100644 mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalTactileSurfaceRevealModifier.kt create mode 100644 mechanics/compose/tests/AndroidManifest.xml create mode 100644 mechanics/src/com/android/mechanics/GestureContext.kt create mode 100644 mechanics/src/com/android/mechanics/MotionValue.kt create mode 100644 mechanics/src/com/android/mechanics/behavior/VerticalExpandContainerBackground.kt create mode 100644 mechanics/src/com/android/mechanics/behavior/VerticalExpandContainerSpec.kt create mode 100644 mechanics/src/com/android/mechanics/debug/DebugInspector.kt create mode 100644 mechanics/src/com/android/mechanics/debug/DebugVisualization.kt create mode 100644 mechanics/src/com/android/mechanics/debug/MotionValueDebugger.kt create mode 100644 mechanics/src/com/android/mechanics/effects/Fixed.kt create mode 100644 mechanics/src/com/android/mechanics/effects/MagneticDetach.kt create mode 100644 mechanics/src/com/android/mechanics/effects/Overdrag.kt create mode 100644 mechanics/src/com/android/mechanics/effects/RevealOnThreshold.kt create mode 100644 mechanics/src/com/android/mechanics/impl/ComputationInput.kt create mode 100644 mechanics/src/com/android/mechanics/impl/Computations.kt create mode 100644 mechanics/src/com/android/mechanics/impl/DiscontinuityAnimation.kt create mode 100644 mechanics/src/com/android/mechanics/impl/GuaranteeState.kt create mode 100644 mechanics/src/com/android/mechanics/impl/SegmentChangeType.kt create mode 100644 mechanics/src/com/android/mechanics/spec/Breakpoint.kt create mode 100644 mechanics/src/com/android/mechanics/spec/Guarantee.kt create mode 100644 mechanics/src/com/android/mechanics/spec/InputDirection.kt create mode 100644 mechanics/src/com/android/mechanics/spec/MotionSpec.kt create mode 100644 mechanics/src/com/android/mechanics/spec/MotionSpecDebugFormatter.kt create mode 100644 mechanics/src/com/android/mechanics/spec/Segment.kt create mode 100644 mechanics/src/com/android/mechanics/spec/SegmentChangeHandler.kt create mode 100644 mechanics/src/com/android/mechanics/spec/SemanticValue.kt create mode 100644 mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderImpl.kt create mode 100644 mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderScope.kt create mode 100644 mechanics/src/com/android/mechanics/spec/builder/DirectionalSpecBuilder.kt create mode 100644 mechanics/src/com/android/mechanics/spec/builder/Effect.kt create mode 100644 mechanics/src/com/android/mechanics/spec/builder/EffectApplyScope.kt create mode 100644 mechanics/src/com/android/mechanics/spec/builder/EffectPlacement.kt create mode 100644 mechanics/src/com/android/mechanics/spec/builder/MotionBuilderContext.kt create mode 100644 mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilder.kt create mode 100644 mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilderImpl.kt create mode 100644 mechanics/src/com/android/mechanics/spring/MaterialSpringParameters.kt create mode 100644 mechanics/src/com/android/mechanics/spring/SpringParameters.kt create mode 100644 mechanics/src/com/android/mechanics/spring/SpringState.kt create mode 100644 mechanics/src/com/android/mechanics/view/ViewGestureContext.kt create mode 100644 mechanics/src/com/android/mechanics/view/ViewMotionBuilderContext.kt create mode 100644 mechanics/src/com/android/mechanics/view/ViewMotionValue.kt create mode 100644 mechanics/testing/Android.bp create mode 100644 mechanics/testing/AndroidManifest.xml create mode 100644 mechanics/testing/src/com/android/mechanics/testing/ComposeMotionValueToolkit.kt create mode 100644 mechanics/testing/src/com/android/mechanics/testing/DataPointTypes.kt create mode 100644 mechanics/testing/src/com/android/mechanics/testing/FakeMotionSpecBuilderContext.kt create mode 100644 mechanics/testing/src/com/android/mechanics/testing/FeatureCaptures.kt create mode 100644 mechanics/testing/src/com/android/mechanics/testing/MotionSpecSubject.kt create mode 100644 mechanics/testing/src/com/android/mechanics/testing/MotionValueToolkit.kt create mode 100644 mechanics/testing/src/com/android/mechanics/testing/TimeSeries.kt create mode 100644 mechanics/testing/src/com/android/mechanics/testing/ViewMotionValueToolkit.kt create mode 100644 mechanics/tests/Android.bp create mode 100644 mechanics/tests/AndroidManifest.xml create mode 100644 mechanics/tests/goldens/MagneticDetach/placedAfter_afterAttach_detachesAgain.json create mode 100644 mechanics/tests/goldens/MagneticDetach/placedAfter_attach_snapsToOrigin.json create mode 100644 mechanics/tests/goldens/MagneticDetach/placedAfter_beforeAttach_suppressesDirectionReverse.json create mode 100644 mechanics/tests/goldens/MagneticDetach/placedAfter_beforeDetach_suppressesDirectionReverse.json create mode 100644 mechanics/tests/goldens/MagneticDetach/placedAfter_detach_animatesDetach.json create mode 100644 mechanics/tests/goldens/MagneticDetach/placedAfter_placedWithDifferentBaseMapping.json create mode 100644 mechanics/tests/goldens/MagneticDetach/placedBefore_afterAttach_detachesAgain.json create mode 100644 mechanics/tests/goldens/MagneticDetach/placedBefore_attach_snapsToOrigin.json create mode 100644 mechanics/tests/goldens/MagneticDetach/placedBefore_beforeAttach_suppressesDirectionReverse.json create mode 100644 mechanics/tests/goldens/MagneticDetach/placedBefore_beforeDetach_suppressesDirectionReverse.json create mode 100644 mechanics/tests/goldens/MagneticDetach/placedBefore_detach_animatesDetach.json create mode 100644 mechanics/tests/goldens/MagneticDetach/placedBefore_placedWithDifferentBaseMapping.json create mode 100644 mechanics/tests/goldens/changeDirection_flipsBetweenDirectionalSegments.json create mode 100644 mechanics/tests/goldens/changingInput_addsAnimationToMapping_becomesStable.json create mode 100644 mechanics/tests/goldens/criticallyDamped_matchesGolden.json create mode 100644 mechanics/tests/goldens/derivedValue_hasAnimationLifecycleOnItsOwn.json create mode 100644 mechanics/tests/goldens/derivedValue_reflectsInputChangeInSameFrame.json create mode 100644 mechanics/tests/goldens/directionChange_maxToMin_appliesGuarantee_afterDirectionChange.json create mode 100644 mechanics/tests/goldens/directionChange_maxToMin_changesSegmentWithDirectionChange.json create mode 100644 mechanics/tests/goldens/directionChange_minToMax_changesSegmentWithDirectionChange.json create mode 100644 mechanics/tests/goldens/doNothingBeforeThreshold.json create mode 100644 mechanics/tests/goldens/emptySpec_outputMatchesInput_withoutAnimation.json create mode 100644 mechanics/tests/goldens/hideAnimation.json create mode 100644 mechanics/tests/goldens/hideAnimationOnThreshold.json create mode 100644 mechanics/tests/goldens/overDamped_matchesGolden.json create mode 100644 mechanics/tests/goldens/overdrag_maxDirection_neverExceedsMaxOverdrag.json create mode 100644 mechanics/tests/goldens/overdrag_minDirection_neverExceedsMaxOverdrag.json create mode 100644 mechanics/tests/goldens/overdrag_nonStandardBaseFunction.json create mode 100644 mechanics/tests/goldens/revealAnimation.json create mode 100644 mechanics/tests/goldens/revealAnimation_afterFixedValue.json create mode 100644 mechanics/tests/goldens/segmentChange_animationAtRest_doesNotAffectVelocity.json create mode 100644 mechanics/tests/goldens/segmentChange_appliesOutputVelocity_atSpringStart.json create mode 100644 mechanics/tests/goldens/segmentChange_appliesOutputVelocity_springVelocityIsNotAppliedTwice.json create mode 100644 mechanics/tests/goldens/segmentChange_appliesOutputVelocity_velocityAddedOnDiscontinuousSegment.json create mode 100644 mechanics/tests/goldens/segmentChange_appliesOutputVelocity_velocityNotAddedOnContinuousSegment.json create mode 100644 mechanics/tests/goldens/segmentChange_guaranteeGestureDragDelta_springCompletesWithinDistance.json create mode 100644 mechanics/tests/goldens/segmentChange_guaranteeInputDelta_springCompletesWithinDistance.json create mode 100644 mechanics/tests/goldens/segmentChange_guaranteeNone_springAnimatesIndependentOfInput.json create mode 100644 mechanics/tests/goldens/segmentChange_inMaxDirection_animatedWhenReachingBreakpoint.json create mode 100644 mechanics/tests/goldens/segmentChange_inMaxDirection_springAnimationStartedRetroactively.json create mode 100644 mechanics/tests/goldens/segmentChange_inMaxDirection_zeroDelta.json create mode 100644 mechanics/tests/goldens/segmentChange_inMinDirection_animatedWhenReachingBreakpoint.json create mode 100644 mechanics/tests/goldens/segmentChange_inMinDirection_springAnimationStartedRetroactively.json create mode 100644 mechanics/tests/goldens/semantics_flipsBetweenDirectionalSegments.json create mode 100644 mechanics/tests/goldens/snapSpring_updatesImmediately_matchesGolden.json create mode 100644 mechanics/tests/goldens/specChange_shiftSegmentBackwards_doesNotAnimateWithinSegment_animatesSegmentChange.json create mode 100644 mechanics/tests/goldens/specChange_shiftSegmentForward_doesNotAnimateWithinSegment_animatesSegmentChange.json create mode 100644 mechanics/tests/goldens/stiffeningSpring_matchesGolden.json create mode 100644 mechanics/tests/goldens/traverseSegmentsInOneFrame_noGuarantee_combinesDiscontinuity.json create mode 100644 mechanics/tests/goldens/traverseSegmentsInOneFrame_withDirectionChange_appliesGuarantees.json create mode 100644 mechanics/tests/goldens/traverseSegmentsInOneFrame_withGuarantee_appliesGuarantees.json create mode 100644 mechanics/tests/goldens/traverseSegments_maxDirection_noGuarantee_addsDiscontinuityToOngoingAnimation.json create mode 100644 mechanics/tests/goldens/underDamped_matchesGolden.json create mode 100644 mechanics/tests/goldens/view/emptySpec_outputMatchesInput_withoutAnimation.json create mode 100644 mechanics/tests/goldens/view/gestureContext_listensToGestureContextUpdates.json create mode 100644 mechanics/tests/goldens/view/segmentChange_animatedWhenReachingBreakpoint.json create mode 100644 mechanics/tests/goldens/view/specChange_triggersAnimation.json create mode 100644 mechanics/tests/goldens/zeroDisplacement_initialVelocity_matchesGolden.json create mode 100644 mechanics/tests/src/com/android/mechanics/DistanceGestureContextTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/MotionValueLifecycleTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/MotionValueTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/debug/MotionValueDebuggerTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/effects/MagneticDetachTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/effects/OverdragTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/effects/RevealOnThresholdTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/spec/DirectionalMotionSpecTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/spec/MotionSpecDebugFormatterTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/spec/MotionSpecTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/spec/SegmentTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/spec/builder/DirectionalBuilderImplTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/spec/builder/MotionSpecBuilderTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/spring/ComposeAndMechanicsSpringCompatibilityTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/spring/SpringParameterTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/spring/SpringStateTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/view/ViewGestureContextTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/view/ViewMotionBuilderContextTest.kt create mode 100644 mechanics/tests/src/com/android/mechanics/view/ViewMotionValueTest.kt diff --git a/mechanics/Android.bp b/mechanics/Android.bp new file mode 100644 index 0000000..d683892 --- /dev/null +++ b/mechanics/Android.bp @@ -0,0 +1,35 @@ +// 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 { + default_team: "trendy_team_motion", + default_applicable_licenses: ["Android-Apache-2.0"], +} + +android_library { + name: "mechanics", + manifest: "AndroidManifest.xml", + sdk_version: "system_current", + min_sdk_version: "31", + static_libs: [ + "androidx.compose.runtime_runtime", + "androidx.compose.material3_material3", + "androidx.compose.ui_ui-util", + "androidx.compose.foundation_foundation-layout", + ], + srcs: [ + "src/**/*.kt", + ], + kotlincflags: ["-Xjvm-default=all"], +} diff --git a/mechanics/AndroidManifest.xml b/mechanics/AndroidManifest.xml new file mode 100644 index 0000000..29874f3 --- /dev/null +++ b/mechanics/AndroidManifest.xml @@ -0,0 +1,19 @@ + + + + diff --git a/mechanics/TEST_MAPPING b/mechanics/TEST_MAPPING new file mode 100644 index 0000000..4dd86b9 --- /dev/null +++ b/mechanics/TEST_MAPPING @@ -0,0 +1,38 @@ +{ + "presubmit": [ + { + "name": "mechanics_tests", + "options": [ + {"exclude-annotation": "org.junit.Ignore"}, + {"exclude-annotation": "androidx.test.filters.FlakyTest"} + ] + }, + { + "name": "SystemUIGoogleTests", + "options": [ + {"exclude-annotation": "org.junit.Ignore"}, + {"exclude-annotation": "androidx.test.filters.FlakyTest"} + ] + }, + { + "name": "PlatformComposeSceneTransitionLayoutTests" + }, + { + "name": "PlatformComposeCoreTests" + } + ], + "presubmit-large": [ + { + "name": "SystemUITests", + "options": [ + {"exclude-annotation": "org.junit.Ignore"}, + {"exclude-annotation": "androidx.test.filters.FlakyTest"} + ] + } + ], + "wm-cf": [ + { + "name": "WMShellUnitTests" + } + ] +} diff --git a/mechanics/benchmark/AndroidManifest.xml b/mechanics/benchmark/AndroidManifest.xml new file mode 100644 index 0000000..405595c --- /dev/null +++ b/mechanics/benchmark/AndroidManifest.xml @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file diff --git a/mechanics/benchmark/benchmark-proguard-rules.pro b/mechanics/benchmark/benchmark-proguard-rules.pro new file mode 100644 index 0000000..e4061d2 --- /dev/null +++ b/mechanics/benchmark/benchmark-proguard-rules.pro @@ -0,0 +1,37 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile + +-dontobfuscate + +-ignorewarnings + +-keepattributes *Annotation* + +-dontnote junit.framework.** +-dontnote junit.runner.** + +-dontwarn androidx.test.** +-dontwarn org.junit.** +-dontwarn org.hamcrest.** +-dontwarn com.squareup.javawriter.JavaWriter + +-keepclasseswithmembers @org.junit.runner.RunWith public class * \ No newline at end of file diff --git a/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/ComposeBaselineBenchmark.kt b/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/ComposeBaselineBenchmark.kt new file mode 100644 index 0000000..c000dfe --- /dev/null +++ b/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/ComposeBaselineBenchmark.kt @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2025 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.benchmark + +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.compose.animation.core.Animatable +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.util.fastForEach +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import platform.test.motion.compose.runMonotonicClockTest + +/** Benchmark, which will execute on an Android device. Previous results: go/mm-microbenchmarks */ +@RunWith(AndroidJUnit4::class) +class ComposeBaselineBenchmark { + @get:Rule val benchmarkRule = BenchmarkRule() + + // Compose specific + + @Test + fun writeState_1snapshotFlow() = runMonotonicClockTest { + val composeState = mutableFloatStateOf(0f) + + var lastRead = 0f + snapshotFlow { composeState.floatValue }.onEach { lastRead = it }.launchIn(backgroundScope) + + benchmarkRule.measureRepeated { + composeState.floatValue++ + Snapshot.sendApplyNotifications() + testScheduler.advanceTimeBy(16) + } + + check(lastRead == composeState.floatValue) { + "snapshotFlow lastRead $lastRead != ${composeState.floatValue} (current composeState)" + } + } + + @Test + fun writeState_100snapshotFlow() = runMonotonicClockTest { + val composeState = mutableFloatStateOf(0f) + + repeat(100) { snapshotFlow { composeState.floatValue }.launchIn(backgroundScope) } + + benchmarkRule.measureRepeated { + composeState.floatValue++ + Snapshot.sendApplyNotifications() + testScheduler.advanceTimeBy(16) + } + } + + @Test + fun readAnimatableValue_100animatables_keepRunning() = runMonotonicClockTest { + val anim = List(100) { Animatable(0f) } + + benchmarkRule.measureRepeated { + testScheduler.advanceTimeBy(16) + anim.fastForEach { + it.value + + if (!it.isRunning) { + launch { it.animateTo(if (it.targetValue != 0f) 0f else 1f) } + } + } + } + + testScheduler.advanceTimeBy(2000) + } + + @Test + fun readAnimatableValue_100animatables_restartEveryFrame() = runMonotonicClockTest { + val animatables = List(100) { Animatable(0f) } + + benchmarkRule.measureRepeated { + testScheduler.advanceTimeBy(16) + animatables.fastForEach { animatable -> + animatable.value + launch { animatable.animateTo(if (animatable.targetValue != 0f) 0f else 1f) } + } + } + + testScheduler.advanceTimeBy(2000) + } +} diff --git a/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/ComposeStateTest.kt b/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/ComposeStateTest.kt new file mode 100644 index 0000000..e70bc2b --- /dev/null +++ b/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/ComposeStateTest.kt @@ -0,0 +1,168 @@ +/* + * Copyright (C) 2025 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.benchmark + +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.snapshots.Snapshot +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.junit.Test +import org.junit.runner.RunWith +import platform.test.motion.compose.runMonotonicClockTest + +@RunWith(AndroidJUnit4::class) +class ComposeStateTest { + @Test + fun mutableState_sendApplyNotifications() = runMonotonicClockTest { + val mutableState = mutableStateOf(0f) + + var lastRead = -1f + snapshotFlow { mutableState.value }.onEach { lastRead = it }.launchIn(backgroundScope) + check(lastRead == -1f) { "[1] lastRead $lastRead, snapshotFlow launchIn" } + + // snapshotFlow will emit the first value (0f). + testScheduler.advanceTimeBy(1) + check(lastRead == 0f) { "[2] lastRead $lastRead, first advanceTimeBy()" } + + // update composeState x5. + repeat(5) { + mutableState.value++ + check(lastRead == 0f) { "[3 loop] lastRead $lastRead, composeState.floatValue++" } + + testScheduler.advanceTimeBy(1) + check(lastRead == 0f) { "[4 loop] lastRead $lastRead, advanceTimeBy()" } + } + + // Try to wait with a delay. It does nothing (lastRead == 0f). + delay(1) + check(mutableState.value == 5f) { "[5] mutableState ${mutableState.value}, after loop" } + check(lastRead == 0f) { "[5] lastRead $lastRead, after loop" } + + // This should trigger the flow. + Snapshot.sendApplyNotifications() + check(lastRead == 0f) { "[6] lastRead $lastRead, Snapshot.sendApplyNotifications()" } + + // lastRead will be updated (5f) after advanceTimeBy (or a delay). + testScheduler.advanceTimeBy(1) + check(lastRead == 5f) { "[7] lastRead $lastRead, advanceTimeBy" } + } + + @Test + fun derivedState_readNotRequireASendApplyNotifications() = runMonotonicClockTest { + val mutableState = mutableStateOf(0f) + + var derivedRuns = 0 + val derived = derivedStateOf { + derivedRuns++ + mutableState.value * 2f + } + check(derivedRuns == 0) { "[1] derivedRuns: $derivedRuns, should be 0" } + + var lastRead = -1f + snapshotFlow { derived.value }.onEach { lastRead = it }.launchIn(backgroundScope) + check(lastRead == -1f) { "[2] lastRead $lastRead, snapshotFlow launchIn" } + check(derivedRuns == 0) { "[2] derivedRuns: $derivedRuns, should be 0" } + + // snapshotFlow will emit the first value (0f * 2f = 0f). + testScheduler.advanceTimeBy(16) + check(lastRead == 0f) { "[3] lastRead $lastRead, first advanceTimeBy()" } + check(derivedRuns == 1) { "[3] derivedRuns: $derivedRuns, should be 1" } + + // update composeState x5. + repeat(5) { + mutableState.value++ + check(lastRead == 0f) { "[4 loop] lastRead $lastRead, composeState.floatValue++" } + + testScheduler.advanceTimeBy(16) + check(lastRead == 0f) { "[5 loop] lastRead $lastRead, advanceTimeBy()" } + } + + // Try to wait with a delay. It does nothing (lastRead == 0f). + delay(1) + check(mutableState.value == 5f) { "[6] mutableState ${mutableState.value}, after loop" } + check(lastRead == 0f) { "[6] lastRead $lastRead, after loop" } + check(derivedRuns == 1) { "[6] derivedRuns $derivedRuns, after loop" } + + // Reading a derived state, this will trigger the flow. + // NOTE: We are not using Snapshot.sendApplyNotifications() + derived.value + check(lastRead == 0f) { "[7] lastRead $lastRead, read derivedDouble" } + check(derivedRuns == 2) { "[7] derivedRuns $derivedRuns, read derived" } // Triggered + + // lastRead will be updated (5f * 2f = 10f) after advanceTimeBy (or a delay) + testScheduler.advanceTimeBy(16) + check(lastRead == 5f * 2f) { "[8] lastRead $lastRead, advanceTimeBy" } // New value + check(derivedRuns == 2) { "[8] derivedRuns $derivedRuns, read derived" } + } + + @Test + fun derivedState_readADerivedStateTriggerOthersDerivedState() = runMonotonicClockTest { + val mutableState = mutableStateOf(0f) + + var derivedRuns = 0 + val derived = derivedStateOf { + derivedRuns++ + mutableState.value + } + + var otherRuns = 0 + repeat(100) { + val otherState = derivedStateOf { + otherRuns++ + mutableState.value + } + // Observer all otherStates. + snapshotFlow { otherState.value }.launchIn(backgroundScope) + } + check(derivedRuns == 0) { "[1] derivedRuns: $derivedRuns" } + check(otherRuns == 0) { "[1] otherRuns: $otherRuns" } + + // Wait for snapshotFlow. + testScheduler.advanceTimeBy(16) + check(derivedRuns == 0) { "[2] derivedRuns: $derivedRuns" } + check(otherRuns == 100) { "[2] otherRuns: $otherRuns" } + + // This write might trigger all otherStates observed, but it does not. + mutableState.value++ + check(derivedRuns == 0) { "[3] derivedRuns: $derivedRuns" } + check(otherRuns == 100) { "[3] otherRuns: $otherRuns" } + + // Wait for several frames, but still doesn't trigger otherStates. + repeat(10) { testScheduler.advanceTimeBy(16) } + check(derivedRuns == 0) { "[4] derivedRuns: $derivedRuns" } + check(otherRuns == 100) { "[4] otherRuns: $otherRuns" } + + // Reading derived state will trigger all otherStates. + // This behavior is causing us some problems, because reading a derived state causes all + // the + // dirty derived states to be reread, and this can happen multiple times per frame, + // making + // derived states much more expensive than one might expect. + derived.value + check(derivedRuns == 1) { "[5] derivedRuns: $derivedRuns" } + check(otherRuns == 100) { "[5] otherRuns: $otherRuns" } + + // Now we pay the cost of those derived states. + testScheduler.advanceTimeBy(1) + check(derivedRuns == 1) { "[6] derivedRuns: $derivedRuns" } + check(otherRuns == 200) { "[6] otherRuns: $otherRuns" } + } +} diff --git a/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/MotionValueBenchmark.kt b/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/MotionValueBenchmark.kt new file mode 100644 index 0000000..f5eab76 --- /dev/null +++ b/mechanics/benchmark/tests/src/com/android/mechanics/benchmark/MotionValueBenchmark.kt @@ -0,0 +1,260 @@ +/* + * Copyright (C) 2025 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.benchmark + +import androidx.benchmark.junit4.BenchmarkRule +import androidx.benchmark.junit4.measureRepeated +import androidx.compose.runtime.MutableFloatState +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.util.fastForEach +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.DistanceGestureContext +import com.android.mechanics.MotionValue +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.directionalMotionSpec +import com.android.mechanics.spring.SpringParameters +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.launch +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import platform.test.motion.compose.MonotonicClockTestScope + +/** Benchmark, which will execute on an Android device. Previous results: go/mm-microbenchmarks */ +@RunWith(AndroidJUnit4::class) +class MotionValueBenchmark { + @get:Rule val benchmarkRule = BenchmarkRule() + + private val tearDownOperations = mutableListOf<() -> Unit>() + + /** + * Runs a test block within a [MonotonicClockTestScope] provided by the underlying + * [platform.test.motion.compose.runMonotonicClockTest] and ensures automatic cleanup. + * + * This mechanism provides a convenient way to register cleanup actions (e.g., stopping + * coroutines, resetting states) that should reliably run at the end of the test, simplifying + * test setup and teardown. + */ + private fun runMonotonicClockTest(block: suspend MonotonicClockTestScope.() -> Unit) { + return platform.test.motion.compose.runMonotonicClockTest { + try { + block() + } finally { + tearDownOperations.fastForEach { it.invoke() } + } + } + } + + private data class TestData( + val motionValue: MotionValue, + val gestureContext: DistanceGestureContext, + val input: MutableFloatState, + val spec: MotionSpec, + ) + + private fun testData( + gestureContext: DistanceGestureContext = DistanceGestureContext(0f, InputDirection.Max, 2f), + input: Float = 0f, + spec: MotionSpec = MotionSpec.Empty, + ): TestData { + val inputState = mutableFloatStateOf(input) + return TestData( + motionValue = MotionValue(inputState::floatValue, gestureContext, spec), + gestureContext = gestureContext, + input = inputState, + spec = spec, + ) + } + + // Fundamental operations on MotionValue: create, read, update. + + @Test + fun createMotionValue() { + val gestureContext = DistanceGestureContext(0f, InputDirection.Max, 2f) + val input = { 0f } + + benchmarkRule.measureRepeated { MotionValue(input, gestureContext) } + } + + @Test + fun stable_readOutput_noChanges() { + val data = testData() + + // The first read may cost more than the others, it is not interesting for this test. + data.motionValue.floatValue + + benchmarkRule.measureRepeated { data.motionValue.floatValue } + } + + @Test + fun stable_readOutput_afterWriteInput() { + val data = testData() + + benchmarkRule.measureRepeated { + runWithMeasurementDisabled { data.input.floatValue += 1f } + data.motionValue.floatValue + } + } + + @Test + fun stable_writeInput_AND_readOutput() { + val data = testData() + + benchmarkRule.measureRepeated { + data.input.floatValue += 1f + data.motionValue.floatValue + } + } + + @Test + fun stable_writeInput_AND_readOutput_keepRunning() = runMonotonicClockTest { + val data = testData() + keepRunningDuringTest(data.motionValue) + + benchmarkRule.measureRepeated { + data.input.floatValue += 1f + testScheduler.advanceTimeBy(16) + data.motionValue.floatValue + } + } + + @Test + fun stable_writeInput_AND_readOutput_100motionValues_keepRunning() = runMonotonicClockTest { + val dataList = List(100) { testData() } + dataList.forEach { keepRunningDuringTest(it.motionValue) } + + benchmarkRule.measureRepeated { + dataList.fastForEach { it.input.floatValue += 1f } + testScheduler.advanceTimeBy(16) + dataList.fastForEach { it.motionValue.floatValue } + } + } + + @Test + fun stable_readOutput_100motionValues_keepRunning() = runMonotonicClockTest { + val dataList = List(100) { testData() } + dataList.forEach { keepRunningDuringTest(it.motionValue) } + + benchmarkRule.measureRepeated { + testScheduler.advanceTimeBy(16) + dataList.fastForEach { it.motionValue.floatValue } + } + } + + // Animations + + private fun MonotonicClockTestScope.keepRunningDuringTest(motionValue: MotionValue) { + val keepRunningJob = launch { motionValue.keepRunning() } + tearDownOperations += { keepRunningJob.cancel() } + } + + private val MotionSpec.Companion.ZeroToOne_AtOne + get() = + MotionSpec( + directionalMotionSpec( + defaultSpring = SpringParameters(stiffness = 300f, dampingRatio = .9f), + initialMapping = Mapping.Zero, + ) { + fixedValue(breakpoint = 1f, value = 1f) + } + ) + + private val InputDirection.opposite + get() = if (this == InputDirection.Min) InputDirection.Max else InputDirection.Min + + @Test + fun unstable_resetGestureContext_readOutput() = runMonotonicClockTest { + val data = testData(input = 1f, spec = MotionSpec.ZeroToOne_AtOne) + keepRunningDuringTest(data.motionValue) + + benchmarkRule.measureRepeated { + if (data.motionValue.isStable) { + data.gestureContext.reset(0f, data.gestureContext.direction.opposite) + } + testScheduler.advanceTimeBy(16) + data.motionValue.floatValue + } + } + + @Test + fun unstable_resetGestureContext_readOutput_100motionValues() = runMonotonicClockTest { + val dataList = List(100) { testData(input = 1f, spec = MotionSpec.ZeroToOne_AtOne) } + dataList.forEach { keepRunningDuringTest(it.motionValue) } + + benchmarkRule.measureRepeated { + dataList.fastForEach { data -> + if (data.motionValue.isStable) { + data.gestureContext.reset(0f, data.gestureContext.direction.opposite) + } + } + testScheduler.advanceTimeBy(16) + dataList.fastForEach { it.motionValue.floatValue } + } + } + + @Test + fun unstable_resetGestureContext_snapshotFlowOutput() = runMonotonicClockTest { + val data = testData(input = 1f, spec = MotionSpec.ZeroToOne_AtOne) + keepRunningDuringTest(data.motionValue) + + snapshotFlow { data.motionValue.floatValue }.launchIn(backgroundScope) + + benchmarkRule.measureRepeated { + if (data.motionValue.isStable) { + data.gestureContext.reset(0f, data.gestureContext.direction.opposite) + } + testScheduler.advanceTimeBy(16) + } + } + + private val MotionSpec.Companion.ZeroToOne_AtOne_WithGuarantee + get() = + MotionSpec( + directionalMotionSpec( + defaultSpring = SpringParameters(stiffness = 300f, dampingRatio = .9f), + initialMapping = Mapping.Zero, + ) { + fixedValue( + breakpoint = 1f, + value = 1f, + guarantee = Guarantee.GestureDragDelta(1f), + ) + } + ) + + @Test + fun unstable_resetGestureContext_guarantee_readOutput() = runMonotonicClockTest { + val data = testData(input = 1f, spec = MotionSpec.ZeroToOne_AtOne_WithGuarantee) + keepRunningDuringTest(data.motionValue) + + benchmarkRule.measureRepeated { + if (data.motionValue.isStable) { + data.gestureContext.reset(0f, data.gestureContext.direction.opposite) + } else { + val isMax = data.gestureContext.direction == InputDirection.Max + data.gestureContext.dragOffset += if (isMax) 0.01f else -0.01f + } + + testScheduler.advanceTimeBy(16) + data.motionValue.floatValue + } + } +} diff --git a/mechanics/build.gradle b/mechanics/build.gradle new file mode 100644 index 0000000..d28e0db --- /dev/null +++ b/mechanics/build.gradle @@ -0,0 +1,50 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +apply plugin: 'org.jetbrains.kotlin.plugin.compose' + +android { + namespace = "com.android.mechanics" + testNamespace = "com.android.mechanics.tests" + + defaultConfig { + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildFeatures { + compose = true + } + + sourceSets { + main { + java.srcDirs = ['src', 'compose'] + manifest.srcFile 'AndroidManifest.xml' + } + } + + lintOptions { + abortOnError false + } + + tasks.lint.enabled = false + + tasks.withType(JavaCompile) { + options.compilerArgs << "-Xlint:unchecked" << "-Xlint:deprecation" + } +} + +addFrameworkJar('framework-16.jar') + +dependencies { + implementation libs.kotlin.stdlib.jdk7 + implementation libs.androidx.core.animation + implementation libs.androidx.core.ktx + + // Compose dependencies for compose source files + implementation platform(libs.compose.bom) + implementation libs.compose.ui + implementation libs.compose.ui.util + implementation libs.compose.ui.graphics + implementation libs.compose.runtime + implementation libs.kotlinx.coroutines.android + implementation libs.compose.material3 +} diff --git a/mechanics/compose/Android.bp b/mechanics/compose/Android.bp new file mode 100644 index 0000000..bc852eb --- /dev/null +++ b/mechanics/compose/Android.bp @@ -0,0 +1,33 @@ +// Copyright (C) 2025 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 { + default_team: "trendy_team_motion", + default_applicable_licenses: ["Android-Apache-2.0"], +} + +android_library { + name: "mechanics-compose", + manifest: "AndroidManifest.xml", + srcs: [ + "src/**/*.kt", + ], + static_libs: [ + "PlatformComposeCore", + "PlatformComposeSceneTransitionLayout", + "//frameworks/libs/systemui/mechanics:mechanics", + "androidx.compose.runtime_runtime", + ], + kotlincflags: ["-Xjvm-default=all"], +} diff --git a/mechanics/compose/AndroidManifest.xml b/mechanics/compose/AndroidManifest.xml new file mode 100644 index 0000000..b84f740 --- /dev/null +++ b/mechanics/compose/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + diff --git a/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalFadeContentRevealModifier.kt b/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalFadeContentRevealModifier.kt new file mode 100644 index 0000000..6428d9d --- /dev/null +++ b/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalFadeContentRevealModifier.kt @@ -0,0 +1,229 @@ +///* +// * Copyright (C) 2025 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.compose.modifier +// +//import androidx.compose.ui.Modifier +//import androidx.compose.ui.geometry.Rect +//import androidx.compose.ui.graphics.CompositingStrategy +//import androidx.compose.ui.layout.ApproachLayoutModifierNode +//import androidx.compose.ui.layout.ApproachMeasureScope +//import androidx.compose.ui.layout.LayoutCoordinates +//import androidx.compose.ui.layout.Measurable +//import androidx.compose.ui.layout.MeasureResult +//import androidx.compose.ui.layout.MeasureScope +//import androidx.compose.ui.layout.Placeable +//import androidx.compose.ui.layout.boundsInParent +//import androidx.compose.ui.node.ModifierNodeElement +//import androidx.compose.ui.platform.InspectorInfo +//import androidx.compose.ui.unit.Constraints +//import androidx.compose.ui.unit.IntOffset +//import androidx.compose.ui.unit.IntSize +//import androidx.compose.ui.util.fastCoerceAtLeast +//import com.android.compose.animation.scene.ContentScope +//import com.android.compose.animation.scene.ElementKey +//import com.android.compose.animation.scene.mechanics.gestureContextOrDefault +//import com.android.mechanics.MotionValue +//import com.android.mechanics.debug.findMotionValueDebugger +//import com.android.mechanics.effects.FixedValue +//import com.android.mechanics.spec.Mapping +//import com.android.mechanics.spec.builder.MotionBuilderContext +//import com.android.mechanics.spec.builder.effectsMotionSpec +//import kotlinx.coroutines.Job +//import kotlinx.coroutines.launch +// +///** +// * This component remains hidden until it reach its target height. +// * +// * TODO: Once b/413283893 is done, [motionBuilderContext] can be read internally via +// * CompositionLocalConsumerModifierNode, instead of passing it. +// */ +//fun Modifier.verticalFadeContentReveal( +// contentScope: ContentScope, +// motionBuilderContext: MotionBuilderContext, +// container: ElementKey, +// deltaY: Float = 0f, +// label: String? = null, +// debug: Boolean = false, +//): Modifier = +// this then +// FadeContentRevealElement( +// contentScope = contentScope, +// motionBuilderContext = motionBuilderContext, +// container = container, +// deltaY = deltaY, +// label = label, +// debug = debug, +// ) +// +//private data class FadeContentRevealElement( +// val contentScope: ContentScope, +// val motionBuilderContext: MotionBuilderContext, +// val container: ElementKey, +// val deltaY: Float, +// val label: String?, +// val debug: Boolean, +//) : ModifierNodeElement() { +// override fun create(): FadeContentRevealNode = +// FadeContentRevealNode( +// contentScope = contentScope, +// motionBuilderContext = motionBuilderContext, +// container = container, +// deltaY = deltaY, +// label = label, +// debug = debug, +// ) +// +// override fun update(node: FadeContentRevealNode) { +// node.update( +// contentScope = contentScope, +// motionBuilderContext = motionBuilderContext, +// container = container, +// deltaY = deltaY, +// ) +// } +// +// override fun InspectorInfo.inspectableProperties() { +// name = "fadeContentReveal" +// properties["container"] = container +// properties["deltaY"] = deltaY +// properties["label"] = label +// properties["debug"] = debug +// } +//} +// +//internal class FadeContentRevealNode( +// private var contentScope: ContentScope, +// private var motionBuilderContext: MotionBuilderContext, +// private var container: ElementKey, +// private var deltaY: Float, +// label: String?, +// private val debug: Boolean, +//) : Modifier.Node(), ApproachLayoutModifierNode { +// +// private val motionValue = +// MotionValue( +// currentInput = { +// with(contentScope) { +// val containerHeight = +// container.lastSize(contentKey)?.height ?: return@MotionValue 0f +// val containerCoordinates = +// container.targetCoordinates(contentKey) ?: return@MotionValue 0f +// val localCoordinates = lastCoordinates ?: return@MotionValue 0f +// +// val offsetY = containerCoordinates.localPositionOf(localCoordinates).y +// containerHeight - offsetY + deltaY +// } +// }, +// gestureContext = contentScope.gestureContextOrDefault(), +// label = "FadeContentReveal(${label.orEmpty()})", +// ) +// +// fun update( +// contentScope: ContentScope, +// motionBuilderContext: MotionBuilderContext, +// container: ElementKey, +// deltaY: Float, +// ) { +// this.contentScope = contentScope +// this.motionBuilderContext = motionBuilderContext +// this.container = container +// this.deltaY = deltaY +// updateMotionSpec() +// } +// +// private var motionValueJob: Job? = null +// +// override fun onAttach() { +// motionValueJob = +// coroutineScope.launch { +// val disposableHandle = +// if (debug) { +// findMotionValueDebugger()?.register(motionValue) +// } else { +// null +// } +// try { +// motionValue.keepRunning() +// } finally { +// disposableHandle?.dispose() +// } +// } +// } +// +// override fun onDetach() { +// motionValueJob?.cancel() +// } +// +// private fun isAnimating(): Boolean { +// return contentScope.layoutState.currentTransition != null || !motionValue.isStable +// } +// +// override fun isMeasurementApproachInProgress(lookaheadSize: IntSize) = isAnimating() +// +// override fun Placeable.PlacementScope.isPlacementApproachInProgress( +// lookaheadCoordinates: LayoutCoordinates +// ) = isAnimating() +// +// private var targetBounds = Rect.Zero +// +// private var lastCoordinates: LayoutCoordinates? = null +// +// private fun updateMotionSpec() { +// motionValue.spec = +// motionBuilderContext.effectsMotionSpec(Mapping.Zero) { +// after(targetBounds.bottom, FixedValue.One) +// } +// } +// +// override fun MeasureScope.measure( +// measurable: Measurable, +// constraints: Constraints, +// ): MeasureResult { +// val placeable = measurable.measure(constraints) +// return layout(placeable.width, placeable.height) { +// val coordinates = coordinates +// if (isLookingAhead && coordinates != null) { +// lastCoordinates = coordinates +// val bounds = coordinates.boundsInParent() +// if (targetBounds != bounds) { +// targetBounds = bounds +// updateMotionSpec() +// } +// } +// placeable.place(IntOffset.Zero) +// } +// } +// +// override fun ApproachMeasureScope.approachMeasure( +// measurable: Measurable, +// constraints: Constraints, +// ): MeasureResult { +// return measurable.measure(constraints).run { +// layout(width, height) { +// val revealAlpha = motionValue.output +// if (revealAlpha < 1) { +// placeWithLayer(IntOffset.Zero) { +// alpha = revealAlpha.fastCoerceAtLeast(0f) +// compositingStrategy = CompositingStrategy.ModulateAlpha +// } +// } else { +// place(IntOffset.Zero) +// } +// } +// } +// } +//} diff --git a/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalTactileSurfaceRevealModifier.kt b/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalTactileSurfaceRevealModifier.kt new file mode 100644 index 0000000..9bfd3db --- /dev/null +++ b/mechanics/compose/src/com/android/mechanics/compose/modifier/VerticalTactileSurfaceRevealModifier.kt @@ -0,0 +1,250 @@ +///* +// * Copyright (C) 2025 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.compose.modifier +// +//import androidx.compose.ui.Modifier +//import androidx.compose.ui.geometry.Rect +//import androidx.compose.ui.graphics.CompositingStrategy +//import androidx.compose.ui.layout.ApproachLayoutModifierNode +//import androidx.compose.ui.layout.ApproachMeasureScope +//import androidx.compose.ui.layout.LayoutCoordinates +//import androidx.compose.ui.layout.Measurable +//import androidx.compose.ui.layout.MeasureResult +//import androidx.compose.ui.layout.MeasureScope +//import androidx.compose.ui.layout.Placeable +//import androidx.compose.ui.layout.boundsInParent +//import androidx.compose.ui.node.ModifierNodeElement +//import androidx.compose.ui.platform.InspectorInfo +//import androidx.compose.ui.unit.Constraints +//import androidx.compose.ui.unit.IntOffset +//import androidx.compose.ui.unit.IntSize +//import androidx.compose.ui.util.fastCoerceAtLeast +//import androidx.compose.ui.util.fastCoerceIn +//import com.android.compose.animation.scene.ContentScope +//import com.android.compose.animation.scene.ElementKey +//import com.android.compose.animation.scene.mechanics.gestureContextOrDefault +//import com.android.mechanics.MotionValue +//import com.android.mechanics.debug.findMotionValueDebugger +//import com.android.mechanics.effects.RevealOnThreshold +//import com.android.mechanics.spec.Mapping +//import com.android.mechanics.spec.builder.MotionBuilderContext +//import com.android.mechanics.spec.builder.spatialMotionSpec +//import kotlin.math.roundToInt +//import kotlinx.coroutines.Job +//import kotlinx.coroutines.launch +// +///** +// * This component remains hidden until its target height meets a minimum threshold. At that point, +// * it reveals itself by animating its height from 0 to the current target height. +// * +// * TODO: Once b/413283893 is done, [motionBuilderContext] can be read internally via +// * CompositionLocalConsumerModifierNode, instead of passing it. +// */ +//fun Modifier.verticalTactileSurfaceReveal( +// contentScope: ContentScope, +// motionBuilderContext: MotionBuilderContext, +// container: ElementKey, +// deltaY: Float = 0f, +// revealOnThreshold: RevealOnThreshold = DefaultRevealOnThreshold, +// label: String? = null, +// debug: Boolean = false, +//): Modifier = +// this then +// VerticalTactileSurfaceRevealElement( +// contentScope = contentScope, +// motionBuilderContext = motionBuilderContext, +// container = container, +// deltaY = deltaY, +// revealOnThreshold = revealOnThreshold, +// label = label, +// debug = debug, +// ) +// +//private val DefaultRevealOnThreshold = RevealOnThreshold() +// +//private data class VerticalTactileSurfaceRevealElement( +// val contentScope: ContentScope, +// val motionBuilderContext: MotionBuilderContext, +// val container: ElementKey, +// val deltaY: Float, +// val revealOnThreshold: RevealOnThreshold, +// val label: String?, +// val debug: Boolean, +//) : ModifierNodeElement() { +// override fun create(): VerticalTactileSurfaceRevealNode = +// VerticalTactileSurfaceRevealNode( +// contentScope = contentScope, +// motionBuilderContext = motionBuilderContext, +// container = container, +// deltaY = deltaY, +// revealOnThreshold = revealOnThreshold, +// label = label, +// debug = debug, +// ) +// +// override fun update(node: VerticalTactileSurfaceRevealNode) { +// node.update( +// contentScope = contentScope, +// motionBuilderContext = motionBuilderContext, +// container = container, +// deltaY = deltaY, +// revealOnThreshold = revealOnThreshold, +// ) +// } +// +// override fun InspectorInfo.inspectableProperties() { +// name = "tactileSurfaceReveal" +// properties["container"] = container +// properties["deltaY"] = deltaY +// properties["revealOnThreshold"] = revealOnThreshold +// properties["label"] = label +// properties["debug"] = debug +// } +//} +// +//private class VerticalTactileSurfaceRevealNode( +// private var contentScope: ContentScope, +// private var motionBuilderContext: MotionBuilderContext, +// private var container: ElementKey, +// private var deltaY: Float, +// private var revealOnThreshold: RevealOnThreshold, +// label: String?, +// private val debug: Boolean, +//) : Modifier.Node(), ApproachLayoutModifierNode { +// +// private val motionValue = +// MotionValue( +// currentInput = { +// with(contentScope) { +// val containerHeight = +// container.lastSize(contentKey)?.height ?: return@MotionValue 0f +// val containerCoordinates = +// container.targetCoordinates(contentKey) ?: return@MotionValue 0f +// val localCoordinates = lastCoordinates ?: return@MotionValue 0f +// +// val offsetY = containerCoordinates.localPositionOf(localCoordinates).y +// containerHeight - offsetY + deltaY +// } +// }, +// gestureContext = contentScope.gestureContextOrDefault(), +// label = "TactileSurfaceReveal(${label.orEmpty()})", +// stableThreshold = MotionBuilderContext.StableThresholdSpatial, +// ) +// +// fun update( +// contentScope: ContentScope, +// motionBuilderContext: MotionBuilderContext, +// container: ElementKey, +// deltaY: Float, +// revealOnThreshold: RevealOnThreshold, +// ) { +// this.contentScope = contentScope +// this.motionBuilderContext = motionBuilderContext +// this.container = container +// this.deltaY = deltaY +// this.revealOnThreshold = revealOnThreshold +// updateMotionSpec() +// } +// +// private var motionValueJob: Job? = null +// +// override fun onAttach() { +// motionValueJob = +// coroutineScope.launch { +// val disposableHandle = +// if (debug) { +// findMotionValueDebugger()?.register(motionValue) +// } else { +// null +// } +// try { +// motionValue.keepRunning() +// } finally { +// disposableHandle?.dispose() +// } +// } +// } +// +// override fun onDetach() { +// motionValueJob?.cancel() +// } +// +// private fun isAnimating(): Boolean { +// return contentScope.layoutState.currentTransition != null || !motionValue.isStable +// } +// +// override fun isMeasurementApproachInProgress(lookaheadSize: IntSize) = isAnimating() +// +// override fun Placeable.PlacementScope.isPlacementApproachInProgress( +// lookaheadCoordinates: LayoutCoordinates +// ) = isAnimating() +// +// private var targetBounds = Rect.Zero +// +// private var lastCoordinates: LayoutCoordinates? = null +// +// private fun updateMotionSpec() { +// motionValue.spec = +// motionBuilderContext.spatialMotionSpec(Mapping.Zero) { +// between( +// start = targetBounds.top, +// end = targetBounds.bottom, +// effect = revealOnThreshold, +// ) +// } +// } +// +// override fun MeasureScope.measure( +// measurable: Measurable, +// constraints: Constraints, +// ): MeasureResult { +// val placeable = measurable.measure(constraints) +// return layout(placeable.width, placeable.height) { +// val coordinates = coordinates +// if (isLookingAhead && coordinates != null) { +// lastCoordinates = coordinates +// val bounds = coordinates.boundsInParent() +// if (targetBounds != bounds) { +// targetBounds = bounds +// updateMotionSpec() +// } +// } +// placeable.place(IntOffset.Zero) +// } +// } +// +// override fun ApproachMeasureScope.approachMeasure( +// measurable: Measurable, +// constraints: Constraints, +// ): MeasureResult { +// val height = motionValue.output.roundToInt().fastCoerceAtLeast(0) +// val animatedConstraints = Constraints.fixed(width = constraints.maxWidth, height = height) +// return measurable.measure(animatedConstraints).run { +// layout(width, height) { +// val revealAlpha = (height / revealOnThreshold.minSize.toPx()).fastCoerceIn(0f, 1f) +// if (revealAlpha < 1) { +// placeWithLayer(IntOffset.Zero) { +// alpha = revealAlpha +// compositingStrategy = CompositingStrategy.ModulateAlpha +// } +// } else { +// place(IntOffset.Zero) +// } +// } +// } +// } +//} diff --git a/mechanics/compose/tests/AndroidManifest.xml b/mechanics/compose/tests/AndroidManifest.xml new file mode 100644 index 0000000..182f244 --- /dev/null +++ b/mechanics/compose/tests/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + diff --git a/mechanics/src/com/android/mechanics/GestureContext.kt b/mechanics/src/com/android/mechanics/GestureContext.kt new file mode 100644 index 0000000..f1fb3ee --- /dev/null +++ b/mechanics/src/com/android/mechanics/GestureContext.kt @@ -0,0 +1,205 @@ +/* + * 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.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalViewConfiguration +import com.android.mechanics.spec.InputDirection +import kotlin.math.max +import kotlin.math.min + +/** + * Remembers [DistanceGestureContext] with the given initial distance / direction. + * + * Providing update [initDistance] or [initialDirection] will not re-create the + * [DistanceGestureContext]. + * + * The `directionChangeSlop` is derived from `ViewConfiguration.touchSlop` and kept current without + * re-creating, should it ever change. + */ +@Composable +fun rememberDistanceGestureContext( + initDistance: Float = 0f, + initialDirection: InputDirection = InputDirection.Max, +): DistanceGestureContext { + val touchSlop = LocalViewConfiguration.current.touchSlop + return remember { DistanceGestureContext(initDistance, initialDirection, touchSlop) } + .also { it.directionChangeSlop = touchSlop } +} + +/** + * Gesture-specific context to augment [MotionValue.currentInput]. + * + * This context helps to capture the user's intent, and should be provided to [MotionValue]s that + * respond to a user gesture. + */ +@Stable +interface GestureContext { + + /** + * The intrinsic direction of the [MotionValue.currentInput]. + * + * This property determines which of the [DirectionalMotionSpec] from the [MotionSpec] is used, + * and also prevents flip-flopping of the output value on tiny input-changes around a + * breakpoint. + * + * If the [MotionValue.currentInput] is driven - directly or indirectly - by a user gesture, + * this property should only change direction after the gesture travelled a significant distance + * in the opposite direction. + * + * @see DistanceGestureContext for a default implementation. + */ + val direction: InputDirection + + /** + * The gesture distance of the current gesture, in pixels. + * + * Used solely for the [GestureDragDelta] [Guarantee]. Can be hard-coded to a static value if + * this type of [Guarantee] is not used. + */ + val dragOffset: Float +} + +/** + * [GestureContext] with a mutable [dragOffset]. + * + * The implementation class defines whether the [direction] is updated accordingly. + */ +interface MutableDragOffsetGestureContext : GestureContext { + /** The gesture distance of the current gesture, in pixels. */ + override var dragOffset: Float +} + +/** [GestureContext] implementation for manually set values. */ +class ProvidedGestureContext(dragOffset: Float, direction: InputDirection) : + MutableDragOffsetGestureContext { + override var direction by mutableStateOf(direction) + override var dragOffset by mutableFloatStateOf(dragOffset) +} + +/** + * [GestureContext] driven by a gesture distance. + * + * The direction is determined from the gesture input, where going further than + * [directionChangeSlop] in the opposite direction toggles the direction. + * + * @param initialDragOffset The initial [dragOffset] of the [GestureContext] + * @param initialDirection The initial [direction] of the [GestureContext] + * @param directionChangeSlop the amount [dragOffset] must be moved in the opposite direction for + * the [direction] to flip. + */ +class DistanceGestureContext( + initialDragOffset: Float, + initialDirection: InputDirection, + directionChangeSlop: Float, +) : MutableDragOffsetGestureContext { + init { + require(directionChangeSlop > 0) { + "directionChangeSlop must be greater than 0, was $directionChangeSlop" + } + } + + override var direction by mutableStateOf(initialDirection) + private set + + private var furthestDragOffset by mutableFloatStateOf(initialDragOffset) + + private var _dragOffset by mutableFloatStateOf(initialDragOffset) + + override var dragOffset: Float + get() = _dragOffset + /** + * Updates the [dragOffset]. + * + * This flips the [direction], if the [value] is further than [directionChangeSlop] away + * from the furthest recorded value regarding to the current [direction]. + */ + set(value) { + _dragOffset = value + this.direction = + when (direction) { + InputDirection.Max -> { + if (furthestDragOffset - value > directionChangeSlop) { + furthestDragOffset = value + InputDirection.Min + } else { + furthestDragOffset = max(value, furthestDragOffset) + InputDirection.Max + } + } + + InputDirection.Min -> { + if (value - furthestDragOffset > directionChangeSlop) { + furthestDragOffset = value + InputDirection.Max + } else { + furthestDragOffset = min(value, furthestDragOffset) + InputDirection.Min + } + } + } + } + + private var _directionChangeSlop by mutableFloatStateOf(directionChangeSlop) + + var directionChangeSlop: Float + get() = _directionChangeSlop + + /** + * This flips the [direction], if the current [direction] is further than the new + * directionChangeSlop [value] away from the furthest recorded value regarding to the + * current [direction]. + */ + set(value) { + require(value > 0) { "directionChangeSlop must be greater than 0, was $value" } + + _directionChangeSlop = value + + when (direction) { + InputDirection.Max -> { + if (furthestDragOffset - dragOffset > directionChangeSlop) { + furthestDragOffset = dragOffset + direction = InputDirection.Min + } + } + InputDirection.Min -> { + if (dragOffset - furthestDragOffset > directionChangeSlop) { + furthestDragOffset = value + direction = InputDirection.Max + } + } + } + } + + /** + * Sets [dragOffset] and [direction] to the specified values. + * + * This also resets memoized [furthestDragOffset], which is used to determine the direction + * change. + */ + fun reset(dragOffset: Float, direction: InputDirection) { + this.dragOffset = dragOffset + this.direction = direction + this.furthestDragOffset = dragOffset + } +} diff --git a/mechanics/src/com/android/mechanics/MotionValue.kt b/mechanics/src/com/android/mechanics/MotionValue.kt new file mode 100644 index 0000000..9d01c10 --- /dev/null +++ b/mechanics/src/com/android/mechanics/MotionValue.kt @@ -0,0 +1,466 @@ +/* + * 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.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 com.android.mechanics.debug.DebugInspector +import com.android.mechanics.debug.FrameData +import com.android.mechanics.impl.Computations +import com.android.mechanics.impl.DiscontinuityAnimation +import com.android.mechanics.impl.GuaranteeState +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.spec.SegmentKey +import com.android.mechanics.spec.SemanticKey +import com.android.mechanics.spring.SpringState +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext + +/** + * 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( + currentInput: () -> Float, + gestureContext: GestureContext, + initialSpec: MotionSpec = MotionSpec.Empty, + label: String? = null, + stableThreshold: Float = StableThresholdEffect, +) : FloatState { + private val impl = + ObservableComputations(currentInput, gestureContext, initialSpec, stableThreshold, label) + + /** The [MotionSpec] describing the mapping of this [MotionValue]'s input to the output. */ + var spec: MotionSpec by impl::spec + + /** Animated [output] value. */ + val output: Float by impl::output + + /** + * [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 by impl::outputTarget + + /** The [output] exposed as [FloatState]. */ + override val floatValue: Float by impl::output + + /** Whether an animation is currently running. */ + val isStable: Boolean by impl::isStable + + /** + * The current value for the [SemanticKey]. + * + * `null` if not defined in the spec. + */ + operator fun get(key: SemanticKey): T? { + return impl.semanticState(key) + } + + /** The current segment used to compute the output. */ + val segmentKey: SegmentKey + get() = impl.currentComputedValues.segment.key + + /** + * 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 { + withContext(CoroutineName("MotionValue($label)")) { impl.keepRunning { true } } + + // `keepRunning` above will never finish, + throw AssertionError("Unreachable code") + } + + /** + * Keeps the [MotionValue]'s animated output running while [continueRunning] returns `true`. + * + * When [continueRunning] returns `false`, the coroutine will end by the next frame. + * + * To keep the [MotionValue] running until the current animations are complete, check for + * `isStable` as well. + * + * ```kotlin + * motionValue.keepRunningWhile { !shouldEnd() || !isStable } + * ``` + */ + suspend fun keepRunningWhile(continueRunning: MotionValue.() -> Boolean) = + withContext(CoroutineName("MotionValue($label)")) { + impl.keepRunning { continueRunning.invoke(this@MotionValue) } + } + + val label: String? by impl::label + + 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.impl.gestureContext, + initialSpec = initialSpec, + label = label, + stableThreshold = stableThreshold, + ) + } + + const val StableThresholdEffect = 0.01f + const val StableThresholdSpatial = 1f + + internal const val TAG = "MotionValue" + } + + private var debugInspectorRefCount = AtomicInteger(0) + + private fun onDisposeDebugInspector() { + if (debugInspectorRefCount.decrementAndGet() == 0) { + impl.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) { + impl.debugInspector = + DebugInspector( + FrameData( + impl.lastInput, + impl.lastSegment.direction, + impl.lastGestureDragOffset, + impl.lastFrameTimeNanos, + impl.lastSpringState, + impl.lastSegment, + impl.lastAnimation, + ), + impl.isActive, + impl.debugIsAnimating, + ::onDisposeDebugInspector, + ) + } + + return checkNotNull(impl.debugInspector) + } +} + +private class ObservableComputations( + val input: () -> Float, + val gestureContext: GestureContext, + initialSpec: MotionSpec = MotionSpec.Empty, + override val stableThreshold: Float, + override val label: String?, +) : Computations() { + + // ---- CurrentFrameInput --------------------------------------------------------------------- + + override var spec by mutableStateOf(initialSpec) + override val currentInput: Float + get() = input.invoke() + + override val currentDirection: InputDirection + get() = gestureContext.direction + + override val currentGestureDragOffset: Float + get() = gestureContext.dragOffset + + override var currentAnimationTimeNanos by mutableLongStateOf(-1L) + + // ---- LastFrameState --------------------------------------------------------------------- + + override var lastSegment: SegmentData by + mutableStateOf( + spec.segmentAtInput(currentInput, currentDirection), + referentialEqualityPolicy(), + ) + + override var lastGuaranteeState: GuaranteeState + get() = GuaranteeState(_lastGuaranteeStatePacked) + set(value) { + _lastGuaranteeStatePacked = value.packedValue + } + + private var _lastGuaranteeStatePacked: Long by + mutableLongStateOf(GuaranteeState.Inactive.packedValue) + + override var lastAnimation: DiscontinuityAnimation by + mutableStateOf(DiscontinuityAnimation.None, referentialEqualityPolicy()) + + override var directMappedVelocity: Float = 0f + + override var lastSpringState: SpringState + get() = SpringState(_lastSpringStatePacked) + set(value) { + _lastSpringStatePacked = value.packedValue + } + + private var _lastSpringStatePacked: Long by + mutableLongStateOf(lastAnimation.springStartState.packedValue) + + override var lastFrameTimeNanos by mutableLongStateOf(-1L) + + override var lastInput by mutableFloatStateOf(currentInput) + + override var lastGestureDragOffset by mutableFloatStateOf(currentGestureDragOffset) + + // ---- Computations --------------------------------------------------------------------------- + + suspend fun keepRunning(continueRunning: () -> Boolean) { + check(!isActive) { "MotionValue($label) is already running" } + isActive = true + + // These `captured*` values will be applied to the `last*` values, at the beginning + // of the each new frame. + // TODO(b/397837971): Encapsulate the state in a StateRecord. + val initialValues = currentComputedValues + var capturedSegment = initialValues.segment + var capturedGuaranteeState = initialValues.guarantee + var capturedAnimation = initialValues.animation + var capturedSpringState = currentSpringState + var capturedFrameTimeNanos = currentAnimationTimeNanos + var capturedInput = currentInput + var capturedGestureDragOffset = currentGestureDragOffset + var capturedDirection = currentDirection + + try { + debugIsAnimating = true + + // indicates whether withFrameNanos is called continuously (as opposed to being + // suspended for an undetermined amount of time in between withFrameNanos). + // This is essential after `withFrameNanos` returned: if true at this point, + // currentAnimationTimeNanos - lastFrameNanos is the duration of the last frame. + var isAnimatingUninterrupted = false + + while (continueRunning()) { + + withFrameNanos { frameTimeNanos -> + currentAnimationTimeNanos = frameTimeNanos + + // With the new frame started, copy + + lastSegment = capturedSegment + lastGuaranteeState = capturedGuaranteeState + lastAnimation = capturedAnimation + lastSpringState = capturedSpringState + lastFrameTimeNanos = capturedFrameTimeNanos + lastInput = capturedInput + lastGestureDragOffset = capturedGestureDragOffset + } + + // At this point, the complete frame is done (including layout, drawing and + // everything else), and this MotionValue has been updated. + + // Capture the `current*` MotionValue state, so that it can be applied as the + // `last*` state when the next frame starts. Its imperative to capture at this point + // already (since the input could change before the next frame starts), while at the + // same time not already applying the `last*` state (as this would cause a + // re-computation if the current state is being read before the next frame). + if (isAnimatingUninterrupted) { + directMappedVelocity = + computeDirectMappedVelocity(currentAnimationTimeNanos - lastFrameTimeNanos) + } else { + directMappedVelocity = 0f + } + + var scheduleNextFrame = false + if (!isSameSegmentAndAtRest) { + // Read currentComputedValues only once and update it, if necessary + val currentValues = currentComputedValues + + if (capturedSegment != currentValues.segment) { + capturedSegment = currentValues.segment + scheduleNextFrame = true + } + + if (capturedGuaranteeState != currentValues.guarantee) { + capturedGuaranteeState = currentValues.guarantee + scheduleNextFrame = true + } + + if (capturedAnimation != currentValues.animation) { + capturedAnimation = currentValues.animation + scheduleNextFrame = true + } + + if (capturedSpringState != currentSpringState) { + capturedSpringState = currentSpringState + scheduleNextFrame = true + } + } + + if (capturedInput != currentInput) { + capturedInput = currentInput + scheduleNextFrame = true + } + + if (capturedGestureDragOffset != currentGestureDragOffset) { + capturedGestureDragOffset = currentGestureDragOffset + scheduleNextFrame = true + } + + if (capturedDirection != currentDirection) { + capturedDirection = currentDirection + scheduleNextFrame = true + } + + capturedFrameTimeNanos = currentAnimationTimeNanos + + debugInspector?.run { + frame = + FrameData( + capturedInput, + capturedDirection, + capturedGestureDragOffset, + capturedFrameTimeNanos, + capturedSpringState, + capturedSegment, + capturedAnimation, + ) + } + + isAnimatingUninterrupted = scheduleNextFrame + if (scheduleNextFrame) { + continue + } + + debugIsAnimating = false + snapshotFlow { + val wakeup = + !continueRunning() || + spec != capturedSegment.spec || + currentInput != capturedInput || + currentDirection != capturedDirection || + currentGestureDragOffset != capturedGestureDragOffset + wakeup + } + .first { it } + debugIsAnimating = true + } + } finally { + isActive = false + debugIsAnimating = false + } + } + + /** Whether a [keepRunning] coroutine is active currently. */ + 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. + */ + var debugIsAnimating = false + set(value) { + field = value + debugInspector?.isAnimating = value + } + + var debugInspector: DebugInspector? = null +} diff --git a/mechanics/src/com/android/mechanics/behavior/VerticalExpandContainerBackground.kt b/mechanics/src/com/android/mechanics/behavior/VerticalExpandContainerBackground.kt new file mode 100644 index 0000000..9738424 --- /dev/null +++ b/mechanics/src/com/android/mechanics/behavior/VerticalExpandContainerBackground.kt @@ -0,0 +1,196 @@ +/* + * Copyright (C) 2025 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.behavior + +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.layer.GraphicsLayer +import androidx.compose.ui.graphics.layer.drawLayer +import androidx.compose.ui.node.DrawModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.requireGraphicsContext +import androidx.compose.ui.util.fastCoerceAtLeast +import androidx.compose.ui.util.fastCoerceIn +import androidx.compose.ui.util.lerp +import kotlin.math.min +import kotlin.math.round + +/** + * Draws the background of a vertically container, and applies clipping to it. + * + * Intended to be used with a [VerticalExpandContainerSpec] motion. + */ +fun Modifier.verticalExpandContainerBackground( + backgroundColor: Color, + spec: VerticalExpandContainerSpec, +): Modifier = + this.then( + if (spec.isFloating) { + Modifier.verticalFloatingExpandContainerBackground(backgroundColor, spec) + } else { + Modifier.verticalEdgeExpandContainerBackground(backgroundColor, spec) + } + ) + +/** + * Draws the background of an floating container, and applies clipping to it. + * + * Intended to be used with a [VerticalExpandContainerSpec] motion. + */ +internal fun Modifier.verticalFloatingExpandContainerBackground( + backgroundColor: Color, + spec: VerticalExpandContainerSpec, +): Modifier = + this.drawWithCache { + val targetRadiusPx = spec.radius.toPx() + val currentRadiusPx = min(targetRadiusPx, min(size.width, size.height) / 2f) + val horizontalInset = targetRadiusPx - currentRadiusPx + val shapeTopLeft = Offset(horizontalInset, 0f) + val shapeSize = Size(size.width - (horizontalInset * 2f), size.height) + + val layer = + obtainGraphicsLayer().apply { + clip = true + setRoundRectOutline(shapeTopLeft, shapeSize, cornerRadius = currentRadiusPx) + } + + onDrawWithContent { + layer.record { this@onDrawWithContent.drawContent() } + drawRoundRect( + color = backgroundColor, + topLeft = shapeTopLeft, + size = shapeSize, + cornerRadius = CornerRadius(currentRadiusPx), + ) + + drawLayer(layer) + } + } + +/** + * Draws the background of an edge container, and applies clipping to it. + * + * Intended to be used with a [VerticalExpandContainerSpec] motion. + */ +internal fun Modifier.verticalEdgeExpandContainerBackground( + backgroundColor: Color, + spec: VerticalExpandContainerSpec, +): Modifier = this.then(EdgeContainerExpansionBackgroundElement(backgroundColor, spec)) + +internal class EdgeContainerExpansionBackgroundNode( + var backgroundColor: Color, + var spec: VerticalExpandContainerSpec, +) : Modifier.Node(), DrawModifierNode { + + private var graphicsLayer: GraphicsLayer? = null + private var lastOutlineSize = Size.Zero + + fun invalidateOutline() { + lastOutlineSize = Size.Zero + } + + override fun onAttach() { + graphicsLayer = requireGraphicsContext().createGraphicsLayer().apply { clip = true } + } + + override fun onDetach() { + requireGraphicsContext().releaseGraphicsLayer(checkNotNull(graphicsLayer)) + } + + override fun ContentDrawScope.draw() { + val height = size.height + + // The width is growing between visibleHeight and detachHeight + val visibleHeight = spec.visibleHeight.toPx() + val widthFraction = + ((height - visibleHeight) / (spec.detachHeight.toPx() - visibleHeight)).fastCoerceIn( + 0f, + 1f, + ) + val width = size.width - lerp(spec.widthOffset.toPx(), 0f, widthFraction) + val horizontalInset = (size.width - width) / 2f + + // The radius is growing at the beginning of the transition + val radius = height.fastCoerceIn(spec.minRadius.toPx(), spec.radius.toPx()) + + // Draw (at most) the bottom half of the rounded corner rectangle, aligned to the bottom. + // Round upper height to the closest integer to avoid to avoid a hairline gap being visible + // due to the two rectangles overlapping. + val upperHeight = round((height - radius)).fastCoerceAtLeast(0f) + + // The rounded rect is drawn at 2x the radius height, to avoid smaller corner radii. + // The clipRect limits this to the relevant part between this and the fill below. + clipRect(top = upperHeight) { + drawRoundRect( + color = backgroundColor, + cornerRadius = CornerRadius(radius), + size = Size(width, radius * 2f), + topLeft = Offset(horizontalInset, size.height - radius * 2f), + ) + } + + if (upperHeight > 0) { + // Fill the space above the bottom shape. + drawRect( + color = backgroundColor, + topLeft = Offset(horizontalInset, 0f), + size = Size(width, upperHeight), + ) + } + + // Draw the node's content in a separate layer. + val graphicsLayer = checkNotNull(graphicsLayer) + graphicsLayer.record { this@draw.drawContent() } + + if (size != lastOutlineSize) { + // The clip outline is a rounded corner shape matching the bottom of the shape. + // At the top, the rounded corner shape extends by radiusPx above top. + // This clipping thus would not prevent the containers content to overdraw at the top, + // however this is off-screen anyways. + val top = min(-radius, height - radius * 2f) + + val rect = Rect(left = horizontalInset, top = top, right = width, bottom = height) + graphicsLayer.setRoundRectOutline(rect.topLeft, rect.size, radius) + lastOutlineSize = size + } + + this.drawLayer(graphicsLayer) + } +} + +private data class EdgeContainerExpansionBackgroundElement( + val backgroundColor: Color, + val spec: VerticalExpandContainerSpec, +) : ModifierNodeElement() { + override fun create(): EdgeContainerExpansionBackgroundNode = + EdgeContainerExpansionBackgroundNode(backgroundColor, spec) + + override fun update(node: EdgeContainerExpansionBackgroundNode) { + node.backgroundColor = backgroundColor + if (node.spec != spec) { + node.spec = spec + node.invalidateOutline() + } + } +} diff --git a/mechanics/src/com/android/mechanics/behavior/VerticalExpandContainerSpec.kt b/mechanics/src/com/android/mechanics/behavior/VerticalExpandContainerSpec.kt new file mode 100644 index 0000000..3bc264a --- /dev/null +++ b/mechanics/src/com/android/mechanics/behavior/VerticalExpandContainerSpec.kt @@ -0,0 +1,161 @@ +/* + * Copyright (C) 2025 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(ExperimentalMaterial3ExpressiveApi::class) + +package com.android.mechanics.behavior + +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MotionScheme +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastCoerceIn +import androidx.compose.ui.util.lerp +import com.android.mechanics.spec.Breakpoint +import com.android.mechanics.spec.BreakpointKey +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.OnChangeSegmentHandler +import com.android.mechanics.spec.SegmentData +import com.android.mechanics.spec.SegmentKey +import com.android.mechanics.spec.builder.directionalMotionSpec +import com.android.mechanics.spring.SpringParameters + +/** Motion spec for a vertically expandable container. */ +class VerticalExpandContainerSpec( + val isFloating: Boolean, + val minRadius: Dp = Defaults.MinRadius, + val radius: Dp = Defaults.Radius, + val visibleHeight: Dp = Defaults.VisibleHeight, + val preDetachRatio: Float = Defaults.PreDetachRatio, + val detachHeight: Dp = if (isFloating) radius * 3 else Defaults.DetachHeight, + val attachHeight: Dp = if (isFloating) radius * 2 else Defaults.AttachHeight, + val widthOffset: Dp = Defaults.WidthOffset, + val attachSpring: SpringParameters = Defaults.AttachSpring, + val detachSpring: SpringParameters = Defaults.DetachSpring, + val opacitySpring: SpringParameters = Defaults.OpacitySpring, +) { + fun createHeightSpec(motionScheme: MotionScheme, density: Density): MotionSpec { + // TODO: michschn@ - replace with MagneticDetach + return with(density) { + val spatialSpring = SpringParameters(motionScheme.defaultSpatialSpec()) + + val detachSpec = + directionalMotionSpec( + initialMapping = Mapping.Zero, + defaultSpring = spatialSpring, + ) { + fractionalInputFromCurrent( + breakpoint = 0f, + key = Breakpoints.Attach, + fraction = preDetachRatio, + ) + identity( + breakpoint = detachHeight.toPx(), + key = Breakpoints.Detach, + spring = detachSpring, + ) + } + + val attachSpec = + directionalMotionSpec( + initialMapping = Mapping.Zero, + defaultSpring = spatialSpring, + ) { + identity( + breakpoint = attachHeight.toPx(), + key = Breakpoints.Detach, + spring = attachSpring, + ) + } + + val segmentHandlers = + mapOf( + SegmentKey(Breakpoints.Detach, Breakpoint.maxLimit.key, InputDirection.Min) to + { currentSegment, _, newDirection -> + if (newDirection != currentSegment.direction) currentSegment else null + }, + SegmentKey(Breakpoints.Attach, Breakpoints.Detach, InputDirection.Max) to + { currentSegment: SegmentData, newInput: Float, newDirection: InputDirection + -> + if (newDirection != currentSegment.direction && newInput >= 0) + currentSegment + else null + }, + ) + + MotionSpec( + maxDirection = detachSpec, + minDirection = attachSpec, + segmentHandlers = segmentHandlers, + ) + } + } + + fun createWidthSpec( + intrinsicWidth: Float, + motionScheme: MotionScheme, + density: Density, + ): MotionSpec { + return with(density) { + if (isFloating) { + MotionSpec(directionalMotionSpec(Mapping.Fixed(intrinsicWidth))) + } else { + MotionSpec( + directionalMotionSpec({ input -> + val fraction = (input / detachHeight.toPx()).fastCoerceIn(0f, 1f) + intrinsicWidth - lerp(widthOffset.toPx(), 0f, fraction) + }) + ) + } + } + } + + fun createAlphaSpec(motionScheme: MotionScheme, density: Density): MotionSpec { + return with(density) { + MotionSpec( + directionalMotionSpec(opacitySpring, initialMapping = Mapping.Zero) { + fixedValue(breakpoint = visibleHeight.toPx(), value = 1f) + } + ) + } + } + + companion object { + object Breakpoints { + val Attach = BreakpointKey("EdgeContainerExpansion::Attach") + val Detach = BreakpointKey("EdgeContainerExpansion::Detach") + } + + object Defaults { + val VisibleHeight = 24.dp + val PreDetachRatio = .25f + val DetachHeight = 80.dp + val AttachHeight = 40.dp + + val WidthOffset = 28.dp + + val MinRadius = 28.dp + val Radius = 46.dp + + val AttachSpring = SpringParameters(stiffness = 380f, dampingRatio = 0.9f) + val DetachSpring = SpringParameters(stiffness = 380f, dampingRatio = 0.9f) + val OpacitySpring = SpringParameters(stiffness = 1200f, dampingRatio = 0.99f) + } + } +} 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..088c78b --- /dev/null +++ b/mechanics/src/com/android/mechanics/debug/DebugInspector.kt @@ -0,0 +1,90 @@ +/* + * 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.MotionValue +import com.android.mechanics.impl.DiscontinuityAnimation +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.SegmentData +import com.android.mechanics.spec.SegmentKey +import com.android.mechanics.spec.SemanticKey +import com.android.mechanics.spec.SemanticValue +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 gestureDragOffset: 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() = segment.mapping.map(input) + springState.displacement + + val outputTarget: Float + get() = segment.mapping.map(input) + + fun semantic(semanticKey: SemanticKey): T? { + return segment.semantic(semanticKey) + } + + val semantics: List> + get() = with(segment) { spec.semantics(key) } +} 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..b89728b --- /dev/null +++ b/mechanics/src/com/android/mechanics/debug/DebugVisualization.kt @@ -0,0 +1,510 @@ +/* + * 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.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.node.DrawModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.ObserverModifierNode +import androidx.compose.ui.node.observeReads +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastCoerceAtLeast +import androidx.compose.ui.util.fastCoerceAtMost +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.InputDirection +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.SegmentKey +import kotlin.math.ceil +import kotlin.math.max +import kotlin.math.min +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +/** + * 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 maxAgeMillis Max age of the elements in the history trail. + */ +@Composable +fun DebugMotionValueVisualization( + motionValue: MotionValue, + inputRange: ClosedFloatingPointRange, + modifier: Modifier = Modifier, + maxAgeMillis: Long = 1000L, +) { + val spec = motionValue.spec + val outputRange = remember(spec, inputRange) { spec.computeOutputValueRange(inputRange) } + + val inspector = remember(motionValue) { motionValue.debugInspector() } + + DisposableEffect(inspector) { onDispose { inspector.dispose() } } + + val colorScheme = MaterialTheme.colorScheme + val axisColor = colorScheme.outline + val specColor = colorScheme.tertiary + val valueColor = colorScheme.primary + + val primarySpec = motionValue.spec.get(inspector.frame.gestureDirection) + val activeSegment = inspector.frame.segmentKey + + Spacer( + modifier = + modifier + .debugMotionSpecGraph( + primarySpec, + inputRange, + outputRange, + axisColor, + specColor, + activeSegment, + ) + .debugMotionValueGraph( + motionValue, + valueColor, + inputRange, + outputRange, + maxAgeMillis, + ) + ) +} + +/** + * 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. + */ +fun Modifier.debugMotionSpecGraph( + spec: DirectionalMotionSpec, + inputRange: ClosedFloatingPointRange, + outputRange: ClosedFloatingPointRange, + axisColor: Color = Color.Gray, + specColor: Color = Color.Blue, + activeSegment: SegmentKey? = null, +): Modifier = drawBehind { + drawAxis(axisColor) + drawDirectionalSpec(spec, inputRange, outputRange, specColor, activeSegment) +} + +/** + * 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 maxAgeMillis Max age of the elements in the history trail. + */ +@Composable +fun Modifier.debugMotionValueGraph( + motionValue: MotionValue, + color: Color, + inputRange: ClosedFloatingPointRange, + outputRange: ClosedFloatingPointRange, + maxAgeMillis: Long = 1000L, +): Modifier = + this then + DebugMotionValueGraphElement(motionValue, color, inputRange, outputRange, maxAgeMillis) + +/** + * Utility to compute the min/max output values of the spec for the given input. + * + * Note: this only samples at breakpoint locations. For segment mappings that produce smaller/larger + * values in between two breakpoints, this method might might not produce a correct result. + */ +fun MotionSpec.computeOutputValueRange( + inputRange: ClosedFloatingPointRange +): ClosedFloatingPointRange { + 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 + } +} + +/** + * Utility to compute the min/max output values of the spec for the given input. + * + * Note: this only samples at breakpoint locations. For segment mappings that produce smaller/larger + * values in between two breakpoints, this method might might not produce a correct result. + */ +fun DirectionalMotionSpec.computeOutputValueRange( + inputRange: ClosedFloatingPointRange +): ClosedFloatingPointRange { + + 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 data class DebugMotionValueGraphElement( + val motionValue: MotionValue, + val color: Color, + val inputRange: ClosedFloatingPointRange, + val outputRange: ClosedFloatingPointRange, + val maxAgeMillis: Long, +) : ModifierNodeElement() { + + init { + require(maxAgeMillis > 0) + } + + override fun create() = + DebugMotionValueGraphNode(motionValue, color, inputRange, outputRange, maxAgeMillis) + + override fun update(node: DebugMotionValueGraphNode) { + node.motionValue = motionValue + node.color = color + node.inputRange = inputRange + node.outputRange = outputRange + node.maxAgeMillis = maxAgeMillis + } + + override fun InspectorInfo.inspectableProperties() { + // intentionally empty + } +} + +private class DebugMotionValueGraphNode( + motionValue: MotionValue, + var color: Color, + var inputRange: ClosedFloatingPointRange, + var outputRange: ClosedFloatingPointRange, + var maxAgeMillis: Long, +) : DrawModifierNode, ObserverModifierNode, Modifier.Node() { + + private var debugInspector by mutableStateOf(null) + private val history = mutableStateListOf() + + var motionValue = motionValue + set(value) { + if (value != field) { + disposeDebugInspector() + field = value + + if (isAttached) { + acquireDebugInspector() + } + } + } + + override fun onAttach() { + acquireDebugInspector() + + coroutineScope.launch { + while (true) { + if (history.size > 1) { + + withFrameNanos { thisFrameTime -> + while ( + history.size > 1 && + (thisFrameTime - history.first().frameTimeNanos) > + maxAgeMillis * 1_000_000 + ) { + history.removeFirst() + } + } + } + + snapshotFlow { history.size > 1 }.first { it } + } + } + } + + override fun onDetach() { + disposeDebugInspector() + } + + private fun acquireDebugInspector() { + debugInspector = motionValue.debugInspector() + observeFrameAndAddToHistory() + } + + private fun disposeDebugInspector() { + debugInspector?.dispose() + debugInspector = null + history.clear() + } + + override fun ContentDrawScope.draw() { + if (history.isNotEmpty()) { + drawDirectionAndAnimationStatus(history.last()) + } + drawInputOutputTrail(history, inputRange, outputRange, color) + drawContent() + } + + private fun observeFrameAndAddToHistory() { + var lastFrame: FrameData? = null + + observeReads { lastFrame = debugInspector?.frame } + + lastFrame?.also { history.add(it) } + } + + override fun onObservedReadsChanged() { + observeFrameAndAddToHistory() + } +} + +private val MotionSpec.isUnidirectional: Boolean + get() = maxDirection == minDirection + +private fun DrawScope.mapPointInInputToX( + input: Float, + inputRange: ClosedFloatingPointRange, +): Float { + val inputExtent = (inputRange.endInclusive - inputRange.start) + return ((input - inputRange.start) / (inputExtent)) * size.width +} + +private fun DrawScope.mapPointInOutputToY( + output: Float, + outputRange: ClosedFloatingPointRange, +): Float { + val outputExtent = (outputRange.endInclusive - outputRange.start) + return (1 - (output - outputRange.start) / (outputExtent)) * size.height +} + +private fun DrawScope.drawDirectionalSpec( + spec: DirectionalMotionSpec, + inputRange: ClosedFloatingPointRange, + outputRange: ClosedFloatingPointRange, + color: Color, + activeSegment: SegmentKey?, +) { + + val startSegment = spec.findBreakpointIndex(inputRange.start) + val endSegment = spec.findBreakpointIndex(inputRange.endInclusive) + + for (segmentIndex in startSegment..endSegment) { + val isActiveSegment = + activeSegment?.let { spec.findSegmentIndex(it) == segmentIndex } ?: false + + val mapping = spec.mappings[segmentIndex] + val startBreakpoint = spec.breakpoints[segmentIndex] + val segmentStart = startBreakpoint.position + val fromInput = segmentStart.fastCoerceAtLeast(inputRange.start) + val endBreakpoint = spec.breakpoints[segmentIndex + 1] + val segmentEnd = endBreakpoint.position + val toInput = segmentEnd.fastCoerceAtMost(inputRange.endInclusive) + + val strokeWidth = if (isActiveSegment) 2.dp.toPx() else Stroke.HairlineWidth + val dotSize = if (isActiveSegment) 4.dp.toPx() else 2.dp.toPx() + 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) + if (mapping is Mapping.Fixed || mapping is Mapping.Identity || mapping is Mapping.Linear) { + drawLine(color, start, end, strokeWidth = strokeWidth) + } else { + val xStart = mapPointInInputToX(fromInput, inputRange) + val xEnd = mapPointInInputToX(toInput, inputRange) + + val oneDpInPx = 1.dp.toPx() + val numberOfLines = ceil((xEnd - xStart) / oneDpInPx).toInt() + val inputLength = (toInput - fromInput) / numberOfLines + + repeat(numberOfLines) { + val lineStart = fromInput + inputLength * it + val lineEnd = lineStart + inputLength + + val partialFromY = mapPointInOutputToY(mapping.map(lineStart), outputRange) + val partialToY = mapPointInOutputToY(mapping.map(lineEnd), outputRange) + + val partialStart = Offset(mapPointInInputToX(lineStart, inputRange), partialFromY) + val partialEnd = Offset(mapPointInInputToX(lineEnd, inputRange), partialToY) + + drawLine(color, partialStart, partialEnd, strokeWidth = strokeWidth) + } + } + + if (segmentStart == fromInput) { + drawCircle(color, dotSize, start) + } + + if (segmentEnd == toInput) { + drawCircle(color, dotSize, 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.drawDirectionAndAnimationStatus(currentFrame: FrameData) { + val indicatorSize = min(this.size.height, 24.dp.toPx()) + + this.scale( + scaleX = if (currentFrame.gestureDirection == InputDirection.Max) 1f else -1f, + scaleY = 1f, + ) { + val color = if (currentFrame.isStable) Color.Green else Color.Red + val strokeWidth = 1.dp.toPx() + val d1 = indicatorSize / 2f + val d2 = indicatorSize / 3f + + translate(left = 2.dp.toPx()) { + drawLine( + color, + Offset(center.x - d2, center.y - d1), + center, + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + drawLine( + color, + Offset(center.x - d2, center.y + d1), + center, + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + } + translate(left = -2.dp.toPx()) { + drawLine( + color, + Offset(center.x - d2, center.y - d1), + center, + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + drawLine( + color, + Offset(center.x - d2, center.y + d1), + center, + strokeWidth = strokeWidth, + cap = StrokeCap.Round, + ) + } + } +} + +private fun DrawScope.drawInputOutputTrail( + history: List, + inputRange: ClosedFloatingPointRange, + outputRange: ClosedFloatingPointRange, + 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/debug/MotionValueDebugger.kt b/mechanics/src/com/android/mechanics/debug/MotionValueDebugger.kt new file mode 100644 index 0000000..3c0109d --- /dev/null +++ b/mechanics/src/com/android/mechanics/debug/MotionValueDebugger.kt @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2025 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.mutableStateListOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.node.DelegatableNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.node.TraversableNode +import androidx.compose.ui.node.findNearestAncestor +import androidx.compose.ui.platform.InspectorInfo +import com.android.mechanics.MotionValue +import com.android.mechanics.debug.MotionValueDebuggerNode.Companion.TRAVERSAL_NODE_KEY +import kotlinx.coroutines.DisposableHandle + +/** State for the [MotionValueDebugger]. */ +sealed interface MotionValueDebuggerState { + val observedMotionValues: List +} + +/** Factory for [MotionValueDebugger]. */ +fun MotionValueDebuggerState(): MotionValueDebuggerState { + return MotionValueDebuggerStateImpl() +} + +/** Collector for [MotionValue]s in the Node subtree that should be observed for debug purposes. */ +fun Modifier.motionValueDebugger(state: MotionValueDebuggerState): Modifier = + this.then(MotionValueDebuggerElement(state as MotionValueDebuggerStateImpl)) + +/** + * [motionValueDebugger]'s interface, nodes in the subtree of a [motionValueDebugger] can retrieve + * it using [findMotionValueDebugger]. + */ +sealed interface MotionValueDebugger { + fun register(motionValue: MotionValue): DisposableHandle +} + +/** Finds a [MotionValueDebugger] that was registered via a [motionValueDebugger] modifier. */ +fun DelegatableNode.findMotionValueDebugger(): MotionValueDebugger? { + return findNearestAncestor(TRAVERSAL_NODE_KEY) as? MotionValueDebugger +} + +/** Registers the motion value for debugging with the parent [MotionValue]. */ +fun Modifier.debugMotionValue(motionValue: MotionValue): Modifier = + this.then(DebugMotionValueElement(motionValue)) + +internal class MotionValueDebuggerNode(internal var state: MotionValueDebuggerStateImpl) : + Modifier.Node(), TraversableNode, MotionValueDebugger { + + override val traverseKey = TRAVERSAL_NODE_KEY + + override fun register(motionValue: MotionValue): DisposableHandle { + val state = state + state.observedMotionValues.add(motionValue) + return DisposableHandle { state.observedMotionValues.remove(motionValue) } + } + + companion object { + const val TRAVERSAL_NODE_KEY = "com.android.mechanics.debug.DEBUG_CONNECTOR_NODE_KEY" + } +} + +private data class MotionValueDebuggerElement(val state: MotionValueDebuggerStateImpl) : + ModifierNodeElement() { + override fun create(): MotionValueDebuggerNode = MotionValueDebuggerNode(state) + + override fun InspectorInfo.inspectableProperties() { + // Intentionally empty + } + + override fun update(node: MotionValueDebuggerNode) { + check(node.state === state) + } +} + +internal class DebugMotionValueNode(motionValue: MotionValue) : Modifier.Node() { + + private var debugger: MotionValueDebugger? = null + + internal var motionValue = motionValue + set(value) { + registration?.dispose() + registration = debugger?.register(value) + field = value + } + + internal var registration: DisposableHandle? = null + + override fun onAttach() { + debugger = findMotionValueDebugger() + registration = debugger?.register(motionValue) + } + + override fun onDetach() { + debugger = null + registration?.dispose() + registration = null + } +} + +private data class DebugMotionValueElement(val motionValue: MotionValue) : + ModifierNodeElement() { + override fun create(): DebugMotionValueNode = DebugMotionValueNode(motionValue) + + override fun InspectorInfo.inspectableProperties() { + // Intentionally empty + } + + override fun update(node: DebugMotionValueNode) { + node.motionValue = motionValue + } +} + +internal class MotionValueDebuggerStateImpl : MotionValueDebuggerState { + override val observedMotionValues: MutableList = mutableStateListOf() +} diff --git a/mechanics/src/com/android/mechanics/effects/Fixed.kt b/mechanics/src/com/android/mechanics/effects/Fixed.kt new file mode 100644 index 0000000..b1c5fb2 --- /dev/null +++ b/mechanics/src/com/android/mechanics/effects/Fixed.kt @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2025 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.effects + +import com.android.mechanics.spec.BreakpointKey +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.builder.Effect +import com.android.mechanics.spec.builder.EffectApplyScope +import com.android.mechanics.spec.builder.EffectPlacement +import com.android.mechanics.spec.builder.MotionBuilderContext +import com.android.mechanics.spec.builder.MotionSpecBuilderScope + +/** Creates a [FixedValue] effect with the given [value]. */ +fun MotionSpecBuilderScope.fixed(value: Float) = FixedValue(value) + +val MotionSpecBuilderScope.zero: FixedValue + get() = FixedValue.Zero +val MotionSpecBuilderScope.one: FixedValue + get() = FixedValue.One + +/** Produces a fixed [value]. */ +class FixedValue(val value: Float) : + Effect.PlaceableAfter, Effect.PlaceableBefore, Effect.PlaceableBetween { + + override fun MotionBuilderContext.intrinsicSize(): Float = Float.NaN + + override fun EffectApplyScope.createSpec( + minLimit: Float, + minLimitKey: BreakpointKey, + maxLimit: Float, + maxLimitKey: BreakpointKey, + placement: EffectPlacement, + ) { + return unidirectional(Mapping.Fixed(value)) + } + + companion object { + val Zero = FixedValue(0f) + val One = FixedValue(1f) + } +} diff --git a/mechanics/src/com/android/mechanics/effects/MagneticDetach.kt b/mechanics/src/com/android/mechanics/effects/MagneticDetach.kt new file mode 100644 index 0000000..1e4e38b --- /dev/null +++ b/mechanics/src/com/android/mechanics/effects/MagneticDetach.kt @@ -0,0 +1,259 @@ +/* + * Copyright (C) 2025 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(ExperimentalMaterial3ExpressiveApi::class) + +package com.android.mechanics.effects + +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import com.android.mechanics.spec.BreakpointKey +import com.android.mechanics.spec.ChangeSegmentHandlers.PreventDirectionChangeWithinCurrentSegment +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.SegmentKey +import com.android.mechanics.spec.SemanticKey +import com.android.mechanics.spec.builder.Effect +import com.android.mechanics.spec.builder.EffectApplyScope +import com.android.mechanics.spec.builder.EffectPlacemenType +import com.android.mechanics.spec.builder.EffectPlacement +import com.android.mechanics.spec.builder.MotionBuilderContext +import com.android.mechanics.spec.with +import com.android.mechanics.spring.SpringParameters + +/** + * Gesture effect that emulates effort to detach an element from its resting position. + * + * @param semanticState semantic state used to check the state of this effect. + * @param detachPosition distance from the origin to detach + * @param attachPosition distance from the origin to re-attach + * @param detachScale fraction of input changes propagated during detach. + * @param attachScale fraction of input changes propagated after re-attach. + * @param detachSpring spring used during detach + * @param attachSpring spring used during attach + */ +class MagneticDetach( + private val semanticState: SemanticKey = Defaults.AttachDetachState, + private val semanticAttachedValue: SemanticKey = Defaults.AttachedValue, + private val detachPosition: Dp = Defaults.DetachPosition, + private val attachPosition: Dp = Defaults.AttachPosition, + private val detachScale: Float = Defaults.AttachDetachScale, + private val attachScale: Float = Defaults.AttachDetachScale * (attachPosition / detachPosition), + private val detachSpring: SpringParameters = Defaults.Spring, + private val attachSpring: SpringParameters = Defaults.Spring, +) : Effect.PlaceableAfter, Effect.PlaceableBefore { + + init { + require(attachPosition <= detachPosition) + } + + enum class State { + Attached, + Detached, + } + + override fun MotionBuilderContext.intrinsicSize(): Float { + return detachPosition.toPx() + } + + override fun EffectApplyScope.createSpec( + minLimit: Float, + minLimitKey: BreakpointKey, + maxLimit: Float, + maxLimitKey: BreakpointKey, + placement: EffectPlacement, + ) { + if (placement.type == EffectPlacemenType.Before) { + createPlacedBeforeSpec(minLimit, minLimitKey, maxLimit, maxLimitKey) + } else { + assert(placement.type == EffectPlacemenType.After) + createPlacedAfterSpec(minLimit, minLimitKey, maxLimit, maxLimitKey) + } + } + + object Defaults { + val AttachDetachState = SemanticKey() + val AttachedValue = SemanticKey() + val AttachDetachScale = .3f + val DetachPosition = 80.dp + val AttachPosition = 40.dp + val Spring = SpringParameters(stiffness = 800f, dampingRatio = 0.95f) + } + + /* Effect is attached at minLimit, and detaches at maxLimit. */ + private fun EffectApplyScope.createPlacedAfterSpec( + minLimit: Float, + minLimitKey: BreakpointKey, + maxLimit: Float, + maxLimitKey: BreakpointKey, + ) { + val attachedValue = baseValue(minLimit) + val detachedValue = baseValue(maxLimit) + val reattachPos = minLimit + attachPosition.toPx() + val reattachValue = baseValue(reattachPos) + + val attachedSemantics = + listOf(semanticState with State.Attached, semanticAttachedValue with attachedValue) + val detachedSemantics = + listOf(semanticState with State.Detached, semanticAttachedValue with null) + + val scaledDetachValue = attachedValue + (detachedValue - attachedValue) * detachScale + val scaledReattachValue = attachedValue + (reattachValue - attachedValue) * attachScale + + val attachKey = BreakpointKey("attach") + forward( + initialMapping = Mapping.Linear(minLimit, attachedValue, maxLimit, scaledDetachValue), + semantics = attachedSemantics, + ) { + after(spring = detachSpring, semantics = detachedSemantics) + before(semantics = listOf(semanticAttachedValue with null)) + } + + backward( + initialMapping = + Mapping.Linear(minLimit, attachedValue, reattachPos, scaledReattachValue), + semantics = attachedSemantics, + ) { + mapping( + breakpoint = reattachPos, + key = attachKey, + spring = attachSpring, + semantics = detachedSemantics, + mapping = baseMapping, + ) + before(semantics = listOf(semanticAttachedValue with null)) + after(semantics = listOf(semanticAttachedValue with null)) + } + + addSegmentHandlers( + beforeDetachSegment = SegmentKey(minLimitKey, maxLimitKey, InputDirection.Max), + beforeAttachSegment = SegmentKey(attachKey, maxLimitKey, InputDirection.Min), + afterAttachSegment = SegmentKey(minLimitKey, attachKey, InputDirection.Min), + minLimit = minLimit, + maxLimit = maxLimit, + ) + } + + /* Effect is attached at maxLimit, and detaches at minLimit. */ + private fun EffectApplyScope.createPlacedBeforeSpec( + minLimit: Float, + minLimitKey: BreakpointKey, + maxLimit: Float, + maxLimitKey: BreakpointKey, + ) { + val attachedValue = baseValue(maxLimit) + val detachedValue = baseValue(minLimit) + val reattachPos = maxLimit - attachPosition.toPx() + val reattachValue = baseValue(reattachPos) + + val attachedSemantics = + listOf(semanticState with State.Attached, semanticAttachedValue with attachedValue) + val detachedSemantics = + listOf(semanticState with State.Detached, semanticAttachedValue with null) + + val scaledDetachValue = attachedValue + (detachedValue - attachedValue) * detachScale + val scaledReattachValue = attachedValue + (reattachValue - attachedValue) * attachScale + + val attachKey = BreakpointKey("attach") + + backward( + initialMapping = Mapping.Linear(minLimit, scaledDetachValue, maxLimit, attachedValue), + semantics = attachedSemantics, + ) { + before(spring = detachSpring, semantics = detachedSemantics) + after(semantics = listOf(semanticAttachedValue with null)) + } + + forward(initialMapping = baseMapping, semantics = detachedSemantics) { + target( + breakpoint = reattachPos, + key = attachKey, + from = scaledReattachValue, + to = attachedValue, + spring = attachSpring, + semantics = attachedSemantics, + ) + after(semantics = listOf(semanticAttachedValue with null)) + } + + addSegmentHandlers( + beforeDetachSegment = SegmentKey(minLimitKey, maxLimitKey, InputDirection.Min), + beforeAttachSegment = SegmentKey(minLimitKey, attachKey, InputDirection.Max), + afterAttachSegment = SegmentKey(attachKey, maxLimitKey, InputDirection.Max), + minLimit = minLimit, + maxLimit = maxLimit, + ) + } + + private fun EffectApplyScope.addSegmentHandlers( + beforeDetachSegment: SegmentKey, + beforeAttachSegment: SegmentKey, + afterAttachSegment: SegmentKey, + minLimit: Float, + maxLimit: Float, + ) { + // Suppress direction change during detach. This prevents snapping to the origin when + // changing the direction while detaching. + addSegmentHandler(beforeDetachSegment, PreventDirectionChangeWithinCurrentSegment) + // Suppress direction when approaching attach. This prevents the detach effect when changing + // direction just before reattaching. + addSegmentHandler(beforeAttachSegment, PreventDirectionChangeWithinCurrentSegment) + + // When changing direction after re-attaching, the pre-detach ratio is tweaked to + // interpolate between the direction change-position and the detach point. + addSegmentHandler(afterAttachSegment) { currentSegment, newInput, newDirection -> + val nextSegment = segmentAtInput(newInput, newDirection) + if (nextSegment.key == beforeDetachSegment) { + nextSegment.copy( + mapping = + switchMappingWithSamePivotValue( + currentSegment.mapping, + nextSegment.mapping, + minLimit, + newInput, + maxLimit, + ) + ) + } else { + nextSegment + } + } + } + + private fun switchMappingWithSamePivotValue( + source: Mapping, + target: Mapping, + minLimit: Float, + pivot: Float, + maxLimit: Float, + ): Mapping { + val minValue = target.map(minLimit) + val pivotValue = source.map(pivot) + val maxValue = target.map(maxLimit) + + return Mapping { input -> + if (input <= pivot) { + val t = (input - minLimit) / (pivot - minLimit) + lerp(minValue, pivotValue, t) + } else { + val t = (input - pivot) / (maxLimit - pivot) + lerp(pivotValue, maxValue, t) + } + } + } +} diff --git a/mechanics/src/com/android/mechanics/effects/Overdrag.kt b/mechanics/src/com/android/mechanics/effects/Overdrag.kt new file mode 100644 index 0000000..af1dca6 --- /dev/null +++ b/mechanics/src/com/android/mechanics/effects/Overdrag.kt @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2025 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.effects + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.android.mechanics.spec.BreakpointKey +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.SemanticKey +import com.android.mechanics.spec.builder.Effect +import com.android.mechanics.spec.builder.EffectApplyScope +import com.android.mechanics.spec.builder.EffectPlacement +import com.android.mechanics.spec.builder.MotionBuilderContext +import com.android.mechanics.spec.with + +/** Gesture effect to soft-limit. */ +class Overdrag( + private val overdragLimit: SemanticKey = Defaults.OverdragLimit, + private val maxOverdrag: Dp = Defaults.MaxOverdrag, + private val tilt: Float = Defaults.tilt, +) : Effect.PlaceableBefore, Effect.PlaceableAfter { + + override fun MotionBuilderContext.intrinsicSize() = Float.POSITIVE_INFINITY + + override fun EffectApplyScope.createSpec( + minLimit: Float, + minLimitKey: BreakpointKey, + maxLimit: Float, + maxLimitKey: BreakpointKey, + placement: EffectPlacement, + ) { + + val maxOverdragPx = maxOverdrag.toPx() + + val limitValue = baseValue(placement.start) + val mapping = Mapping { input -> + val baseMapped = baseMapping.map(input) + + maxOverdragPx * kotlin.math.tanh((baseMapped - limitValue) / (maxOverdragPx * tilt)) + + limitValue + } + + unidirectional(mapping, listOf(overdragLimit with limitValue)) { + if (!placement.isForward) { + after(semantics = listOf(overdragLimit with null)) + } + } + } + + object Defaults { + val OverdragLimit = SemanticKey() + val MaxOverdrag = 30.dp + val tilt = 3f + } +} diff --git a/mechanics/src/com/android/mechanics/effects/RevealOnThreshold.kt b/mechanics/src/com/android/mechanics/effects/RevealOnThreshold.kt new file mode 100644 index 0000000..124f031 --- /dev/null +++ b/mechanics/src/com/android/mechanics/effects/RevealOnThreshold.kt @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2025 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.effects + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastCoerceAtMost +import com.android.mechanics.spec.BreakpointKey +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.builder.Effect +import com.android.mechanics.spec.builder.EffectApplyScope +import com.android.mechanics.spec.builder.EffectPlacement + +/** An effect that reveals a component when the available space reaches a certain threshold. */ +data class RevealOnThreshold(val minSize: Dp = Defaults.MinSize) : Effect.PlaceableBetween { + init { + require(minSize >= 0.dp) + } + + override fun EffectApplyScope.createSpec( + minLimit: Float, + minLimitKey: BreakpointKey, + maxLimit: Float, + maxLimitKey: BreakpointKey, + placement: EffectPlacement, + ) { + val maxSize = maxLimit - minLimit + val minSize = minSize.toPx().fastCoerceAtMost(maxSize) + + unidirectional(initialMapping = Mapping.Zero) { + before(mapping = Mapping.Zero) + + target(breakpoint = minLimit + minSize, from = minSize, to = maxSize) + + after(mapping = Mapping.Fixed(maxSize)) + } + } + + object Defaults { + val MinSize: Dp = 8.dp + } +} diff --git a/mechanics/src/com/android/mechanics/impl/ComputationInput.kt b/mechanics/src/com/android/mechanics/impl/ComputationInput.kt new file mode 100644 index 0000000..23ac183 --- /dev/null +++ b/mechanics/src/com/android/mechanics/impl/ComputationInput.kt @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2025 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.impl + +import com.android.mechanics.MotionValue +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.SpringState + +/** Static configuration that remains constant over a MotionValue's lifecycle. */ +internal interface StaticConfig { + /** + * A threshold value (in output units) that determines when the [MotionValue]'s internal spring + * animation is considered stable. + */ + val stableThreshold: Float + + /** Optional label for identifying a MotionValue for debugging purposes. */ + val label: String? +} + +/** The up-to-date [MotionValue] input, used by [Computations] to calculate the updated output. */ +internal interface CurrentFrameInput { + val spec: MotionSpec + val currentInput: Float + val currentAnimationTimeNanos: Long + val currentDirection: InputDirection + val currentGestureDragOffset: Float +} + +/** + * The [MotionValue] state of the last completed frame. + * + * The values must be published at the start of the frame, together with the + * [CurrentFrameInput.currentAnimationTimeNanos]. + */ +internal interface LastFrameState { + /** + * The segment in use, defined by the min/max [Breakpoint]s and the [Mapping] in between. This + * implicitly also captures the [InputDirection] and [MotionSpec]. + */ + val lastSegment: SegmentData + /** + * 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. + */ + val lastGuaranteeState: GuaranteeState + /** + * 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. + */ + val lastAnimation: DiscontinuityAnimation + /** + * Last frame's spring state, based on initial origin values in [lastAnimation], carried-forward + * to [lastFrameTimeNanos]. + */ + val lastSpringState: SpringState + /** The time of the last frame, in nanoseconds. */ + val lastFrameTimeNanos: Long + /** The [currentInput] of the last frame */ + val lastInput: Float + val lastGestureDragOffset: Float + + val directMappedVelocity: Float +} diff --git a/mechanics/src/com/android/mechanics/impl/Computations.kt b/mechanics/src/com/android/mechanics/impl/Computations.kt new file mode 100644 index 0000000..2ac9574 --- /dev/null +++ b/mechanics/src/com/android/mechanics/impl/Computations.kt @@ -0,0 +1,576 @@ +/* + * Copyright (C) 2025 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.impl + +import android.util.Log +import androidx.compose.ui.util.fastCoerceAtLeast +import androidx.compose.ui.util.fastCoerceIn +import androidx.compose.ui.util.fastIsFinite +import androidx.compose.ui.util.lerp +import com.android.mechanics.MotionValue.Companion.TAG +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.spec.SemanticKey +import com.android.mechanics.spring.SpringState +import com.android.mechanics.spring.calculateUpdatedState + +internal abstract class Computations : CurrentFrameInput, LastFrameState, StaticConfig { + internal class ComputedValues( + val segment: SegmentData, + val guarantee: GuaranteeState, + val animation: DiscontinuityAnimation, + ) + + // currentComputedValues input + private var memoizedSpec: MotionSpec? = null + private var memoizedInput: Float = Float.MIN_VALUE + private var memoizedAnimationTimeNanos: Long = Long.MIN_VALUE + private var memoizedDirection: InputDirection = InputDirection.Min + + // currentComputedValues output + private lateinit var memoizedComputedValues: ComputedValues + + internal val currentComputedValues: ComputedValues + get() { + val currentSpec: MotionSpec = spec + val currentInput: Float = currentInput + val currentAnimationTimeNanos: Long = currentAnimationTimeNanos + val currentDirection: InputDirection = currentDirection + + if ( + memoizedSpec == currentSpec && + memoizedInput == currentInput && + memoizedAnimationTimeNanos == currentAnimationTimeNanos && + memoizedDirection == currentDirection + ) { + return memoizedComputedValues + } + + memoizedSpec = currentSpec + memoizedInput = currentInput + memoizedAnimationTimeNanos = currentAnimationTimeNanos + memoizedDirection = currentDirection + + val segment: SegmentData = + computeSegmentData( + spec = currentSpec, + input = currentInput, + direction = currentDirection, + ) + + val segmentChange: SegmentChangeType = + getSegmentChangeType( + segment = segment, + input = currentInput, + direction = currentDirection, + ) + + val guarantee: GuaranteeState = + computeGuaranteeState( + segment = segment, + segmentChange = segmentChange, + input = currentInput, + ) + + val animation: DiscontinuityAnimation = + computeAnimation( + segment = segment, + guarantee = guarantee, + segmentChange = segmentChange, + spec = currentSpec, + input = currentInput, + animationTimeNanos = currentAnimationTimeNanos, + ) + + return ComputedValues(segment, guarantee, animation).also { + memoizedComputedValues = it + } + } + + // currentSpringState input + private var memoizedAnimation: DiscontinuityAnimation? = null + private var memoizedTimeNanos: Long = Long.MIN_VALUE + + // currentSpringState output + private var memoizedSpringState: SpringState = SpringState.AtRest + + val currentSpringState: SpringState + get() { + val animation = currentComputedValues.animation + val timeNanos = currentAnimationTimeNanos + if (memoizedAnimation == animation && memoizedTimeNanos == timeNanos) { + return memoizedSpringState + } + memoizedAnimation = animation + memoizedTimeNanos = timeNanos + return computeSpringState(animation, timeNanos).also { memoizedSpringState = it } + } + + val isSameSegmentAndAtRest: Boolean + get() = + lastSpringState == SpringState.AtRest && + lastSegment.spec == spec && + lastSegment.isValidForInput(currentInput, currentDirection) + + val output: Float + get() = + if (isSameSegmentAndAtRest) { + lastSegment.mapping.map(currentInput) + } else { + outputTarget + currentSpringState.displacement + } + + val outputTarget: Float + get() = + if (isSameSegmentAndAtRest) { + lastSegment.mapping.map(currentInput) + } else { + currentComputedValues.segment.mapping.map(currentInput) + } + + val isStable: Boolean + get() = + if (isSameSegmentAndAtRest) { + true + } else { + currentSpringState == SpringState.AtRest + } + + fun semanticState(semanticKey: SemanticKey): T? { + return with(if (isSameSegmentAndAtRest) lastSegment else currentComputedValues.segment) { + spec.semanticState(semanticKey, key) + } + } + + fun computeDirectMappedVelocity(frameDurationNanos: Long): Float { + val directMappedDelta = + if ( + lastSegment.spec == spec && + lastSegment.isValidForInput(currentInput, currentDirection) + ) { + lastSegment.mapping.map(currentInput) - lastSegment.mapping.map(lastInput) + } else { + val springChange = currentSpringState.displacement - lastSpringState.displacement + + currentComputedValues.segment.mapping.map(currentInput) - + lastSegment.mapping.map(lastInput) + springChange + } + + val frameDuration = frameDurationNanos / 1_000_000_000.0 + return (directMappedDelta / frameDuration).toFloat() + } + + /** + * The current segment, which defines the [Mapping] function used to transform the input to the + * output. + * + * While both [spec] and [direction] remain the same, and [input] is within the segment (see + * [SegmentData.isValidForInput]), this is [LastFrameState.lastSegment]. + * + * Otherwise, [MotionSpec.onChangeSegment] is queried for an up-dated segment. + */ + private fun computeSegmentData( + spec: MotionSpec, + input: Float, + direction: InputDirection, + ): SegmentData { + val specChanged = lastSegment.spec != spec + return if (specChanged || !lastSegment.isValidForInput(input, direction)) { + spec.onChangeSegment(lastSegment, input, direction) + } else { + lastSegment + } + } + + /** Computes the [SegmentChangeType] between [LastFrameState.lastSegment] and [segment]. */ + private fun getSegmentChangeType( + segment: SegmentData, + input: Float, + direction: InputDirection, + ): SegmentChangeType { + if (segment.key == lastSegment.key) { + return SegmentChangeType.Same + } + + if ( + segment.key.minBreakpoint == lastSegment.key.minBreakpoint && + segment.key.maxBreakpoint == lastSegment.key.maxBreakpoint + ) { + return SegmentChangeType.SameOppositeDirection + } + + val currentSpec = segment.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(input, direction).key + if (segment.key != lastSpecSegmentForSameInput) { + // Note: this might not be correct if the new [MotionSpec.segmentHandlers] were + // involved. + return SegmentChangeType.Spec + } + } + + return if (segment.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, + lastInput: Float, + input: Float, + ): Float { + return ((position - lastInput) / (input - lastInput)).fastCoerceIn(0f, 1f) + } + + /** + * The [GuaranteeState] for [segment]. + * + * 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 [segment]. Any remaining + * [LastFrameState.lastGuaranteeState] will be consumed in [currentAnimation]. + */ + private fun computeGuaranteeState( + segment: SegmentData, + segmentChange: SegmentChangeType, + input: Float, + ): GuaranteeState { + val entryBreakpoint = segment.entryBreakpoint + + // First, determine the origin of the guarantee computations + val guaranteeOriginState = + when (segmentChange) { + // 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 GuaranteeState.Inactive + // The spec changes, there is no guarantee associated with the animation. + SegmentChangeType.Spec -> return 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 -> input + is Guarantee.GestureDragDelta -> currentGestureDragOffset + is Guarantee.None -> return 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.GestureDragDelta -> { + // Guess the GestureDragDelta origin - since the gesture dragOffset + // is sampled, interpolate it according to when the breakpoint was + // crossed in the last frame. + val fractionalBreakpointPos = + lastFrameFractionOfPosition( + entryBreakpoint.position, + lastInput, + input, + ) + + lerp( + lastGestureDragOffset, + currentGestureDragOffset, + fractionalBreakpointPos, + ) + } + + // No guarantee to enforce. + is Guarantee.None -> return GuaranteeState.Inactive + } + ) + } + } + + // Finally, update the origin state with the current guarantee value. + return guaranteeOriginState.withCurrentValue( + when (entryBreakpoint.guarantee) { + is Guarantee.InputDelta -> input + is Guarantee.GestureDragDelta -> currentGestureDragOffset + is Guarantee.None -> return GuaranteeState.Inactive + }, + segment.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 [guarantee] requires the spring to be tightened. + * + * See [currentSpringState] for the continuously updated, animated spring values. + */ + private fun computeAnimation( + segment: SegmentData, + guarantee: GuaranteeState, + segmentChange: SegmentChangeType, + spec: MotionSpec, + input: Float, + animationTimeNanos: Long, + ): DiscontinuityAnimation { + return when (segmentChange) { + SegmentChangeType.Same -> { + if (lastSpringState == SpringState.AtRest) { + // Nothing to update if no animation is ongoing + DiscontinuityAnimation.None + } else if (lastGuaranteeState == guarantee) { + // Nothing to update if the spring must not be tightened. + lastAnimation + } else { + // Compute the updated spring parameters + val tightenedSpringParameters = + guarantee.updatedSpringParameters(segment.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 currentMapping = segment.mapping.map(input) + val lastMapping = lastSegment.mapping.map(input) + val delta = currentMapping - lastMapping + + val deltaIsFinite = delta.fastIsFinite() + if (!deltaIsFinite) { + Log.wtf( + TAG, + "Delta between mappings is undefined!\n" + + " MotionValue: $label\n" + + " input: $input\n" + + " lastMapping: $lastMapping (lastSegment: $lastSegment)\n" + + " currentMapping: $currentMapping (currentSegment: $segment)", + ) + } + + if (delta == 0f || !deltaIsFinite) { + // Nothing new to animate. + lastAnimation + } else { + val springParameters = + if (segmentChange == SegmentChangeType.Direction) { + segment.entryBreakpoint.spring + } else { + spec.resetSpring + } + + val newTarget = delta - lastSpringState.displacement + DiscontinuityAnimation( + SpringState(-newTarget, lastSpringState.velocity + directMappedVelocity), + 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 = segment.direction + + with(spec[currentDirection]) { + val targetIndex = findSegmentIndex(segment.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 springParameters = lastAnimation.springParameters + var initialSpringVelocity = directMappedVelocity + + var segmentIndex = sourceIndex + while (segmentIndex != targetIndex) { + val nextBreakpoint = + breakpoints[segmentIndex + directionOffset.fastCoerceAtLeast(0)] + + val nextBreakpointFrameFraction = + lastFrameFractionOfPosition(nextBreakpoint.position, lastInput, input) + + val nextBreakpointCrossTime = + lerp( + lastFrameTimeNanos, + animationTimeNanos, + nextBreakpointFrameFraction, + ) + if ( + guaranteeState != GuaranteeState.Inactive && + springState != SpringState.AtRest + ) { + val guaranteeValueAtNextBreakpoint = + when (lastBreakpoint.guarantee) { + is Guarantee.InputDelta -> nextBreakpoint.position + is Guarantee.GestureDragDelta -> + lerp( + lastGestureDragOffset, + currentGestureDragOffset, + 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 mappingBefore = mappings[segmentIndex] + val beforeBreakpoint = mappingBefore.map(nextBreakpoint.position) + val mappingAfter = mappings[segmentIndex + directionOffset] + val afterBreakpoint = mappingAfter.map(nextBreakpoint.position) + + val delta = afterBreakpoint - beforeBreakpoint + val deltaIsFinite = delta.fastIsFinite() + if (deltaIsFinite) { + if (delta != 0f) { + // There is a discontinuity on this breakpoint, that needs to be + // animated. The delta is pushed to the spring, to consume the + // discontinuity over time. + springState = + springState.nudge( + displacementDelta = -delta, + velocityDelta = initialSpringVelocity, + ) + + // When *first* crossing a discontinuity in a given frame, the + // static mapped velocity observed during previous frame is added as + // initial velocity to the spring. This is done ot most once per + // frame, and only if there is an actual discontinuity. + initialSpringVelocity = 0f + } + } else { + // The before and / or after mapping produced an non-finite number, + // which is not allowed. This intentionally crashes eng-builds, since + // it's a bug in the Mapping implementation that must be fixed. On + // regular builds, it will likely cause a jumpcut. + Log.wtf( + TAG, + "Delta between breakpoints is undefined!\n" + + " MotionValue: ${label}\n" + + " position: ${nextBreakpoint.position}\n" + + " before: $beforeBreakpoint (mapping: $mappingBefore)\n" + + " after: $afterBreakpoint (mapping: $mappingAfter)", + ) + } + + segmentIndex += directionOffset + lastBreakpoint = nextBreakpoint + guaranteeState = + when (nextBreakpoint.guarantee) { + is Guarantee.InputDelta -> + GuaranteeState.withStartValue(nextBreakpoint.position) + + is Guarantee.GestureDragDelta -> + GuaranteeState.withStartValue( + lerp( + lastGestureDragOffset, + currentGestureDragOffset, + nextBreakpointFrameFraction, + ) + ) + + is Guarantee.None -> GuaranteeState.Inactive + } + } + + val tightened = guarantee.updatedSpringParameters(segment.entryBreakpoint) + + DiscontinuityAnimation(springState, tightened, lastAnimationTime) + } + } + } + } + + private fun computeSpringState( + animation: DiscontinuityAnimation, + timeNanos: Long, + ): SpringState { + with(animation) { + if (isAtRest) return SpringState.AtRest + + val nanosSinceAnimationStart = timeNanos - springStartTimeNanos + val updatedSpringState = + springStartState.calculateUpdatedState(nanosSinceAnimationStart, springParameters) + + return if (updatedSpringState.isStable(springParameters, stableThreshold)) { + SpringState.AtRest + } else { + updatedSpringState + } + } + } +} diff --git a/mechanics/src/com/android/mechanics/impl/DiscontinuityAnimation.kt b/mechanics/src/com/android/mechanics/impl/DiscontinuityAnimation.kt new file mode 100644 index 0000000..b0deb75 --- /dev/null +++ b/mechanics/src/com/android/mechanics/impl/DiscontinuityAnimation.kt @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2025 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.impl + +import com.android.mechanics.spring.SpringParameters +import com.android.mechanics.spring.SpringState + +/** + * 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 springStartState: SpringState, + val springParameters: SpringParameters, + val springStartTimeNanos: Long, +) { + val isAtRest: Boolean + get() = springStartState == SpringState.AtRest + + companion object { + val None = + DiscontinuityAnimation( + springStartState = SpringState.AtRest, + springParameters = SpringParameters.Snap, + springStartTimeNanos = 0L, + ) + } +} diff --git a/mechanics/src/com/android/mechanics/impl/GuaranteeState.kt b/mechanics/src/com/android/mechanics/impl/GuaranteeState.kt new file mode 100644 index 0000000..0c4f291 --- /dev/null +++ b/mechanics/src/com/android/mechanics/impl/GuaranteeState.kt @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2025 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.impl + +import androidx.compose.ui.util.fastCoerceAtLeast +import androidx.compose.ui.util.packFloats +import androidx.compose.ui.util.unpackFloat1 +import androidx.compose.ui.util.unpackFloat2 +import com.android.mechanics.spec.Breakpoint +import com.android.mechanics.spec.Guarantee +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spring.SpringParameters +import kotlin.math.max + +/** + * 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).fastCoerceAtLeast(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.GestureDragDelta -> guarantee.delta + } + + 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/impl/SegmentChangeType.kt b/mechanics/src/com/android/mechanics/impl/SegmentChangeType.kt new file mode 100644 index 0000000..b8c68bc --- /dev/null +++ b/mechanics/src/com/android/mechanics/impl/SegmentChangeType.kt @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2025 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.impl + +/** + * Describes how the [currentSegment] is different from last frame's [lastSegment]. + * + * This affects how the discontinuities are animated and [Guarantee]s applied. + */ +internal 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, +} diff --git a/mechanics/src/com/android/mechanics/spec/Breakpoint.kt b/mechanics/src/com/android/mechanics/spec/Breakpoint.kt new file mode 100644 index 0000000..5ff18ed --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/Breakpoint.kt @@ -0,0 +1,120 @@ +/* + * 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.spec + +import androidx.compose.ui.util.fastIsFinite +import com.android.mechanics.spring.SpringParameters + +/** + * Key to identify a breakpoint in a [DirectionalMotionSpec]. + * + * @param debugLabel name of the breakpoint, for tooling and debugging. + * @param identity is used to check the equality of two key instances. + */ +class BreakpointKey(val debugLabel: String? = null, val identity: Any = Object()) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as BreakpointKey + + return identity == other.identity + } + + override fun hashCode(): Int { + return identity.hashCode() + } + + override fun toString(): String { + return "BreakpointKey(${debugLabel ?: ""}" + + "@${System.identityHashCode(identity).toString(16).padStart(8,'0')})" + } + + internal companion object { + val MinLimit = BreakpointKey("built-in::min") + val MaxLimit = BreakpointKey("built-in::max") + } +} + +/** + * Specification of a breakpoint, in the context of a [DirectionalMotionSpec]. + * + * The [spring] and [guarantee] define the physics animation for the discontinuity at this + * breakpoint.They are applied in the direction of the containing [DirectionalMotionSpec]. + * + * This [Breakpoint]'s animation definition is valid while the input is within the next segment. If + * the animation is still in progress when the input value reaches the next breakpoint, the + * remaining animation will be blended with the animation starting at the next breakpoint. + * + * @param key Identity of the [Breakpoint], unique within a [DirectionalMotionSpec]. + * @param position The position of the [Breakpoint], in the domain of the `MotionValue`'s input. + * @param spring Parameters of the spring used to animate the breakpoints discontinuity. + * @param guarantee Optional constraints to accelerate the completion of the spring motion, based on + * `MotionValue`'s input or other non-time signals. + */ +data class Breakpoint( + val key: BreakpointKey, + val position: Float, + val spring: SpringParameters, + val guarantee: Guarantee, +) : Comparable { + + init { + when (key) { + BreakpointKey.MinLimit -> require(position == Float.NEGATIVE_INFINITY) + BreakpointKey.MaxLimit -> require(position == Float.POSITIVE_INFINITY) + else -> require(position.fastIsFinite()) + } + } + + companion object { + /** First breakpoint of each spec. */ + val minLimit = + Breakpoint( + BreakpointKey.MinLimit, + Float.NEGATIVE_INFINITY, + SpringParameters.Snap, + Guarantee.None, + ) + + /** Last breakpoint of each spec. */ + val maxLimit = + Breakpoint( + BreakpointKey.MaxLimit, + Float.POSITIVE_INFINITY, + SpringParameters.Snap, + Guarantee.None, + ) + + internal fun create( + breakpointKey: BreakpointKey, + breakpointPosition: Float, + springSpec: SpringParameters, + guarantee: Guarantee, + ): Breakpoint { + return when (breakpointKey) { + BreakpointKey.MinLimit -> minLimit + BreakpointKey.MaxLimit -> maxLimit + else -> Breakpoint(breakpointKey, breakpointPosition, springSpec, guarantee) + } + } + } + + override fun compareTo(other: Breakpoint): Int { + return position.compareTo(other.position) + } +} diff --git a/mechanics/src/com/android/mechanics/spec/Guarantee.kt b/mechanics/src/com/android/mechanics/spec/Guarantee.kt new file mode 100644 index 0000000..12981cc --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/Guarantee.kt @@ -0,0 +1,45 @@ +/* + * 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.spec + +/** + * Describes the condition by which a discontinuity at a breakpoint must have finished animating. + * + * With a guarantee in effect, the spring parameters will be continuously adjusted, ensuring the + * guarantee's target will be met. + */ +sealed class Guarantee { + /** + * No guarantee is provided. + * + * The spring animation will proceed at its natural pace, regardless of the input or gesture's + * progress. + */ + data object None : Guarantee() + + /** + * Guarantees that the animation will be complete before the input value is [delta] away from + * the [Breakpoint] position. + */ + data class InputDelta(val delta: Float) : Guarantee() + + /** + * Guarantees to complete the animation before the gesture is [delta] away from the gesture + * position captured when the breakpoint was crossed. + */ + data class GestureDragDelta(val delta: Float) : Guarantee() +} diff --git a/mechanics/src/com/android/mechanics/spec/InputDirection.kt b/mechanics/src/com/android/mechanics/spec/InputDirection.kt new file mode 100644 index 0000000..58fa590 --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/InputDirection.kt @@ -0,0 +1,31 @@ +/* + * 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.spec + +/** + * The intrinsic direction of the input value. + * + * It reflects the user's intent, that is its meant to be derived from a gesture. If the input is + * driven by an animation, the direction is expected to not change. + * + * The directions are labelled [Min] and [Max] to reflect descending and ascending input values + * respectively, but it does not imply an spatial direction. + */ +enum class InputDirection(val sign: Int) { + Min(sign = -1), + Max(sign = +1), +} diff --git a/mechanics/src/com/android/mechanics/spec/MotionSpec.kt b/mechanics/src/com/android/mechanics/spec/MotionSpec.kt new file mode 100644 index 0000000..4628804 --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/MotionSpec.kt @@ -0,0 +1,239 @@ +/* + * 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.spec + +import androidx.compose.ui.util.fastFirstOrNull +import com.android.mechanics.spring.SpringParameters + +/** + * Specification for the mapping of input values to output values. + * + * The spec consists of two independent directional spec's, while only one the one matching + * `MotionInput`'s `direction` is used at any given time. + * + * @param maxDirection spec used when the MotionInput's direction is [InputDirection.Max] + * @param minDirection spec used when the MotionInput's direction is [InputDirection.Min] + * @param resetSpring spring parameters to animate a difference in output, if the difference is + * caused by setting this new spec. + * @param segmentHandlers allow for custom segment-change logic, when the `MotionValue` runtime + * would leave the [SegmentKey]. + */ +data class MotionSpec( + val maxDirection: DirectionalMotionSpec, + val minDirection: DirectionalMotionSpec = maxDirection, + val resetSpring: SpringParameters = DefaultResetSpring, + val segmentHandlers: Map = emptyMap(), +) { + + /** The [DirectionalMotionSpec] for the specified [direction]. */ + operator fun get(direction: InputDirection): DirectionalMotionSpec { + return when (direction) { + InputDirection.Min -> minDirection + InputDirection.Max -> maxDirection + } + } + + /** Whether this spec contains a segment with the specified [segmentKey]. */ + fun containsSegment(segmentKey: SegmentKey): Boolean { + return get(segmentKey.direction).findSegmentIndex(segmentKey) != -1 + } + + /** + * The semantic state for [key] at segment with [segmentKey]. + * + * Returns `null` if no semantic value with [key] is defined. Throws [NoSuchElementException] if + * [segmentKey] does not exist in this [MotionSpec]. + */ + fun semanticState(key: SemanticKey, segmentKey: SegmentKey): T? { + with(get(segmentKey.direction)) { + val semanticValues = semantics.fastFirstOrNull { it.key == key } ?: return null + val segmentIndex = findSegmentIndex(segmentKey) + if (segmentIndex < 0) throw NoSuchElementException() + + @Suppress("UNCHECKED_CAST") + return semanticValues.values[segmentIndex] as T + } + } + + /** + * All [SemanticValue]s associated with the segment identified with [segmentKey]. + * + * Throws [NoSuchElementException] if [segmentKey] does not exist in this [MotionSpec]. + */ + fun semantics(segmentKey: SegmentKey): List> { + with(get(segmentKey.direction)) { + val segmentIndex = findSegmentIndex(segmentKey) + if (segmentIndex < 0) throw NoSuchElementException() + + return semantics.map { it[segmentIndex] } + } + } + + /** + * The [SegmentData] for an input with the specified [position] and [direction]. + * + * The returned [SegmentData] will be cached while [SegmentData.isValidForInput] returns `true`. + */ + fun segmentAtInput(position: Float, direction: InputDirection): SegmentData { + require(position.isFinite()) + + return with(get(direction)) { + var idx = findBreakpointIndex(position) + if (direction == InputDirection.Min && breakpoints[idx].position == position) { + // The segment starts at `position`. Since the breakpoints are sorted ascending, no + // matter the spec's direction, need to return the previous segment in the min + // direction. + idx-- + } + + SegmentData( + this@MotionSpec, + breakpoints[idx], + breakpoints[idx + 1], + direction, + mappings[idx], + ) + } + } + + /** + * Looks up the new [SegmentData] once the [currentSegment] is not valid for an input with + * [newPosition] and [newDirection]. + * + * This will delegate to the [segmentHandlers], if registered for the [currentSegment]'s key. + */ + internal fun onChangeSegment( + currentSegment: SegmentData, + newPosition: Float, + newDirection: InputDirection, + ): SegmentData { + val segmentChangeHandler = segmentHandlers[currentSegment.key] + return segmentChangeHandler?.invoke(this, currentSegment, newPosition, newDirection) + ?: segmentAtInput(newPosition, newDirection) + } + + override fun toString() = toDebugString() + + companion object { + /** + * Default spring parameters for the reset spring. Matches the Fast Spatial spring of the + * standard motion scheme. + */ + private val DefaultResetSpring = SpringParameters(stiffness = 1400f, dampingRatio = 1f) + + /* Empty motion spec, the output is the same as the input. */ + val Empty = MotionSpec(DirectionalMotionSpec.Empty) + } +} + +/** + * Defines the [breakpoints], as well as the [mappings] in-between adjacent [Breakpoint] pairs. + * + * This [DirectionalMotionSpec] is applied in the direction defined by the containing [MotionSpec]: + * especially the direction in which the `breakpoint` [Guarantee] are applied depend on how this is + * used; this type does not have an inherit direction. + * + * All [breakpoints] are sorted in ascending order by their `position`, with the first and last + * breakpoints are guaranteed to be sentinel values for negative and positive infinity respectively. + * + * @param breakpoints All breakpoints in the spec, must contain [Breakpoint.minLimit] as the first + * element, and [Breakpoint.maxLimit] as the last element. + * @param mappings All mappings in between the breakpoints, thus must always contain + * `breakpoints.size - 1` elements. + * @param semantics semantics provided by this spec, must only reference to breakpoint keys included + * in [breakpoints]. + */ +data class DirectionalMotionSpec( + val breakpoints: List, + val mappings: List, + val semantics: List> = emptyList(), +) { + /** Maps all [BreakpointKey]s used in this spec to its index in [breakpoints]. */ + private val breakpointIndexByKey: Map + + init { + require(breakpoints.size >= 2) + require(breakpoints.first() == Breakpoint.minLimit) + require(breakpoints.last() == Breakpoint.maxLimit) + require(breakpoints.zipWithNext { a, b -> a <= b }.all { it }) { + "Breakpoints are not sorted ascending ${breakpoints.map { "${it.key}@${it.position}" }}" + } + require(mappings.size == breakpoints.size - 1) + + breakpointIndexByKey = + breakpoints.mapIndexed { index, breakpoint -> breakpoint.key to index }.toMap() + + semantics.forEach { + require(it.values.size == mappings.size) { + "Semantics ${it.key} contains ${it.values.size} values vs ${mappings.size} expected" + } + } + } + + /** + * Returns the index of the closest breakpoint where `Breakpoint.position <= position`. + * + * Guaranteed to be a valid index into [breakpoints], and guaranteed to be neither the first nor + * the last element. + * + * @param position the position in the input domain. + * @return Index into [breakpoints], guaranteed to be in range `1..breakpoints.size - 2` + */ + fun findBreakpointIndex(position: Float): Int { + require(position.isFinite()) + val breakpointPosition = breakpoints.binarySearchBy(position) { it.position } + + val result = + when { + // position is between two anchors, return the min one. + breakpointPosition < 0 -> -breakpointPosition - 2 + else -> breakpointPosition + } + + check(result >= 0) + check(result < breakpoints.size - 1) + + return result + } + + /** + * The index of the breakpoint with the specified [breakpointKey], or `-1` if no such breakpoint + * exists. + */ + fun findBreakpointIndex(breakpointKey: BreakpointKey): Int { + return breakpointIndexByKey[breakpointKey] ?: -1 + } + + /** Index into [mappings] for the specified [segmentKey], or `-1` if no such segment exists. */ + fun findSegmentIndex(segmentKey: SegmentKey): Int { + val result = breakpointIndexByKey[segmentKey.minBreakpoint] ?: return -1 + if (breakpoints[result + 1].key != segmentKey.maxBreakpoint) return -1 + + return result + } + + override fun toString() = toDebugString() + + companion object { + /* Empty spec, the full input domain is mapped to output using [Mapping.identity]. */ + val Empty = + DirectionalMotionSpec( + listOf(Breakpoint.minLimit, Breakpoint.maxLimit), + listOf(Mapping.Identity), + ) + } +} diff --git a/mechanics/src/com/android/mechanics/spec/MotionSpecDebugFormatter.kt b/mechanics/src/com/android/mechanics/spec/MotionSpecDebugFormatter.kt new file mode 100644 index 0000000..9c7f9bd --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/MotionSpecDebugFormatter.kt @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2025 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.spec + +/** Returns a string representation of the [MotionSpec] for debugging by humans. */ +fun MotionSpec.toDebugString(): String { + return buildString { + if (minDirection == maxDirection) { + appendLine("unidirectional:") + appendLine(minDirection.toDebugString().prependIndent(" ")) + } else { + appendLine("maxDirection:") + appendLine(maxDirection.toDebugString().prependIndent(" ")) + appendLine("minDirection:") + appendLine(minDirection.toDebugString().prependIndent(" ")) + } + + if (segmentHandlers.isNotEmpty()) { + appendLine("segmentHandlers:") + segmentHandlers.keys.forEach { + appendIndent(2) + appendSegmentKey(it) + appendLine() + } + } + } + .trim() +} + +/** Returns a string representation of the [DirectionalMotionSpec] for debugging by humans. */ +fun DirectionalMotionSpec.toDebugString(): String { + return buildString { + appendBreakpointLine(breakpoints.first()) + for (i in mappings.indices) { + appendMappingLine(mappings[i], indent = 2) + semantics.forEach { appendSemanticsLine(it.key, it.values[i], indent = 4) } + appendBreakpointLine(breakpoints[i + 1]) + } + } + .trim() +} + +private fun StringBuilder.appendIndent(indent: Int) { + repeat(indent) { append(' ') } +} + +private fun StringBuilder.appendBreakpointLine(breakpoint: Breakpoint, indent: Int = 0) { + appendIndent(indent) + append("@") + append(breakpoint.position) + + append(" [") + appendBreakpointKey(breakpoint.key) + append("]") + + if (breakpoint.guarantee != Guarantee.None) { + append(" guarantee=") + append(breakpoint.key.debugLabel) + } + + if (!breakpoint.spring.isSnapSpring) { + append(" spring=") + append(breakpoint.spring.stiffness) + append("/") + append(breakpoint.spring.dampingRatio) + } + + appendLine() +} + +private fun StringBuilder.appendBreakpointKey(key: BreakpointKey) { + if (key.debugLabel != null) { + append(key.debugLabel) + append("|") + } + append("id:0x") + append(System.identityHashCode(key.identity).toString(16).padStart(8, '0')) +} + +private fun StringBuilder.appendSegmentKey(key: SegmentKey) { + appendBreakpointKey(key.minBreakpoint) + if (key.direction == InputDirection.Min) append(" << ") else append(" >> ") + appendBreakpointKey(key.maxBreakpoint) +} + +private fun StringBuilder.appendMappingLine(mapping: Mapping, indent: Int = 0) { + appendIndent(indent) + append(mapping.toString()) + appendLine() +} + +private fun StringBuilder.appendSemanticsLine( + semanticKey: SemanticKey<*>, + value: Any?, + indent: Int = 0, +) { + appendIndent(indent) + + append(semanticKey.debugLabel) + append("[id:0x") + append(System.identityHashCode(semanticKey.identity).toString(16).padStart(8, '0')) + append("]") + + append("=") + append(value) + appendLine() +} diff --git a/mechanics/src/com/android/mechanics/spec/Segment.kt b/mechanics/src/com/android/mechanics/spec/Segment.kt new file mode 100644 index 0000000..d3bce7b --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/Segment.kt @@ -0,0 +1,155 @@ +/* + * 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.spec + +/** + * Identifies a segment in a [MotionSpec]. + * + * A segment only exists between two adjacent [Breakpoint]s; it cannot span multiple breakpoints. + * The [direction] indicates to the relevant [DirectionalMotionSpec] of the [MotionSpec]. + * + * The position of the [minBreakpoint] must be less or equal to the position of the [maxBreakpoint]. + */ +data class SegmentKey( + val minBreakpoint: BreakpointKey, + val maxBreakpoint: BreakpointKey, + val direction: InputDirection, +) { + override fun toString(): String { + return "SegmentKey(min=$minBreakpoint, max=$maxBreakpoint, direction=$direction)" + } +} + +/** + * Captures denormalized segment data from a [MotionSpec]. + * + * Instances are created by the [MotionSpec] and used by the [MotionValue] runtime to compute the + * output value. By default, the [SegmentData] is cached while [isValidForInput] returns true. + * + * The [SegmentData] has an intrinsic direction, thus the segment has an entry and exit side, at the + * respective breakpoint. + */ +data class SegmentData( + val spec: MotionSpec, + val minBreakpoint: Breakpoint, + val maxBreakpoint: Breakpoint, + val direction: InputDirection, + val mapping: Mapping, +) { + val key = SegmentKey(minBreakpoint.key, maxBreakpoint.key, direction) + + /** + * Whether the given [inputPosition] and [inputDirection] should be handled by this segment. + * + * The input is considered invalid only if the direction changes or the input is *at or outside* + * the segment on the exit-side. The input remains intentionally valid outside the segment on + * the entry-side, to avoid flip-flopping. + */ + fun isValidForInput(inputPosition: Float, inputDirection: InputDirection): Boolean { + if (inputDirection != direction) return false + + return when (inputDirection) { + InputDirection.Max -> inputPosition < maxBreakpoint.position + InputDirection.Min -> inputPosition > minBreakpoint.position + } + } + + /** + * The breakpoint at the side of the segment's start. + * + * The [entryBreakpoint]'s [Guarantee] is the relevant guarantee for this segment. + */ + val entryBreakpoint: Breakpoint + get() = + when (direction) { + InputDirection.Max -> minBreakpoint + InputDirection.Min -> maxBreakpoint + } + + /** Semantic value for the given [semanticKey]. */ + fun semantic(semanticKey: SemanticKey): T? { + return spec.semanticState(semanticKey, key) + } + + val range: ClosedFloatingPointRange + get() = minBreakpoint.position..maxBreakpoint.position + + override fun toString(): String { + return "SegmentData(key=$key, range=$range, mapping=$mapping)" + } +} + +/** + * Maps the `input` of a [MotionValue] to the desired output value. + * + * The mapping implementation can be arbitrary, but must not produce discontinuities. + */ +fun interface Mapping { + /** Computes the [MotionValue]'s target output, given the input. */ + fun map(input: Float): Float + + /** `f(x) = x` */ + object Identity : Mapping { + override fun map(input: Float): Float { + return input + } + + override fun toString(): String { + return "Identity" + } + } + + /** `f(x) = value` */ + data class Fixed(val value: Float) : Mapping { + init { + require(value.isFinite()) + } + + override fun map(input: Float): Float { + return value + } + } + + /** `f(x) = factor*x + offset` */ + data class Linear(val factor: Float, val offset: Float = 0f) : Mapping { + init { + require(factor.isFinite()) + require(offset.isFinite()) + } + + override fun map(input: Float): Float { + return input * factor + offset + } + } + + companion object { + val Zero = Fixed(0f) + val One = Fixed(1f) + val Two = Fixed(2f) + + /** Create a linear mapping defined as a line between {in0,out0} and {in1,out1}. */ + fun Linear(in0: Float, out0: Float, in1: Float, out1: Float): Linear { + require(in0 != in1) { + "Cannot define a linear function with both inputs being the same ($in0)." + } + + val factor = (out1 - out0) / (in1 - in0) + val offset = out0 - factor * in0 + return Linear(factor, offset) + } + } +} diff --git a/mechanics/src/com/android/mechanics/spec/SegmentChangeHandler.kt b/mechanics/src/com/android/mechanics/spec/SegmentChangeHandler.kt new file mode 100644 index 0000000..b6ce6ab --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/SegmentChangeHandler.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2025 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.spec + +/** + * Handler to allow for custom segment-change logic. + * + * This handler is called whenever the new input (position or direction) does not match + * [currentSegment] anymore (see [SegmentData.isValidForInput]). + * + * This is intended to implement custom effects on direction-change. + * + * Implementations can return: + * 1. [currentSegment] to delay/suppress segment change. + * 2. `null` to use the default segment lookup based on [newPosition] and [newDirection] + * 3. manually looking up segments on this [MotionSpec] + * 4. create a [SegmentData] that is not in the spec. + */ +typealias OnChangeSegmentHandler = + MotionSpec.( + currentSegment: SegmentData, newPosition: Float, newDirection: InputDirection, + ) -> SegmentData? + +/** Generic change segment handlers. */ +object ChangeSegmentHandlers { + /** Prevents direction changes, as long as the input is still valid on the current segment. */ + val PreventDirectionChangeWithinCurrentSegment: OnChangeSegmentHandler = + { currentSegment, newInput, newDirection -> + currentSegment.takeIf { + newDirection != currentSegment.direction && + it.isValidForInput(newInput, currentSegment.direction) + } + } +} diff --git a/mechanics/src/com/android/mechanics/spec/SemanticValue.kt b/mechanics/src/com/android/mechanics/spec/SemanticValue.kt new file mode 100644 index 0000000..8adf61a --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/SemanticValue.kt @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2025 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.spec + +/** + * Identifies a "semantic state" of a [MotionValue]. + * + * Semantic states can be supplied by a [MotionSpec], and allows expose semantic information on the + * logical state a [MotionValue] is in. + */ +class SemanticKey(val type: Class, val debugLabel: String, val identity: Any = Object()) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SemanticKey<*> + + return identity == other.identity + } + + override fun hashCode(): Int { + return identity.hashCode() + } + + override fun toString(): String { + return "Semantics($debugLabel)" + } +} + +/** Creates a new semantic key of type [T], identified by [identity]. */ +inline fun SemanticKey( + debugLabel: String = T::class.java.simpleName, + identity: Any = Object(), +) = SemanticKey(T::class.java, debugLabel, identity) + +/** Pair of semantic [key] and [value]. */ +data class SemanticValue(val key: SemanticKey, val value: T) + +/** + * Creates a [SemanticValue] tuple from [SemanticKey] `this` with [value]. + * + * This can be useful for creating [SemanticValue] literals with less noise. + */ +infix fun SemanticKey.with(value: T) = SemanticValue(this, value) + +/** + * Defines semantics values for [key], one per segment. + * + * This [values] are required to align with the segments of the [DirectionalMotionSpec] the instance + * will be passed to. The class has no particular value outside of a [DirectionalMotionSpec]. + */ +class SegmentSemanticValues(val key: SemanticKey, val values: List) { + + /** Retrieves the [SemanticValue] at [segmentIndex]. */ + operator fun get(segmentIndex: Int): SemanticValue { + return SemanticValue(key, values[segmentIndex]) + } + + override fun toString() = "Semantics($key): [$values]" +} diff --git a/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderImpl.kt b/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderImpl.kt new file mode 100644 index 0000000..994927f --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderImpl.kt @@ -0,0 +1,388 @@ +/* + * Copyright (C) 2025 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.spec.builder + +import com.android.mechanics.spec.Breakpoint +import com.android.mechanics.spec.BreakpointKey +import com.android.mechanics.spec.DirectionalMotionSpec +import com.android.mechanics.spec.Guarantee +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.SegmentSemanticValues +import com.android.mechanics.spec.SemanticKey +import com.android.mechanics.spec.SemanticValue +import com.android.mechanics.spring.SpringParameters + +/** + * Internal, reusable implementation of the [DirectionalBuilderScope]. + * + * Clients must use [directionalMotionSpec] instead. + */ +internal open class DirectionalBuilderImpl( + override val defaultSpring: SpringParameters, + baseSemantics: List>, +) : DirectionalBuilderScope { + internal val breakpoints = mutableListOf(Breakpoint.minLimit) + internal val semantics = mutableListOf>() + internal val mappings = mutableListOf() + private var sourceValue: Float = Float.NaN + private var targetValue: Float = Float.NaN + private var fractionalMapping: Float = Float.NaN + private var breakpointPosition: Float = Float.NaN + private var breakpointKey: BreakpointKey? = null + + init { + baseSemantics.forEach { getSemantics(it.key).apply { set(0, it.value) } } + } + + /** Prepares the builder for invoking the [DirectionalBuilderFn] on it. */ + fun prepareBuilderFn( + initialMapping: Mapping = Mapping.Identity, + initialSemantics: List> = emptyList(), + ) { + check(mappings.size == breakpoints.size - 1) + + mappings.add(initialMapping) + val semanticIndex = mappings.size - 1 + initialSemantics.forEach { semantic -> + getSemantics(semantic.key).apply { set(semanticIndex, semantic.value) } + } + } + + internal fun getSemantics(key: SemanticKey): SegmentSemanticValuesBuilder { + @Suppress("UNCHECKED_CAST") + var builder = semantics.firstOrNull { it.key == key } as SegmentSemanticValuesBuilder? + if (builder == null) { + builder = SegmentSemanticValuesBuilder(key).also { semantics.add(it) } + } + return builder + } + + /** + * Finalizes open segments, after invoking a [DirectionalBuilderFn]. + * + * Afterwards, either [build] or another pair of {[prepareBuilderFn], [finalizeBuilderFn]} calls + * can be done. + */ + fun finalizeBuilderFn( + atPosition: Float, + key: BreakpointKey, + springSpec: SpringParameters, + guarantee: Guarantee, + semantics: List>, + ) { + if (!(targetValue.isNaN() && fractionalMapping.isNaN())) { + // Finalizing will produce the mapping and breakpoint + check(mappings.size == breakpoints.size - 1) + } else { + // Mapping is already added, this will add the breakpoint + check(mappings.size == breakpoints.size) + } + + if (key == BreakpointKey.MaxLimit) { + check(targetValue.isNaN()) { "cant specify target value for last segment" } + check(semantics.isEmpty()) { "cant specify semantics for last breakpoint" } + } else { + check(atPosition.isFinite()) + check(atPosition > breakpoints.last().position) { + "Breakpoint ${breakpoints.last()} placed after partial sequence (end=$atPosition)" + } + } + + toBreakpointImpl(atPosition, key, semantics) + doAddBreakpointImpl(springSpec, guarantee) + } + + fun finalizeBuilderFn(breakpoint: Breakpoint) = + finalizeBuilderFn( + breakpoint.position, + breakpoint.key, + breakpoint.spring, + breakpoint.guarantee, + emptyList(), + ) + + /* Creates the [DirectionalMotionSpec] from the current builder state. */ + fun build(): DirectionalMotionSpec { + require(mappings.size == breakpoints.size - 1) + check(breakpoints.last() == Breakpoint.maxLimit) + + val segmentCount = mappings.size + + val semantics = semantics.map { builder -> with(builder) { build(segmentCount) } } + + return DirectionalMotionSpec(breakpoints.toList(), mappings.toList(), semantics) + } + + override fun target( + breakpoint: Float, + from: Float, + to: Float, + spring: SpringParameters, + guarantee: Guarantee, + key: BreakpointKey, + semantics: List>, + ) { + toBreakpointImpl(breakpoint, key, semantics) + jumpToImpl(from, spring, guarantee) + continueWithTargetValueImpl(to) + } + + override fun targetFromCurrent( + breakpoint: Float, + to: Float, + delta: Float, + spring: SpringParameters, + guarantee: Guarantee, + key: BreakpointKey, + semantics: List>, + ) { + toBreakpointImpl(breakpoint, key, semantics) + jumpByImpl(delta, spring, guarantee) + continueWithTargetValueImpl(to) + } + + override fun fractionalInput( + breakpoint: Float, + from: Float, + fraction: Float, + spring: SpringParameters, + guarantee: Guarantee, + key: BreakpointKey, + semantics: List>, + ): CanBeLastSegment { + toBreakpointImpl(breakpoint, key, semantics) + jumpToImpl(from, spring, guarantee) + continueWithFractionalInputImpl(fraction) + return CanBeLastSegmentImpl + } + + override fun fractionalInputFromCurrent( + breakpoint: Float, + fraction: Float, + delta: Float, + spring: SpringParameters, + guarantee: Guarantee, + key: BreakpointKey, + semantics: List>, + ): CanBeLastSegment { + toBreakpointImpl(breakpoint, key, semantics) + jumpByImpl(delta, spring, guarantee) + continueWithFractionalInputImpl(fraction) + return CanBeLastSegmentImpl + } + + override fun fixedValue( + breakpoint: Float, + value: Float, + spring: SpringParameters, + guarantee: Guarantee, + key: BreakpointKey, + semantics: List>, + ): CanBeLastSegment { + toBreakpointImpl(breakpoint, key, semantics) + jumpToImpl(value, spring, guarantee) + continueWithFixedValueImpl() + return CanBeLastSegmentImpl + } + + override fun fixedValueFromCurrent( + breakpoint: Float, + delta: Float, + spring: SpringParameters, + guarantee: Guarantee, + key: BreakpointKey, + semantics: List>, + ): CanBeLastSegment { + toBreakpointImpl(breakpoint, key, semantics) + jumpByImpl(delta, spring, guarantee) + continueWithFixedValueImpl() + return CanBeLastSegmentImpl + } + + override fun mapping( + breakpoint: Float, + spring: SpringParameters, + guarantee: Guarantee, + key: BreakpointKey, + semantics: List>, + mapping: Mapping, + ): CanBeLastSegment { + toBreakpointImpl(breakpoint, key, semantics) + continueWithImpl(mapping, spring, guarantee) + return CanBeLastSegmentImpl + } + + private fun continueWithTargetValueImpl(target: Float) { + check(sourceValue.isFinite()) + + targetValue = target + } + + private fun continueWithFractionalInputImpl(fraction: Float) { + check(sourceValue.isFinite()) + + fractionalMapping = fraction + } + + private fun continueWithFixedValueImpl() { + check(sourceValue.isFinite()) + + mappings.add(Mapping.Fixed(sourceValue)) + sourceValue = Float.NaN + } + + private fun jumpToImpl(value: Float, spring: SpringParameters, guarantee: Guarantee) { + check(sourceValue.isNaN()) + + doAddBreakpointImpl(spring, guarantee) + sourceValue = value + } + + private fun jumpByImpl(delta: Float, spring: SpringParameters, guarantee: Guarantee) { + check(sourceValue.isNaN()) + + val breakpoint = doAddBreakpointImpl(spring, guarantee) + sourceValue = mappings.last().map(breakpoint.position) + delta + } + + private fun continueWithImpl(mapping: Mapping, spring: SpringParameters, guarantee: Guarantee) { + check(sourceValue.isNaN()) + + doAddBreakpointImpl(spring, guarantee) + mappings.add(mapping) + } + + private fun toBreakpointImpl( + atPosition: Float, + key: BreakpointKey, + semantics: List>, + ) { + check(breakpointPosition.isNaN()) + check(breakpointKey == null) + + check(atPosition >= breakpoints.last().position) { + "Breakpoint position specified is before last breakpoint" + } + + if (!targetValue.isNaN() || !fractionalMapping.isNaN()) { + check(!sourceValue.isNaN()) + + val sourcePosition = breakpoints.last().position + val breakpointDistance = atPosition - sourcePosition + val mapping = + if (breakpointDistance == 0f) { + Mapping.Fixed(sourceValue) + } else { + + if (fractionalMapping.isNaN()) { + val delta = targetValue - sourceValue + fractionalMapping = delta / (atPosition - sourcePosition) + } else { + val delta = (atPosition - sourcePosition) * fractionalMapping + targetValue = sourceValue + delta + } + + val offset = sourceValue - (sourcePosition * fractionalMapping) + Mapping.Linear(fractionalMapping, offset) + } + + mappings.add(mapping) + targetValue = Float.NaN + sourceValue = Float.NaN + fractionalMapping = Float.NaN + } + + breakpointPosition = atPosition + breakpointKey = key + + semantics.forEach { (key, value) -> + getSemantics(key).apply { + // Last segment is guaranteed to be completed + set(mappings.size, value) + } + } + } + + private fun doAddBreakpointImpl( + springSpec: SpringParameters, + guarantee: Guarantee, + ): Breakpoint { + val breakpoint = + Breakpoint.create( + checkNotNull(breakpointKey), + breakpointPosition, + springSpec, + guarantee, + ) + + breakpoints.add(breakpoint) + breakpointPosition = Float.NaN + breakpointKey = null + + return breakpoint + } +} + +internal class SegmentSemanticValuesBuilder(val key: SemanticKey) { + private val values = mutableListOf>() + private val unspecified = SemanticValueHolder.Unspecified() + + @Suppress("UNCHECKED_CAST") + fun set(segmentIndex: Int, value: V) { + if (segmentIndex < values.size) { + values[segmentIndex] = SemanticValueHolder.Specified(value as T) + } else { + backfill(segmentCount = segmentIndex) + values.add(SemanticValueHolder.Specified(value as T)) + } + } + + @Suppress("UNCHECKED_CAST") + fun updateBefore(segmentIndex: Int, value: V) { + require(segmentIndex < values.size) + + val specified = SemanticValueHolder.Specified(value as T) + + for (i in segmentIndex downTo 0) { + if (values[i] is SemanticValueHolder.Specified) break + values[i] = specified + } + } + + fun build(segmentCount: Int): SegmentSemanticValues { + backfill(segmentCount) + val firstValue = values.firstNotNullOf { it as? SemanticValueHolder.Specified }.value + return SegmentSemanticValues( + key, + values.drop(1).runningFold(firstValue) { lastValue, thisHolder -> + if (thisHolder is SemanticValueHolder.Specified) thisHolder.value else lastValue + }, + ) + } + + private fun backfill(segmentCount: Int) { + repeat(segmentCount - values.size) { values.add(unspecified) } + } +} + +internal sealed interface SemanticValueHolder { + class Specified(val value: T) : SemanticValueHolder + + class Unspecified() : SemanticValueHolder +} + +private data object CanBeLastSegmentImpl : CanBeLastSegment diff --git a/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderScope.kt b/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderScope.kt new file mode 100644 index 0000000..9eacd8f --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/builder/DirectionalBuilderScope.kt @@ -0,0 +1,273 @@ +/* + * Copyright (C) 2025 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.spec.builder + +import com.android.mechanics.spec.BreakpointKey +import com.android.mechanics.spec.DirectionalMotionSpec +import com.android.mechanics.spec.Guarantee +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.SemanticKey +import com.android.mechanics.spec.SemanticValue +import com.android.mechanics.spring.SpringParameters + +/** Builder function signature. */ +typealias DirectionalBuilderFn = DirectionalBuilderScope.() -> CanBeLastSegment + +/** + * Defines the contract for building a [DirectionalMotionSpec]. + * + * Provides methods to define breakpoints and mappings for the motion specification. + */ +interface DirectionalBuilderScope { + /** The default [SpringParameters] used for breakpoints. */ + val defaultSpring: SpringParameters + + /** + * Ends the current segment at the [breakpoint] position and defines the next segment to + * linearly interpolate from a starting value ([from]) to the desired target value ([to]). + * + * Note: This segment cannot be used as the last segment in the specification, as it requires a + * subsequent breakpoint to define the target value. + * + * @param breakpoint The breakpoint defining the end of the current segment and the start of the + * next. + * @param from The output value at the previous breakpoint, explicitly setting the starting + * point for the linear mapping. + * @param to The desired output value at the new breakpoint. + * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to + * [defaultSpring]. + * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None]. + * @param key A unique [BreakpointKey] for this breakpoint. Defaults to a newly generated key. + * @param semantics Updated semantics values to be applied. Must be a subset of the + * [SemanticKey]s used when first creating this builder. + */ + fun target( + breakpoint: Float, + from: Float, + to: Float, + spring: SpringParameters = defaultSpring, + guarantee: Guarantee = Guarantee.None, + key: BreakpointKey = BreakpointKey(), + semantics: List> = emptyList(), + ) + + /** + * Ends the current segment at the [breakpoint] position and defines the next segment to + * linearly interpolate from the current output value (optionally with an offset of [delta]) to + * the desired target value ([to]). + * + * Note: This segment cannot be used as the last segment in the specification, as it requires a + * subsequent breakpoint to define the target value. + * + * @param breakpoint The breakpoint defining the end of the current segment and the start of the + * next. + * @param to The desired output value at the new breakpoint. + * @param delta An optional offset to apply to the calculated starting value. Defaults to 0f. + * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to + * [defaultSpring]. + * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None]. + * @param key A unique [BreakpointKey] for this breakpoint. Defaults to a newly generated key. + * @param semantics Updated semantics values to be applied. Must be a subset of the + * [SemanticKey]s used when first creating this builder. + */ + fun targetFromCurrent( + breakpoint: Float, + to: Float, + delta: Float = 0f, + spring: SpringParameters = defaultSpring, + guarantee: Guarantee = Guarantee.None, + key: BreakpointKey = BreakpointKey(), + semantics: List> = emptyList(), + ) + + /** + * Ends the current segment at the [breakpoint] position and defines the next segment to + * linearly interpolate from a starting value ([from]) and then continue with a fractional input + * ([fraction]). + * + * Note: This segment can be used as the last segment in the specification. + * + * @param breakpoint The breakpoint defining the end of the current segment and the start of the + * next. + * @param from The output value at the previous breakpoint, explicitly setting the starting + * point for the linear mapping. + * @param fraction The fractional multiplier applied to the input difference between + * breakpoints. + * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to + * [defaultSpring]. + * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None]. + * @param key A unique [BreakpointKey] for this breakpoint. Defaults to a newly generated key. + * @param semantics Updated semantics values to be applied. Must be a subset of the + * [SemanticKey]s used when first creating this builder. + */ + fun fractionalInput( + breakpoint: Float, + from: Float, + fraction: Float, + spring: SpringParameters = defaultSpring, + guarantee: Guarantee = Guarantee.None, + key: BreakpointKey = BreakpointKey(), + semantics: List> = emptyList(), + ): CanBeLastSegment + + /** + * Ends the current segment at the [breakpoint] position and defines the next segment to + * linearly interpolate from the current output value (optionally with an offset of [delta]) and + * then continue with a fractional input ([fraction]). + * + * Note: This segment can be used as the last segment in the specification. + * + * @param breakpoint The breakpoint defining the end of the current segment and the start of the + * next. + * @param fraction The fractional multiplier applied to the input difference between + * breakpoints. + * @param delta An optional offset to apply to the calculated starting value. Defaults to 0f. + * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to + * [defaultSpring]. + * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None]. + * @param key A unique [BreakpointKey] for this breakpoint. Defaults to a newly generated key. + * @param semantics Updated semantics values to be applied. Must be a subset of the + * [SemanticKey]s used when first creating this builder. + */ + fun fractionalInputFromCurrent( + breakpoint: Float, + fraction: Float, + delta: Float = 0f, + spring: SpringParameters = defaultSpring, + guarantee: Guarantee = Guarantee.None, + key: BreakpointKey = BreakpointKey(), + semantics: List> = emptyList(), + ): CanBeLastSegment + + /** + * Ends the current segment at the [breakpoint] position and defines the next segment to output + * a fixed value ([value]). + * + * Note: This segment can be used as the last segment in the specification. + * + * @param breakpoint The breakpoint defining the end of the current segment and the start of the + * next. + * @param value The constant output value for this segment. + * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to + * [defaultSpring]. + * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None]. + * @param key A unique [BreakpointKey] for this breakpoint. Defaults to a newly generated key. + * @param semantics Updated semantics values to be applied. Must be a subset of the + * [SemanticKey]s used when first creating this builder. + */ + fun fixedValue( + breakpoint: Float, + value: Float, + spring: SpringParameters = defaultSpring, + guarantee: Guarantee = Guarantee.None, + key: BreakpointKey = BreakpointKey(), + semantics: List> = emptyList(), + ): CanBeLastSegment + + /** + * Ends the current segment at the [breakpoint] position and defines the next segment to output + * a constant value derived from the current output value (optionally with an offset of + * [delta]). + * + * Note: This segment can be used as the last segment in the specification. + * + * @param breakpoint The breakpoint defining the end of the current segment and the start of the + * next. + * @param delta An optional offset to apply to the mapped value to determine the fixed value. + * Defaults to 0f. + * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to + * [defaultSpring]. + * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None]. + * @param key A unique [BreakpointKey] for this breakpoint. Defaults to a newly generated key. + * @param semantics Updated semantics values to be applied. Must be a subset of the + * [SemanticKey]s used when first creating this builder. + */ + fun fixedValueFromCurrent( + breakpoint: Float, + delta: Float = 0f, + spring: SpringParameters = defaultSpring, + guarantee: Guarantee = Guarantee.None, + key: BreakpointKey = BreakpointKey(), + semantics: List> = emptyList(), + ): CanBeLastSegment + + /** + * Ends the current segment at the [breakpoint] position and defines the next segment using the + * provided [mapping]. + * + * Note: This segment can be used as the last segment in the specification. + * + * @param breakpoint The breakpoint defining the end of the current segment and the start of the + * next. + * @param spring The [SpringParameters] for the transition to this breakpoint. Defaults to + * [defaultSpring]. + * @param guarantee The animation guarantee for this transition. Defaults to [Guarantee.None]. + * @param key A unique [BreakpointKey] for this breakpoint. Defaults to a newly generated key. + * @param semantics Updated semantics values to be applied. Must be a subset of the + * [SemanticKey]s used when first creating this builder. + * @param mapping The custom [Mapping] to use. + */ + fun mapping( + breakpoint: Float, + spring: SpringParameters = defaultSpring, + guarantee: Guarantee = Guarantee.None, + key: BreakpointKey = BreakpointKey(), + semantics: List> = emptyList(), + mapping: Mapping, + ): CanBeLastSegment + + /** + * Ends the current segment at the [breakpoint] position and defines the next segment to produce + * the input value as output (optionally with an offset of [delta]). + * + * Note: This segment can be used as the last segment in the specification. + * + * @param breakpoint The breakpoint defining the end of the current segment and the start of the + * next. + * @param delta An optional offset to apply to the mapped value to determine the fixed value. + * @param spring The [SpringParameters] for the transition to this breakpoint. + * @param guarantee The animation guarantee for this transition. + * @param key A unique [BreakpointKey] for this breakpoint. + * @param semantics Updated semantics values to be applied. Must be a subset of the + * [SemanticKey]s used when first creating this builder. + */ + fun identity( + breakpoint: Float, + delta: Float = 0f, + spring: SpringParameters = defaultSpring, + guarantee: Guarantee = Guarantee.None, + key: BreakpointKey = BreakpointKey(), + semantics: List> = emptyList(), + ): CanBeLastSegment { + return if (delta == 0f) { + mapping(breakpoint, spring, guarantee, key, semantics, Mapping.Identity) + } else { + fractionalInput( + breakpoint, + fraction = 1f, + from = breakpoint + delta, + spring = spring, + guarantee = guarantee, + key = key, + semantics = semantics, + ) + } + } +} + +/** Marker interface to indicate that a segment can be the last one in a [DirectionalMotionSpec]. */ +sealed interface CanBeLastSegment diff --git a/mechanics/src/com/android/mechanics/spec/builder/DirectionalSpecBuilder.kt b/mechanics/src/com/android/mechanics/spec/builder/DirectionalSpecBuilder.kt new file mode 100644 index 0000000..b4483b7 --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/builder/DirectionalSpecBuilder.kt @@ -0,0 +1,128 @@ +/* + * Copyright (C) 2025 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.spec.builder + +import com.android.mechanics.spec.Breakpoint +import com.android.mechanics.spec.DirectionalMotionSpec +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.SegmentSemanticValues +import com.android.mechanics.spec.SemanticValue +import com.android.mechanics.spring.SpringParameters + +/** + * Builds a [DirectionalMotionSpec] for spatial values by defining a sequence of ([Breakpoint], + * [Mapping]) pairs + * + * The [initialMapping] is [Mapping.Identity], and the Material spatial.default spring is used, + * unless otherwise specified. + * + * @see directionalMotionSpec + */ +fun MotionBuilderContext.spatialDirectionalMotionSpec( + initialMapping: Mapping = Mapping.Identity, + semantics: List> = emptyList(), + defaultSpring: SpringParameters = this.spatial.default, + init: DirectionalBuilderFn, +) = directionalMotionSpec(defaultSpring, initialMapping, semantics, init) + +/** + * Builds a [DirectionalMotionSpec] for effects values by defining a sequence of ([Breakpoint], + * [Mapping]) pairs + * + * The [initialMapping] is [Mapping.Zero], and the Material effects.default spring is used, unless + * otherwise specified. + * + * @see directionalMotionSpec + */ +fun MotionBuilderContext.effectsDirectionalMotionSpec( + initialMapping: Mapping = Mapping.Zero, + semantics: List> = emptyList(), + defaultSpring: SpringParameters = this.effects.default, + init: DirectionalBuilderFn, +) = directionalMotionSpec(defaultSpring, initialMapping, semantics, init) + +/** + * Builds a [DirectionalMotionSpec] by defining a sequence of ([Breakpoint], [Mapping]) pairs. + * + * This function simplifies the creation of complex motion specifications. It allows you to define a + * series of motion segments, each with its own behavior, separated by breakpoints. The breakpoints + * and their corresponding segments will always be ordered from min to max value, regardless of how + * the `DirectionalMotionSpec` is applied. + * + * Example Usage: + * ```kotlin + * val motionSpec = directionalMotionSpec( + * defaultSpring = materialSpatial, + * + * // Start as a constant transition, always 0. + * initialMapping = Mapping.Zero + * ) { + * // At breakpoint 10: Linear transition from 0 to 50. + * target(breakpoint = 10f, from = 0f, to = 50f) + * + * // At breakpoint 20: Jump +5, and constant value 55. + * fixedValueFromCurrent(breakpoint = 20f, delta = 5f) + * + * // At breakpoint 30: Jump to 40. Linear mapping using: progress_since_breakpoint * fraction. + * fractionalInput(breakpoint = 30f, from = 40f, fraction = 2f) + * } + * ``` + * + * @param defaultSpring The default [SpringParameters] to use for all breakpoints. + * @param initialMapping The initial [Mapping] for the first segment (defaults to + * [Mapping.Identity]). + * @param init A lambda function that configures the spec using the [DirectionalBuilderScope]. The + * lambda should return a [CanBeLastSegment] to indicate the end of the spec. + * @param semantics Semantics specified in this spec, including the initial value applied for + * [initialMapping]. + * @return The constructed [DirectionalMotionSpec]. + */ +fun directionalMotionSpec( + defaultSpring: SpringParameters, + initialMapping: Mapping = Mapping.Identity, + semantics: List> = emptyList(), + init: DirectionalBuilderFn, +): DirectionalMotionSpec { + return DirectionalBuilderImpl(defaultSpring, semantics) + .apply { + prepareBuilderFn(initialMapping) + init() + finalizeBuilderFn(Breakpoint.maxLimit) + } + .build() +} + +/** + * Builds a simple [DirectionalMotionSpec] with a single segment. + * + * @param mapping The [Mapping] to apply to the segment. Defaults to [Mapping.Identity]. + * @param semantics Semantics values for this spec. + * @return A new [DirectionalMotionSpec] instance configured with the provided parameters. + */ +fun directionalMotionSpec( + mapping: Mapping = Mapping.Identity, + semantics: List> = emptyList(), +): DirectionalMotionSpec { + fun toSegmentSemanticValues(semanticValue: SemanticValue) = + SegmentSemanticValues(semanticValue.key, listOf(semanticValue.value)) + + return DirectionalMotionSpec( + listOf(Breakpoint.minLimit, Breakpoint.maxLimit), + listOf(mapping), + semantics.map { toSegmentSemanticValues(it) }, + ) +} diff --git a/mechanics/src/com/android/mechanics/spec/builder/Effect.kt b/mechanics/src/com/android/mechanics/spec/builder/Effect.kt new file mode 100644 index 0000000..93314c0 --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/builder/Effect.kt @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2025 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.spec.builder + +import com.android.mechanics.spec.BreakpointKey + +/** + * Blueprint for a reusable behavior in a [MotionSpec]. + * + * [Effect] instances are reusable for building multiple + */ +sealed interface Effect { + + /** + * Applies the effect to the motion spec. + * + * The boundaries of the effect are defined by the [minLimit] and [maxLimit] properties, and + * extend in both, the min and max direction by the same amount. + * + * Implementations must invoke either [EffectApplyScope.unidirectional] or both, + * [EffectApplyScope.forward] and [EffectApplyScope.backward]. The motion spec builder will + * throw if neither is called. + */ + fun EffectApplyScope.createSpec( + minLimit: Float, + minLimitKey: BreakpointKey, + maxLimit: Float, + maxLimitKey: BreakpointKey, + placement: EffectPlacement, + ) + + interface PlaceableAfter : Effect { + fun MotionBuilderContext.intrinsicSize(): Float + } + + interface PlaceableBefore : Effect { + fun MotionBuilderContext.intrinsicSize(): Float + } + + interface PlaceableBetween : Effect + + interface PlaceableAt : Effect { + fun MotionBuilderContext.minExtent(): Float + + fun MotionBuilderContext.maxExtent(): Float + } +} + +/** + * Handle for an [Effect] that was placed within a [MotionSpecBuilderScope]. + * + * Used to place effects relative to each other. + */ +@JvmInline value class PlacedEffect internal constructor(internal val id: Int) diff --git a/mechanics/src/com/android/mechanics/spec/builder/EffectApplyScope.kt b/mechanics/src/com/android/mechanics/spec/builder/EffectApplyScope.kt new file mode 100644 index 0000000..920b58b --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/builder/EffectApplyScope.kt @@ -0,0 +1,182 @@ +/* + * Copyright (C) 2025 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.spec.builder + +import com.android.mechanics.spec.Guarantee +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.OnChangeSegmentHandler +import com.android.mechanics.spec.SegmentKey +import com.android.mechanics.spec.SemanticValue +import com.android.mechanics.spring.SpringParameters + +/** + * Defines the contract for applying [Effect]s within a [MotionSpecBuilder] + * + * Provides methods to define breakpoints and mappings for the motion specification. + * + * Breakpoints for [minLimit] and [maxLimit] will be created, with the specified key and parameters. + */ +interface EffectApplyScope : MotionBuilderContext { + /** Default spring in use when not otherwise specified. */ + val defaultSpring: SpringParameters + + /** Mapping used outside of the defined effects. */ + val baseMapping: Mapping + + /** + * Defines spec simultaneously for both, the min and max direction. + * + * The behavior is the same as for `directionalMotionSpec`, with the notable exception that the + * spec to be defined is confined within [minLimit] and [maxLimit]. Specifying breakpoints + * outside of this range will throw. + * + * Will throw if [forward] or [unidirectional] has been called in this scope before. + * + * The first / last semantic value will implicitly extend to the start / end of the resulting + * spec, unless redefined in another spec. + * + * @param initialMapping [Mapping] for the first segment after [minLimit]. + * @param semantics Initial semantics for the effect. + * @param init Configures the effect's spec using [DirectionalBuilderScope]. + * @see com.android.mechanics.spec.directionalMotionSpec for in-depth documentation. + */ + fun unidirectional( + initialMapping: Mapping, + semantics: List> = emptyList(), + init: DirectionalEffectBuilderScope.() -> Unit, + ) + + /** + * Defines spec simultaneously for both, the min and max direction, using a single segment only. + * + * The behavior is the same as for `directionalMotionSpec`, with the notable exception that the + * spec to be defined is confined within [minLimit] and [maxLimit]. + * + * Will throw if [forward] or [unidirectional] has been called in this scope before. + * + * The first / last semantic value will implicitly extend to the start / end of the resulting + * spec, unless redefined in another spec. + * + * @param mapping [Mapping] to be used between [minLimit] and [maxLimit]. + * @param semantics Initial semantics for the effect. + * @see com.android.mechanics.spec.directionalMotionSpec for in depth documentation. + */ + fun unidirectional(mapping: Mapping, semantics: List> = emptyList()) + + /** + * Defines the spec for max direction. + * + * The behavior is the same as for `directionalMotionSpec`, with the notable exception that the + * spec to be defined is confined within [minLimit] and [maxLimit]. Specifying breakpoints + * outside of this range will throw. + * + * Will throw if [forward] or [unidirectional] has been called in this scope before. + * + * The first / last semantic value will implicitly extend to the start / end of the resulting + * spec, unless redefined in another spec. + * + * @param initialMapping [Mapping] for the first segment after [minLimit]. + * @param semantics Initial semantics for the effect. + * @param init Configures the effect's spec using [DirectionalBuilderScope]. + * @see com.android.mechanics.spec.directionalMotionSpec for in-depth documentation. + */ + fun forward( + initialMapping: Mapping, + semantics: List> = emptyList(), + init: DirectionalEffectBuilderScope.() -> Unit, + ) + + /** + * Defines the spec for max direction, using a single segment only. + * + * The behavior is the same as for `directionalMotionSpec`, with the notable exception that the + * spec to be defined is confined within [minLimit] and [maxLimit]. + * + * Will throw if [forward] or [unidirectional] has been called in this scope before. + * + * The first / last semantic value will implicitly extend to the start / end of the resulting + * spec, unless redefined in another spec. + * + * @param mapping [Mapping] to be used between [minLimit] and [maxLimit]. + * @param semantics Initial semantics for the effect. + * @see com.android.mechanics.spec.directionalMotionSpec for in depth documentation. + */ + fun forward(mapping: Mapping, semantics: List> = emptyList()) + + /** + * Defines the spec for min direction. + * + * The behavior is the same as for `directionalMotionSpec`, with the notable exception that the + * spec to be defined is confined within [minLimit] and [maxLimit]. Specifying breakpoints + * outside of this range will throw. + * + * Will throw if [forward] or [unidirectional] has been called in this scope before. + * + * The first / last semantic value will implicitly extend to the start / end of the resulting + * spec, unless redefined in another spec. + * + * @param initialMapping [Mapping] for the first segment after [minLimit]. + * @param semantics Initial semantics for the effect. + * @param init Configures the effect's spec using [DirectionalBuilderScope]. + * @see com.android.mechanics.spec.directionalMotionSpec for in-depth documentation. + */ + fun backward( + initialMapping: Mapping, + semantics: List> = emptyList(), + init: DirectionalEffectBuilderScope.() -> Unit, + ) + + /** + * Defines the spec for min direction, using a single segment only. + * + * The behavior is the same as for `directionalMotionSpec`, with the notable exception that the + * spec to be defined is confined within [minLimit] and [maxLimit]. + * + * Will throw if [forward] or [unidirectional] has been called in this scope before. + * + * The first / last semantic value will implicitly extend to the start / end of the resulting + * spec, unless redefined in another spec. + * + * @param mapping [Mapping] to be used between [minLimit] and [maxLimit]. + * @param semantics Initial semantics for the effect. + * @see com.android.mechanics.spec.directionalMotionSpec for in depth documentation. + */ + fun backward(mapping: Mapping, semantics: List> = emptyList()) + + /** Adds a segment handler to the resulting [MotionSpec]. */ + fun addSegmentHandler(key: SegmentKey, handler: OnChangeSegmentHandler) + + /** Returns the value of [baseValue] at [position]. */ + fun baseValue(position: Float): Float +} + +interface DirectionalEffectBuilderScope : DirectionalBuilderScope { + + fun before( + spring: SpringParameters? = null, + guarantee: Guarantee? = null, + semantics: List>? = null, + mapping: Mapping? = null, + ) + + fun after( + spring: SpringParameters? = null, + guarantee: Guarantee? = null, + semantics: List>? = null, + mapping: Mapping? = null, + ) +} diff --git a/mechanics/src/com/android/mechanics/spec/builder/EffectPlacement.kt b/mechanics/src/com/android/mechanics/spec/builder/EffectPlacement.kt new file mode 100644 index 0000000..00f4f10 --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/builder/EffectPlacement.kt @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2025 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.spec.builder + +import androidx.compose.ui.util.packFloats +import androidx.compose.ui.util.unpackFloat1 +import androidx.compose.ui.util.unpackFloat2 +import kotlin.math.max +import kotlin.math.min +import kotlin.math.nextDown +import kotlin.math.nextUp + +/** + * Describes the desired placement of an effect within the input domain of a [MotionSpec]. + * + * [start] is always finite, and denotes a specific position in the input where the effects starts. + * + * [end] is either finite, describing a specific range in the input where the [Effect] applies. + * Alternatively, the [end] can be either [Float.NEGATIVE_INFINITY] or [Float.POSITIVE_INFINITY], + * indicating that the effect extends either + * - for the effects intrinsic extent + * - the boundaries of the next placed effect + * - the specs' min/max limit + * + * Thus, [start] and [end] define an implicit direction of the effect. If not [isForward], the + * [Effect] will be reversed when applied. + */ +@JvmInline +value class EffectPlacement internal constructor(val value: Long) { + + init { + require(start.isFinite()) + } + + val start: Float + get() = unpackFloat1(value) + + val end: Float + get() = unpackFloat2(value) + + val type: EffectPlacemenType + get() { + return when { + end.isNaN() -> EffectPlacemenType.At + end == Float.NEGATIVE_INFINITY -> EffectPlacemenType.Before + end == Float.POSITIVE_INFINITY -> EffectPlacemenType.After + else -> EffectPlacemenType.Between + } + } + + val isForward: Boolean + get() { + return when (type) { + EffectPlacemenType.At -> true + EffectPlacemenType.Before -> false + EffectPlacemenType.After -> true + EffectPlacemenType.Between -> end >= start + } + } + + internal val sortOrder: Float + get() { + return when (type) { + EffectPlacemenType.At -> start + EffectPlacemenType.Before -> start.nextDown() + EffectPlacemenType.After -> start.nextUp() + EffectPlacemenType.Between -> (start + end) / 2 + } + } + + internal val min: Float + get() = min(start, end) + + internal val max: Float + get() = max(start, end) + + override fun toString(): String { + return "EffectPlacement(start=$start, end=$end)" + } + + companion object { + fun at(position: Float) = EffectPlacement(packFloats(position, Float.NaN)) + + fun after(position: Float) = EffectPlacement(packFloats(position, Float.POSITIVE_INFINITY)) + + fun before(position: Float) = EffectPlacement(packFloats(position, Float.NEGATIVE_INFINITY)) + + fun between(start: Float, end: Float) = EffectPlacement(packFloats(start, end)) + } +} + +enum class EffectPlacemenType { + At, + Before, + After, + Between, +} diff --git a/mechanics/src/com/android/mechanics/spec/builder/MotionBuilderContext.kt b/mechanics/src/com/android/mechanics/spec/builder/MotionBuilderContext.kt new file mode 100644 index 0000000..989d481 --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/builder/MotionBuilderContext.kt @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2025 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(ExperimentalMaterial3ExpressiveApi::class) + +package com.android.mechanics.spec.builder + +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MotionScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density +import com.android.mechanics.spring.SpringParameters + +/** + * Device / scheme specific context for building motion specs. + * + * See go/motion-system. + * + * @see rememberMotionBuilderContext for Compose + * @see standardViewMotionBuilderContext for Views + * @see expressiveViewMotionBuilderContext for Views + */ +interface MotionBuilderContext : Density { + /** + * Spatial spring tokens. + * + * Used for animations that move something on screen, for example the x and y position, + * rotation, size, rounded corners. + * + * See go/motion-system#b99b0d12-e9c8-4605-96dd-e3f17bfe9538 + */ + val spatial: MaterialSprings + + /** + * Effects spring tokens. + * + * Used to animate properties such as color and opacity animations. + * + * See go/motion-system#142c8835-7474-4f74-b2eb-e1187051ec1f + */ + val effects: MaterialSprings + + companion object { + /** Default threshold for effect springs. */ + const val StableThresholdEffects = 0.01f + /** + * Default threshold for spatial springs. + * + * Cuts off when remaining oscillations are below 1px + */ + const val StableThresholdSpatial = 1f + } +} + +/** Material spring tokens, see go/motion-system##63b14c00-d049-4d3e-b8b6-83d8f524a8db for usage. */ +data class MaterialSprings( + val default: SpringParameters, + val fast: SpringParameters, + val slow: SpringParameters, + val stabilityThreshold: Float, +) + +/** [MotionBuilderContext] based on the current [Density] and [MotionScheme]. */ +@Composable +fun rememberMotionBuilderContext(): MotionBuilderContext { + val density = LocalDensity.current + val motionScheme = MaterialTheme.motionScheme + return remember(density, motionScheme) { ComposeMotionBuilderContext(motionScheme, density) } +} + +class ComposeMotionBuilderContext(motionScheme: MotionScheme, density: Density) : + MotionBuilderContext, Density by density { + + override val spatial = + MaterialSprings( + SpringParameters(motionScheme.defaultSpatialSpec()), + SpringParameters(motionScheme.fastSpatialSpec()), + SpringParameters(motionScheme.slowSpatialSpec()), + MotionBuilderContext.StableThresholdSpatial, + ) + override val effects = + MaterialSprings( + SpringParameters(motionScheme.defaultEffectsSpec()), + SpringParameters(motionScheme.fastEffectsSpec()), + SpringParameters(motionScheme.slowEffectsSpec()), + MotionBuilderContext.StableThresholdEffects, + ) +} diff --git a/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilder.kt b/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilder.kt new file mode 100644 index 0000000..de62c44 --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilder.kt @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2025 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.spec.builder + +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.SemanticValue +import com.android.mechanics.spring.SpringParameters + +/** + * Creates a [MotionSpec] for a spatial value. + * + * The [baseMapping] is [Mapping.Identity], and the Material spatial.default spring is used unless + * otherwise specified. + * + * @see motionSpec + */ +fun MotionBuilderContext.spatialMotionSpec( + baseMapping: Mapping = Mapping.Identity, + defaultSpring: SpringParameters = this.spatial.default, + resetSpring: SpringParameters = defaultSpring, + baseSemantics: List> = emptyList(), + init: MotionSpecBuilderScope.() -> Unit, +) = motionSpec(baseMapping, defaultSpring, resetSpring, baseSemantics, init) + +/** + * Creates a [MotionSpec] for an effects value. + * + * The [baseMapping] is [Mapping.Zero], and the Material effects.default spring is used unless + * otherwise specified. + * + * @see motionSpec + */ +fun MotionBuilderContext.effectsMotionSpec( + baseMapping: Mapping = Mapping.Zero, + defaultSpring: SpringParameters = this.effects.default, + resetSpring: SpringParameters = defaultSpring, + baseSemantics: List> = emptyList(), + init: MotionSpecBuilderScope.() -> Unit, +) = motionSpec(baseMapping, defaultSpring, resetSpring, baseSemantics, init) + +/** + * Creates a [MotionSpec], based on reusable effects. + * + * @param baseMapping The mapping in used for segments where no [Effect] is specified. + * @param defaultSpring The [DirectionalBuilderScope.defaultSpring], used for all discontinuities + * unless otherwise specified. + * @param resetSpring spring parameters to animate a difference in output, if the difference is + * caused by setting this new spec. + * @param baseSemantics initial semantics that apply before of effects override them. + * @param init + */ +fun MotionBuilderContext.motionSpec( + baseMapping: Mapping, + defaultSpring: SpringParameters, + resetSpring: SpringParameters = defaultSpring, + baseSemantics: List> = emptyList(), + init: MotionSpecBuilderScope.() -> Unit, +): MotionSpec { + return MotionSpecBuilderImpl( + baseMapping, + defaultSpring, + resetSpring, + baseSemantics, + motionBuilderContext = this, + ) + .apply(init) + .build() +} + +/** + * Creates a [MotionSpec] producing a fixed output value, no matter the [MotionValues]'s input. + * + * The Material spatial.default spring is used to animate to the fixed output value. + * + * @see fixedValueSpec + */ +fun MotionBuilderContext.fixedSpatialValueSpec( + value: Float, + resetSpring: SpringParameters = this.spatial.default, + semantics: List> = emptyList(), +) = fixedValueSpec(value, resetSpring, semantics) + +/** + * Creates a [MotionSpec] producing a fixed output value, no matter the [MotionValues]'s input. + * + * The Material effects.default spring is used to animate to the fixed output value. + * + * @see fixedValueSpec + */ +fun MotionBuilderContext.fixedEffectsValueSpec( + value: Float, + resetSpring: SpringParameters = this.effects.default, + semantics: List> = emptyList(), +) = fixedValueSpec(value, resetSpring, semantics) + +/** + * Creates a [MotionSpec] producing a fixed output value, no matter the [MotionValues]'s input. + * + * @param value The fixed output value. + * @param resetSpring spring parameters to animate to the fixed output value. + * @param semantics for this spec. + */ +fun MotionBuilderContext.fixedValueSpec( + value: Float, + resetSpring: SpringParameters, + semantics: List> = emptyList(), +): MotionSpec { + return MotionSpec( + directionalMotionSpec(Mapping.Fixed(value), semantics), + resetSpring = resetSpring, + ) +} + +/** Defines the contract placing [Effect]s within a [MotionSpecBuilder] */ +interface MotionSpecBuilderScope : MotionBuilderContext { + + /** + * Places [effect] between [start] and [end]. + * + * If `start > end`, the effect will be reversed when applied. The [effect] can overrule the + * `end` position with [Effect.measure]. + */ + fun between(start: Float, end: Float, effect: Effect.PlaceableBetween): PlacedEffect + + /** + * Places [effect] at position, extending backwards. + * + * The effect will be reversed when applied. + */ + fun before(position: Float, effect: Effect.PlaceableBefore): PlacedEffect + + /** Places [effect] at position, extending forward. */ + fun after(position: Float, effect: Effect.PlaceableAfter): PlacedEffect + + /** + * Places [effect] at [otherEffect]'s min position, extending backwards. + * + * The effect will be reversed when applied. + */ + fun before(otherEffect: PlacedEffect, effect: Effect.PlaceableBefore): PlacedEffect + + /** Places [effect] after the end of [otherEffect], extending forward. */ + fun after(otherEffect: PlacedEffect, effect: Effect.PlaceableAfter): PlacedEffect + + /** Places [effect] at position. */ + fun at(position: Float, effect: Effect.PlaceableAt): PlacedEffect +} diff --git a/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilderImpl.kt b/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilderImpl.kt new file mode 100644 index 0000000..75b9953 --- /dev/null +++ b/mechanics/src/com/android/mechanics/spec/builder/MotionSpecBuilderImpl.kt @@ -0,0 +1,589 @@ +/* + * Copyright (C) 2025 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.spec.builder + +import androidx.collection.MutableIntIntMap +import androidx.collection.MutableIntList +import androidx.collection.MutableIntLongMap +import androidx.collection.MutableIntObjectMap +import androidx.collection.MutableLongList +import androidx.collection.ObjectList +import androidx.collection.mutableObjectListOf +import com.android.mechanics.spec.Breakpoint +import com.android.mechanics.spec.BreakpointKey +import com.android.mechanics.spec.Guarantee +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.OnChangeSegmentHandler +import com.android.mechanics.spec.SegmentKey +import com.android.mechanics.spec.SemanticValue +import com.android.mechanics.spring.SpringParameters + +internal class MotionSpecBuilderImpl( + override val baseMapping: Mapping, + override val defaultSpring: SpringParameters, + private val resetSpring: SpringParameters, + private val baseSemantics: List>, + motionBuilderContext: MotionBuilderContext, +) : MotionSpecBuilderScope, MotionBuilderContext by motionBuilderContext, EffectApplyScope { + + private val placedEffects = MutableIntObjectMap() + private val absoluteEffectPlacements = MutableIntLongMap() + private val relativeEffectPlacements = MutableIntIntMap() + + private lateinit var builders: ObjectList + private val forwardBuilder: DirectionalEffectBuilderScopeImpl + get() = builders[0] + + private val reverseBuilder: DirectionalEffectBuilderScopeImpl + get() = builders[1] + + private lateinit var segmentHandlers: MutableMap + + fun build(): MotionSpec { + if (placedEffects.isEmpty()) { + return MotionSpec(directionalMotionSpec(baseMapping), resetSpring = resetSpring) + } + + builders = + mutableObjectListOf( + DirectionalEffectBuilderScopeImpl(defaultSpring, baseSemantics), + DirectionalEffectBuilderScopeImpl(defaultSpring, baseSemantics), + ) + segmentHandlers = mutableMapOf() + + val capacity = placedEffects.size * 2 + 1 + val sortedEffects = MutableIntList(capacity) + val specifiedPlacements = MutablePlacementList(MutableLongList(capacity)) + val actualPlacements = MutablePlacementList(MutableLongList(capacity)) + + placeEffects(sortedEffects, specifiedPlacements, actualPlacements) + check(sortedEffects.size >= 2) + + var minLimitKey = BreakpointKey.MinLimit + lateinit var maxLimitKey: BreakpointKey + + for (i in 0 until sortedEffects.lastIndex) { + maxLimitKey = BreakpointKey() + applyEffect( + sortedEffects[i], + specifiedPlacements[i], + actualPlacements[i], + minLimitKey, + maxLimitKey, + ) + minLimitKey = maxLimitKey + } + + maxLimitKey = BreakpointKey.MaxLimit + + applyEffect( + sortedEffects.last(), + specifiedPlacements.last(), + actualPlacements.last(), + minLimitKey, + maxLimitKey, + ) + + return MotionSpec( + builders[0].build(), + builders[1].build(), + resetSpring, + segmentHandlers.toMap(), + ) + } + + private fun placeEffects( + sortedEffects: MutableIntList, + specifiedPlacements: MutablePlacementList, + actualPlacements: MutablePlacementList, + ) { + + // To place the effects, do the following + // - sort all `absoluteEffectPlacements` in ascending order + // - use the sorted absolutely placed effects as seeds. For each of them, do the following: + // - measure the effect + // - recursively walk the relatively effects placed before, tracking the min boundary + // (this requires effects that have a defined extend to the min side) + // - upon reaching the beginning, start placing the effects in the forward direction. + // continue up to the seed effects, t + // - recursively continue placing effects relatively placed afterwards. + + fun appendEffect( + effectId: Int, + specifiedPlacement: EffectPlacement, + measuredPlacement: EffectPlacement, + ) { + var actualPlacement = measuredPlacement + var prependNoPlaceholderEffect = false + + if (actualPlacements.isEmpty()) { + // placing first effect. + if (measuredPlacement.min.isFinite()) { + prependNoPlaceholderEffect = true + } + } else { + + val previousPlacement = actualPlacements.last() + if (previousPlacement.max.isFinite()) { + // The previous effect has a defined end-point. + + if (measuredPlacement.min == Float.NEGATIVE_INFINITY) { + // The current effect wants to extend to the end of the previous effect. + require(measuredPlacement.max.isFinite()) + actualPlacement = + EffectPlacement.between(previousPlacement.max, measuredPlacement.max) + } else if (measuredPlacement.min > previousPlacement.max) { + // There's a gap between the last and the current effect, will need to + // insert a placeholder + require(measuredPlacement.min.isFinite()) + prependNoPlaceholderEffect = true + } else { + // In all other cases, the previous end has to match the current start. + // In all other cases, effects are overlapping, which is not supported. + require(measuredPlacement.min == previousPlacement.max) { + "Effects are overlapping" + } + } + } else { + // The previous effect wants to extend to the beginning of the next effect + assert(previousPlacement.max == Float.POSITIVE_INFINITY) + + // Therefore the current effect is required to have a defined start-point + require(measuredPlacement.min.isFinite()) { + "Only one of the effects can extend to the boundary, not both:\n" + + " this: $actualPlacement (${placedEffects[effectId]})\n" + + " previous: $previousPlacement (${placedEffects[effectId]}])\n" + } + + actualPlacements[actualPlacements.lastIndex] = + EffectPlacement.between(previousPlacement.min, measuredPlacement.min) + } + } + + if (prependNoPlaceholderEffect) { + assert(actualPlacement.min.isFinite()) + // Adding a placeholder that will be skipped, but simplifies the algorithm by + // ensuring all effects are back-to-back. The NoEffectPlaceholderId is used to + + sortedEffects.add(NoEffectPlaceholderId) + val placeholderPlacement = EffectPlacement.before(actualPlacement.min) + specifiedPlacements.add(placeholderPlacement) + actualPlacements.add(placeholderPlacement) + } + + sortedEffects.add(effectId) + specifiedPlacements.add(specifiedPlacement) + + actualPlacements.add(actualPlacement) + } + + fun processEffectsPlacedBefore( + anchorEffectId: Int, + anchorEffectPlacement: EffectPlacement, + ) { + val beforeEffectKey = -anchorEffectId + if (relativeEffectPlacements.containsKey(beforeEffectKey)) { + val effectId = relativeEffectPlacements[beforeEffectKey] + val effect = checkNotNull(placedEffects[effectId]) + + require(anchorEffectPlacement.min.isFinite()) + val specifiedPlacement = EffectPlacement.before(anchorEffectPlacement.min) + + val measuredPlacement = measureEffect(effect, specifiedPlacement) + processEffectsPlacedBefore(effectId, measuredPlacement) + appendEffect(effectId, specifiedPlacement, measuredPlacement) + } + } + + fun processEffectsPlacedAfter(anchorEffectId: Int, anchorEffectPlacement: EffectPlacement) { + val afterEffectKey = anchorEffectId + if (relativeEffectPlacements.containsKey(afterEffectKey)) { + val effectId = relativeEffectPlacements[afterEffectKey] + val effect = checkNotNull(placedEffects[effectId]) + + require(anchorEffectPlacement.max.isFinite()) + val specifiedPlacement = EffectPlacement.after(anchorEffectPlacement.max) + + val measuredPlacement = measureEffect(effect, specifiedPlacement) + appendEffect(effectId, specifiedPlacement, measuredPlacement) + processEffectsPlacedAfter(effectId, measuredPlacement) + } + } + + check(absoluteEffectPlacements.isNotEmpty()) + // Implementation note: sortedAbsolutePlacedEffects should be an IntArray, but that cannot + // be sorted with a custom comparator, hence using a typed array. + val sortedAbsolutePlacedEffects = + Array(absoluteEffectPlacements.size) { 0 } + .also { array -> + var index = 0 + absoluteEffectPlacements.forEachKey { array[index++] = it } + array.sortBy { EffectPlacement(absoluteEffectPlacements[it]).sortOrder } + } + + sortedAbsolutePlacedEffects.forEach { effectId -> + val effect = checkNotNull(placedEffects[effectId]) + val specifiedPlacement = EffectPlacement(absoluteEffectPlacements[effectId]) + val measuredPlacement = measureEffect(effect, specifiedPlacement) + processEffectsPlacedBefore(effectId, measuredPlacement) + appendEffect(effectId, specifiedPlacement, measuredPlacement) + processEffectsPlacedAfter(effectId, measuredPlacement) + } + + if (actualPlacements.last().max != Float.POSITIVE_INFINITY) { + sortedEffects.add(NoEffectPlaceholderId) + val placeholderPlacement = EffectPlacement.after(actualPlacements.last().max) + specifiedPlacements.add(placeholderPlacement) + actualPlacements.add(placeholderPlacement) + } + } + + // ---- MotionSpecBuilderScope implementation -------------------------------------------------- + + override fun at(position: Float, effect: Effect.PlaceableAt): PlacedEffect { + return addEffect(effect).also { + absoluteEffectPlacements[it.id] = EffectPlacement.after(position).value + } + } + + override fun between(start: Float, end: Float, effect: Effect.PlaceableBetween): PlacedEffect { + return addEffect(effect).also { + absoluteEffectPlacements[it.id] = EffectPlacement.between(start, end).value + } + } + + override fun before(position: Float, effect: Effect.PlaceableBefore): PlacedEffect { + return addEffect(effect).also { + absoluteEffectPlacements[it.id] = EffectPlacement.before(position).value + } + } + + override fun before(otherEffect: PlacedEffect, effect: Effect.PlaceableBefore): PlacedEffect { + require(placedEffects.containsKey(otherEffect.id)) + require(!relativeEffectPlacements.containsKey(-otherEffect.id)) + return addEffect(effect).also { relativeEffectPlacements[-otherEffect.id] = it.id } + } + + override fun after(position: Float, effect: Effect.PlaceableAfter): PlacedEffect { + return addEffect(effect).also { + absoluteEffectPlacements[it.id] = EffectPlacement.after(position).value + } + } + + override fun after(otherEffect: PlacedEffect, effect: Effect.PlaceableAfter): PlacedEffect { + require(placedEffects.containsKey(otherEffect.id)) + require(!relativeEffectPlacements.containsKey(otherEffect.id)) + + relativeEffectPlacements.forEach { key, value -> + if (value == otherEffect.id) { + require(key > 0) { + val other = placedEffects[otherEffect.id] + "Cannot place effect [$effect] *after* [$other], since the latter was placed" + + "*before* an effect" + } + } + } + + require(!relativeEffectPlacements.containsKey(otherEffect.id)) + return addEffect(effect).also { relativeEffectPlacements[otherEffect.id] = it.id } + } + + private fun addEffect(effect: Effect): PlacedEffect { + return PlacedEffect(placedEffects.size + 1).also { placedEffects[it.id] = effect } + } + + // ----- EffectApplyScope implementation ------------------------------------------------------- + + override fun addSegmentHandler(key: SegmentKey, handler: OnChangeSegmentHandler) { + require(!segmentHandlers.containsKey(key)) + segmentHandlers[key] = handler + } + + override fun baseValue(position: Float): Float { + return baseMapping.map(position) + } + + override fun unidirectional( + initialMapping: Mapping, + semantics: List>, + init: DirectionalEffectBuilderScope.() -> Unit, + ) { + forward(initialMapping, semantics, init) + backward(initialMapping, semantics, init) + } + + override fun unidirectional(mapping: Mapping, semantics: List>) { + forward(mapping, semantics) + backward(mapping, semantics) + } + + override fun forward( + initialMapping: Mapping, + semantics: List>, + init: DirectionalEffectBuilderScope.() -> Unit, + ) { + check(!forwardInvoked) { "Cannot define forward spec more than once" } + forwardInvoked = true + + forwardBuilder.prepareBuilderFn(initialMapping, semantics) + forwardBuilder.init() + } + + override fun forward(mapping: Mapping, semantics: List>) { + check(!forwardInvoked) { "Cannot define forward spec more than once" } + forwardInvoked = true + + forwardBuilder.prepareBuilderFn(mapping, semantics) + } + + override fun backward( + initialMapping: Mapping, + semantics: List>, + init: DirectionalEffectBuilderScope.() -> Unit, + ) { + check(!backwardInvoked) { "Cannot define backward spec more than once" } + backwardInvoked = true + + reverseBuilder.prepareBuilderFn(initialMapping, semantics) + reverseBuilder.init() + } + + override fun backward(mapping: Mapping, semantics: List>) { + check(!backwardInvoked) { "Cannot define backward spec more than once" } + backwardInvoked = true + + reverseBuilder.prepareBuilderFn(mapping, semantics) + } + + private var forwardInvoked = false + private var backwardInvoked = false + + private fun applyEffect( + effectId: Int, + specifiedPlacement: EffectPlacement, + actualPlacement: EffectPlacement, + minLimitKey: BreakpointKey, + maxLimitKey: BreakpointKey, + ) { + require(minLimitKey != maxLimitKey) + + if (effectId == NoEffectPlaceholderId) { + val maxBreakpoint = + Breakpoint.create(maxLimitKey, actualPlacement.max, defaultSpring, Guarantee.None) + builders.forEach { builder -> + builder.mappings += builder.afterMapping ?: baseMapping + builder.breakpoints += maxBreakpoint + } + return + } + + val initialForwardSize = forwardBuilder.breakpoints.size + val initialReverseSize = reverseBuilder.breakpoints.size + + val effect = checkNotNull(placedEffects[effectId]) + + forwardInvoked = false + backwardInvoked = false + + builders.forEach { it.resetBeforeAfter() } + with(effect) { + createSpec( + actualPlacement.min, + minLimitKey, + actualPlacement.max, + maxLimitKey, + specifiedPlacement, + ) + } + + check(forwardInvoked) { "forward() spec not defined during createSpec()" } + check(backwardInvoked) { "backward() spec not defined during createSpec()" } + + builders.forEachIndexed { index, builder -> + val initialSize = if (index == 0) initialForwardSize else initialReverseSize + + require(builder.breakpoints[initialSize - 1].key == minLimitKey) + + builder.finalizeBuilderFn( + actualPlacement.max, + maxLimitKey, + builder.afterSpring ?: defaultSpring, + builder.afterGuarantee ?: Guarantee.None, + builder.afterSemantics ?: emptyList(), + ) + check(builder.breakpoints.size > initialSize) + + if (builder.beforeSpring != null || builder.beforeGuarantee != null) { + val oldMinBreakpoint = builder.breakpoints[initialSize - 1] + builder.breakpoints[initialSize - 1] = + oldMinBreakpoint.copy( + spring = builder.beforeSpring ?: oldMinBreakpoint.spring, + guarantee = builder.beforeGuarantee ?: oldMinBreakpoint.guarantee, + ) + } + + builder.beforeMapping + ?.takeIf { initialSize >= 2 && builder.mappings[initialSize - 2] === baseMapping } + ?.also { builder.mappings[initialSize - 2] = it } + + builder.beforeSemantics?.forEach { + builder.getSemantics(it.key).updateBefore(initialSize - 2, it.value) + } + } + } + + companion object { + private val NoEffectPlaceholderId = -1 + } +} + +private class DirectionalEffectBuilderScopeImpl( + defaultSpring: SpringParameters, + baseSemantics: List>, +) : DirectionalBuilderImpl(defaultSpring, baseSemantics), DirectionalEffectBuilderScope { + + var beforeGuarantee: Guarantee? = null + var beforeSpring: SpringParameters? = null + var beforeSemantics: List>? = null + var beforeMapping: Mapping? = null + + override fun before( + spring: SpringParameters?, + guarantee: Guarantee?, + semantics: List>?, + mapping: Mapping?, + ) { + beforeGuarantee = guarantee + beforeSpring = spring + beforeSemantics = semantics + beforeMapping = mapping + } + + var afterGuarantee: Guarantee? = null + var afterSpring: SpringParameters? = null + var afterSemantics: List>? = null + var afterMapping: Mapping? = null + + override fun after( + spring: SpringParameters?, + guarantee: Guarantee?, + semantics: List>?, + mapping: Mapping?, + ) { + afterGuarantee = guarantee + afterSpring = spring + afterSemantics = semantics + afterMapping = mapping + } + + fun resetBeforeAfter() { + beforeGuarantee = null + beforeSpring = null + beforeSemantics = null + beforeMapping = null + afterGuarantee = null + afterSpring = null + afterSemantics = null + afterMapping = null + } +} + +private fun MotionBuilderContext.measureEffect( + effect: Effect, + specifiedPlacement: EffectPlacement, +): EffectPlacement { + return when (specifiedPlacement.type) { + EffectPlacemenType.At -> { + require(effect is Effect.PlaceableAt) + with(effect) { + val minExtend = minExtent() + require(minExtend.isFinite() && minExtend >= 0) + val maxExtend = maxExtent() + require(maxExtend.isFinite() && maxExtend >= 0) + + EffectPlacement.between( + specifiedPlacement.start - minExtend, + specifiedPlacement.start + maxExtend, + ) + } + } + + EffectPlacemenType.Before -> { + require(effect is Effect.PlaceableBefore) + with(effect) { + val intrinsicSize = intrinsicSize() + if (intrinsicSize.isFinite()) { + require(intrinsicSize >= 0) + + EffectPlacement.between( + specifiedPlacement.start, + specifiedPlacement.start - intrinsicSize, + ) + } else { + specifiedPlacement + } + } + } + + EffectPlacemenType.After -> { + require(effect is Effect.PlaceableAfter) + with(effect) { + val intrinsicSize = intrinsicSize() + if (intrinsicSize.isFinite()) { + + require(intrinsicSize >= 0) + + EffectPlacement.between( + specifiedPlacement.start, + specifiedPlacement.start + intrinsicSize, + ) + } else { + specifiedPlacement + } + } + } + + EffectPlacemenType.Between -> specifiedPlacement + } +} + +@JvmInline +value class MutablePlacementList(val storage: MutableLongList) { + + val size: Int + get() = storage.size + + val lastIndex: Int + get() = storage.lastIndex + + val indices: IntRange + get() = storage.indices + + fun isEmpty() = storage.isEmpty() + + fun isNotEmpty() = storage.isNotEmpty() + + operator fun get(index: Int) = EffectPlacement(storage.get(index)) + + fun last() = EffectPlacement(storage.last()) + + fun add(element: EffectPlacement) = storage.add(element.value) + + operator fun set(index: Int, element: EffectPlacement) = + EffectPlacement(storage.set(index, element.value)) +} diff --git a/mechanics/src/com/android/mechanics/spring/MaterialSpringParameters.kt b/mechanics/src/com/android/mechanics/spring/MaterialSpringParameters.kt new file mode 100644 index 0000000..81af8a4 --- /dev/null +++ b/mechanics/src/com/android/mechanics/spring/MaterialSpringParameters.kt @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2025 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(ExperimentalMaterial3ExpressiveApi::class) + +package com.android.mechanics.spring + +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.SpringSpec +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable + +/** Converts a [SpringSpec] into its [SpringParameters] equivalent. */ +fun SpringParameters(springSpec: SpringSpec) = + with(springSpec) { SpringParameters(stiffness, dampingRatio) } + +/** + * Converts a [FiniteAnimationSpec] from the [MotionScheme] into its [SpringParameters] equivalent. + */ +@ExperimentalMaterial3ExpressiveApi +fun SpringParameters(animationSpec: FiniteAnimationSpec): SpringParameters { + check(animationSpec is SpringSpec) { + "animationSpec is expected to be a SpringSpec, but is $animationSpec" + } + return SpringParameters(animationSpec) +} + +@Composable +fun defaultSpatialSpring(): SpringParameters { + return SpringParameters(MaterialTheme.motionScheme.defaultSpatialSpec()) +} + +@Composable +fun defaultEffectSpring(): SpringParameters { + return SpringParameters(MaterialTheme.motionScheme.defaultEffectsSpec()) +} diff --git a/mechanics/src/com/android/mechanics/spring/SpringParameters.kt b/mechanics/src/com/android/mechanics/spring/SpringParameters.kt new file mode 100644 index 0000000..828527a --- /dev/null +++ b/mechanics/src/com/android/mechanics/spring/SpringParameters.kt @@ -0,0 +1,79 @@ +/* + * 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.spring + +import androidx.compose.ui.util.fastCoerceIn +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 kotlin.math.pow + +/** + * Describes the parameters of a spring. + * + * Note: This is conceptually compatible with the Compose [SpringSpec]. In contrast to the compose + * implementation, these [SpringParameters] are intended to be continuously updated. + * + * @see SpringParameters function to create this value. + */ +@JvmInline +value class SpringParameters(val packedValue: Long) { + val stiffness: Float + get() = unpackFloat1(packedValue) + + val dampingRatio: Float + get() = unpackFloat2(packedValue) + + /** Whether the spring is expected to immediately end movement. */ + val isSnapSpring: Boolean + get() = stiffness >= snapStiffness && dampingRatio == snapDamping + + override fun toString(): String { + return "MechanicsSpringSpec(stiffness=$stiffness, dampingRatio=$dampingRatio)" + } + + companion object { + private val snapStiffness = 100_000f + private val snapDamping = 1f + + /** A spring so stiff it completes the motion almost immediately. */ + val Snap = SpringParameters(snapStiffness, snapDamping) + } +} + +/** Creates a [SpringParameters] with the given [stiffness] and [dampingRatio]. */ +fun SpringParameters(stiffness: Float, dampingRatio: Float): SpringParameters { + require(stiffness > 0) { "Spring stiffness constant must be positive." } + require(dampingRatio >= 0) { "Spring damping constant must be positive." } + return SpringParameters(packFloats(stiffness, dampingRatio)) +} + +/** + * Return interpolated [SpringParameters], based on the [fraction] between [start] and [stop]. + * + * The [SpringParameters.dampingRatio] is interpolated linearly, the [SpringParameters.stiffness] is + * interpolated logarithmically. + * + * The [fraction] is clamped to a `0..1` range. + */ +fun lerp(start: SpringParameters, stop: SpringParameters, fraction: Float): SpringParameters { + val f = fraction.fastCoerceIn(0f, 1f) + val stiffness = start.stiffness.pow(1 - f) * stop.stiffness.pow(f) + val dampingRatio = lerp(start.dampingRatio, stop.dampingRatio, f) + return SpringParameters(packFloats(stiffness, dampingRatio)) +} diff --git a/mechanics/src/com/android/mechanics/spring/SpringState.kt b/mechanics/src/com/android/mechanics/spring/SpringState.kt new file mode 100644 index 0000000..bdf7c33 --- /dev/null +++ b/mechanics/src/com/android/mechanics/spring/SpringState.kt @@ -0,0 +1,134 @@ +/* + * 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.spring + +import androidx.compose.ui.util.packFloats +import androidx.compose.ui.util.unpackFloat1 +import androidx.compose.ui.util.unpackFloat2 +import kotlin.math.cos +import kotlin.math.exp +import kotlin.math.sin +import kotlin.math.sqrt + +/** + * Describes the motion state of a spring. + * + * @see calculateUpdatedState to simulate the springs movement + * @see SpringState function to create this value. + */ +@JvmInline +value class SpringState(val packedValue: Long) { + val displacement: Float + get() = unpackFloat1(packedValue) + + val velocity: Float + get() = unpackFloat2(packedValue) + + /** + * Whether the state is considered stable. + * + * The amplitude of the remaining movement, for a spring with [parameters] is less than + * [stableThreshold] + */ + fun isStable(parameters: SpringParameters, stableThreshold: Float): Boolean { + if (this == AtRest) return true + val currentEnergy = parameters.stiffness * displacement * displacement + velocity * velocity + val maxStableEnergy = parameters.stiffness * stableThreshold * stableThreshold + return currentEnergy <= maxStableEnergy + } + + /** Adds the specified [displacementDelta] and [velocityDelta] to the returned state. */ + fun nudge(displacementDelta: Float = 0f, velocityDelta: Float = 0f): SpringState { + return SpringState(displacement + displacementDelta, velocity + velocityDelta) + } + + override fun toString(): String { + return "MechanicsSpringState(displacement=$displacement, velocity=$velocity)" + } + + companion object { + /** Spring at rest. */ + val AtRest = SpringState(displacement = 0f, velocity = 0f) + } +} + +/** Creates a [SpringState] given [displacement] and [velocity] */ +fun SpringState(displacement: Float, velocity: Float = 0f) = + SpringState(packFloats(displacement, velocity)) + +/** + * Computes the updated [SpringState], after letting the spring with the specified [parameters] + * settle for [elapsedNanos]. + * + * This implementation is based on Compose's [SpringSimulation]. + */ +fun SpringState.calculateUpdatedState( + elapsedNanos: Long, + parameters: SpringParameters, +): SpringState { + if (parameters.isSnapSpring || this == SpringState.AtRest) { + return SpringState.AtRest + } + + val stiffness = parameters.stiffness.toDouble() + val naturalFreq = sqrt(stiffness) + + val dampingRatio = parameters.dampingRatio + val displacement = displacement + val velocity = velocity + val deltaT = elapsedNanos / 1_000_000_000.0 // unit: seconds + val dampingRatioSquared = dampingRatio * dampingRatio.toDouble() + val r = -dampingRatio * naturalFreq + + val currentDisplacement: Double + val currentVelocity: Double + + if (dampingRatio > 1) { + // Over damping + val s = naturalFreq * sqrt(dampingRatioSquared - 1) + val gammaPlus = r + s + val gammaMinus = r - s + + val coeffB = (gammaMinus * displacement - velocity) / (gammaMinus - gammaPlus) + val coeffA = displacement - coeffB + currentDisplacement = (coeffA * exp(gammaMinus * deltaT) + coeffB * exp(gammaPlus * deltaT)) + currentVelocity = + (coeffA * gammaMinus * exp(gammaMinus * deltaT) + + coeffB * gammaPlus * exp(gammaPlus * deltaT)) + } else if (dampingRatio == 1.0f) { + // Critically damped + val coeffA = displacement + val coeffB = velocity + naturalFreq * displacement + val nFdT = -naturalFreq * deltaT + currentDisplacement = (coeffA + coeffB * deltaT) * exp(nFdT) + currentVelocity = + (((coeffA + coeffB * deltaT) * exp(nFdT) * (-naturalFreq)) + coeffB * exp(nFdT)) + } else { + // Underdamped + val dampedFreq = naturalFreq * sqrt(1 - dampingRatioSquared) + val cosCoeff = displacement + val sinCoeff = ((1 / dampedFreq) * (((-r * displacement) + velocity))) + val dFdT = dampedFreq * deltaT + currentDisplacement = (exp(r * deltaT) * ((cosCoeff * cos(dFdT) + sinCoeff * sin(dFdT)))) + currentVelocity = + (currentDisplacement * r + + (exp(r * deltaT) * + ((-dampedFreq * cosCoeff * sin(dFdT) + dampedFreq * sinCoeff * cos(dFdT))))) + } + + return SpringState(currentDisplacement.toFloat(), currentVelocity.toFloat()) +} diff --git a/mechanics/src/com/android/mechanics/view/ViewGestureContext.kt b/mechanics/src/com/android/mechanics/view/ViewGestureContext.kt new file mode 100644 index 0000000..140fe75 --- /dev/null +++ b/mechanics/src/com/android/mechanics/view/ViewGestureContext.kt @@ -0,0 +1,135 @@ +/* + * Copyright (C) 2025 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.view + +import android.content.Context +import android.view.ViewConfiguration +import androidx.compose.ui.util.fastForEach +import com.android.mechanics.spec.InputDirection +import kotlin.math.max +import kotlin.math.min + +fun interface GestureContextUpdateListener { + fun onGestureContextUpdated() +} + +interface ViewGestureContext { + val direction: InputDirection + val dragOffset: Float + + fun addUpdateCallback(listener: GestureContextUpdateListener) + + fun removeUpdateCallback(listener: GestureContextUpdateListener) +} + +/** + * [ViewGestureContext] driven by a gesture distance. + * + * The direction is determined from the gesture input, where going further than + * [directionChangeSlop] in the opposite direction toggles the direction. + * + * @param initialDragOffset The initial [dragOffset] of the [ViewGestureContext] + * @param initialDirection The initial [direction] of the [ViewGestureContext] + * @param directionChangeSlop the amount [dragOffset] must be moved in the opposite direction for + * the [direction] to flip. + */ +class DistanceGestureContext( + initialDragOffset: Float, + initialDirection: InputDirection, + private val directionChangeSlop: Float, +) : ViewGestureContext { + init { + require(directionChangeSlop > 0) { + "directionChangeSlop must be greater than 0, was $directionChangeSlop" + } + } + + companion object { + @JvmStatic + fun create( + context: Context, + initialDragOffset: Float = 0f, + initialDirection: InputDirection = InputDirection.Max, + ): DistanceGestureContext { + val directionChangeSlop = ViewConfiguration.get(context).scaledTouchSlop.toFloat() + return DistanceGestureContext(initialDragOffset, initialDirection, directionChangeSlop) + } + } + + private val callbacks = mutableListOf() + + override var dragOffset: Float = initialDragOffset + set(value) { + if (field == value) return + + field = value + direction = + when (direction) { + InputDirection.Max -> { + if (furthestDragOffset - value > directionChangeSlop) { + furthestDragOffset = value + InputDirection.Min + } else { + furthestDragOffset = max(value, furthestDragOffset) + InputDirection.Max + } + } + + InputDirection.Min -> { + if (value - furthestDragOffset > directionChangeSlop) { + furthestDragOffset = value + InputDirection.Max + } else { + furthestDragOffset = min(value, furthestDragOffset) + InputDirection.Min + } + } + } + invokeCallbacks() + } + + override var direction = initialDirection + private set + + private var furthestDragOffset = initialDragOffset + + /** + * Sets [dragOffset] and [direction] to the specified values. + * + * This also resets memoized [furthestDragOffset], which is used to determine the direction + * change. + */ + fun reset(dragOffset: Float, direction: InputDirection) { + this.dragOffset = dragOffset + this.direction = direction + this.furthestDragOffset = dragOffset + + invokeCallbacks() + } + + override fun addUpdateCallback(listener: GestureContextUpdateListener) { + callbacks.add(listener) + } + + override fun removeUpdateCallback(listener: GestureContextUpdateListener) { + callbacks.remove(listener) + } + + private fun invokeCallbacks() { + callbacks.fastForEach { it.onGestureContextUpdated() } + } +} diff --git a/mechanics/src/com/android/mechanics/view/ViewMotionBuilderContext.kt b/mechanics/src/com/android/mechanics/view/ViewMotionBuilderContext.kt new file mode 100644 index 0000000..5d1a21a --- /dev/null +++ b/mechanics/src/com/android/mechanics/view/ViewMotionBuilderContext.kt @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2025 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.view + +import android.content.Context +import androidx.compose.ui.unit.Density +import com.android.mechanics.spec.builder.MaterialSprings +import com.android.mechanics.spec.builder.MotionBuilderContext +import com.android.mechanics.spring.SpringParameters +import com.android.mechanics.view.ViewMaterialSprings.Default + +/** + * Creates a [MotionBuilderContext] using the **standard** motion spec. + * + * See go/motion-system. + * + * @param context The context to derive the density from. + */ +fun standardViewMotionBuilderContext(context: Context): MotionBuilderContext { + return standardViewMotionBuilderContext(context.resources.displayMetrics.density) +} + +/** + * Creates a [MotionBuilderContext] using the **standard** motion spec. + * + * See go/motion-system. + * + * @param density The density of the display, as a scaling factor for the dp to px conversion. + */ +fun standardViewMotionBuilderContext(density: Float): MotionBuilderContext { + return with(ViewMaterialSprings.Default) { + ViewMotionBuilderContext(Spatial, Effects, Density(density)) + } +} + +/** + * Creates a [MotionBuilderContext] using the **expressive** motion spec. + * + * See go/motion-system. + * + * @param context The context to derive the density from. + */ +fun expressiveViewMotionBuilderContext(context: Context): MotionBuilderContext { + return expressiveViewMotionBuilderContext(context.resources.displayMetrics.density) +} + +/** + * Creates a [MotionBuilderContext] using the **expressive** motion spec. + * + * See go/motion-system. + * + * @param density The density of the display, as a scaling factor for the dp to px conversion. + */ +fun expressiveViewMotionBuilderContext(density: Float): MotionBuilderContext { + return with(ViewMaterialSprings.Expressive) { + ViewMotionBuilderContext(Spatial, Effects, Density(density)) + } +} + +/** + * Material motion system spring definitions. + * + * See go/motion-system. + * + * NOTE: These are only defined here since material spring parameters are not available for View + * based APIs. There might be a delay in updating these values, should the material tokens be + * updated in the future. + * + * @see rememberMotionBuilderContext for Compose + */ +object ViewMaterialSprings { + object Default { + val Spatial = + MaterialSprings( + SpringParameters(700.0f, 0.9f), + SpringParameters(1400.0f, 0.9f), + SpringParameters(300.0f, 0.9f), + MotionBuilderContext.StableThresholdSpatial, + ) + + val Effects = + MaterialSprings( + SpringParameters(1600.0f, 1.0f), + SpringParameters(3800.0f, 1.0f), + SpringParameters(800.0f, 1.0f), + MotionBuilderContext.StableThresholdEffects, + ) + } + + object Expressive { + val Spatial = + MaterialSprings( + SpringParameters(380.0f, 0.8f), + SpringParameters(800.0f, 0.6f), + SpringParameters(200.0f, 0.8f), + MotionBuilderContext.StableThresholdSpatial, + ) + + val Effects = + MaterialSprings( + SpringParameters(1600.0f, 1.0f), + SpringParameters(3800.0f, 1.0f), + SpringParameters(800.0f, 1.0f), + MotionBuilderContext.StableThresholdEffects, + ) + } +} + +internal class ViewMotionBuilderContext( + override val spatial: MaterialSprings, + override val effects: MaterialSprings, + density: Density, +) : MotionBuilderContext, Density by density diff --git a/mechanics/src/com/android/mechanics/view/ViewMotionValue.kt b/mechanics/src/com/android/mechanics/view/ViewMotionValue.kt new file mode 100644 index 0000000..617e363 --- /dev/null +++ b/mechanics/src/com/android/mechanics/view/ViewMotionValue.kt @@ -0,0 +1,342 @@ +/* + * Copyright (C) 2025 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.view + +import android.animation.ValueAnimator +import androidx.compose.ui.util.fastForEach +import com.android.mechanics.MotionValue.Companion.StableThresholdEffect +import com.android.mechanics.debug.DebugInspector +import com.android.mechanics.debug.FrameData +import com.android.mechanics.impl.Computations +import com.android.mechanics.impl.DiscontinuityAnimation +import com.android.mechanics.impl.GuaranteeState +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.SegmentData +import com.android.mechanics.spec.SegmentKey +import com.android.mechanics.spec.SemanticKey +import com.android.mechanics.spring.SpringState +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.DisposableHandle + +/** Observe MotionValue output changes. */ +fun interface ViewMotionValueListener { + /** Invoked whenever the ViewMotionValue computed a new output. */ + fun onMotionValueUpdated(motionValue: ViewMotionValue) +} + +/** + * [MotionValue] implementation for View-based UIs. + * + * See the documentation of [MotionValue]. + */ +class ViewMotionValue +@JvmOverloads +constructor( + initialInput: Float, + gestureContext: ViewGestureContext, + initialSpec: MotionSpec = MotionSpec.Empty, + label: String? = null, + stableThreshold: Float = StableThresholdEffect, +) : DisposableHandle { + + private val impl = + ImperativeComputations( + this, + initialInput, + gestureContext, + initialSpec, + stableThreshold, + label, + ) + + var input: Float by impl::currentInput + + var spec: MotionSpec by impl::spec + + /** Animated [output] value. */ + val output: Float by impl::output + + /** + * [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 by impl::outputTarget + + /** Whether an animation is currently running. */ + val isStable: Boolean by impl::isStable + + /** + * The current value for the [SemanticKey]. + * + * `null` if not defined in the spec. + */ + operator fun get(key: SemanticKey): T? { + return impl.semanticState(key) + } + + /** The current segment used to compute the output. */ + val segmentKey: SegmentKey + get() = impl.currentComputedValues.segment.key + + val label: String? by impl::label + + fun addUpdateCallback(listener: ViewMotionValueListener) { + check(impl.isActive) + impl.listeners.add(listener) + } + + fun removeUpdateCallback(listener: ViewMotionValueListener) { + impl.listeners.remove(listener) + } + + override fun dispose() { + impl.dispose() + } + + companion object { + internal const val TAG = "ViewMotionValue" + } + + private var debugInspectorRefCount = AtomicInteger(0) + + private fun onDisposeDebugInspector() { + if (debugInspectorRefCount.decrementAndGet() == 0) { + impl.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) { + impl.debugInspector = + DebugInspector( + FrameData( + impl.lastInput, + impl.lastSegment.direction, + impl.lastGestureDragOffset, + impl.lastFrameTimeNanos, + impl.lastSpringState, + impl.lastSegment, + impl.lastAnimation, + ), + impl.isActive, + impl.animationFrameDriver.isRunning, + ::onDisposeDebugInspector, + ) + } + + return checkNotNull(impl.debugInspector) + } +} + +private class ImperativeComputations( + private val motionValue: ViewMotionValue, + initialInput: Float, + val gestureContext: ViewGestureContext, + initialSpec: MotionSpec, + override val stableThreshold: Float, + override val label: String?, +) : Computations(), GestureContextUpdateListener { + + init { + gestureContext.addUpdateCallback(this) + } + + override fun onGestureContextUpdated() { + ensureFrameRequested() + } + + // ---- CurrentFrameInput --------------------------------------------------------------------- + + override var spec: MotionSpec = initialSpec + set(value) { + if (field != value) { + field = value + ensureFrameRequested() + } + } + + override var currentInput: Float = initialInput + set(value) { + if (field != value) { + field = value + ensureFrameRequested() + } + } + + override val currentDirection: InputDirection + get() = gestureContext.direction + + override val currentGestureDragOffset: Float + get() = gestureContext.dragOffset + + override var currentAnimationTimeNanos: Long = -1L + + // ---- LastFrameState --------------------------------------------------------------------- + + override var lastSegment: SegmentData = spec.segmentAtInput(currentInput, currentDirection) + override var lastGuaranteeState: GuaranteeState = GuaranteeState.Inactive + override var lastAnimation: DiscontinuityAnimation = DiscontinuityAnimation.None + override var lastSpringState: SpringState = lastAnimation.springStartState + override var lastFrameTimeNanos: Long = -1L + override var lastInput: Float = currentInput + override var lastGestureDragOffset: Float = currentGestureDragOffset + override var directMappedVelocity: Float = 0f + var lastDirection: InputDirection = currentDirection + + // ---- Lifecycle ------------------------------------------------------------------------------ + + // HACK: Use a ValueAnimator to listen to animation frames without using Choreographer directly. + // This is done solely for testability - because the AnimationHandler is not usable directly[1], + // this resumes/pauses a - for all practical purposes - infinite animation. + // + // [1] the android one is hidden API, the androidx one is package private, and the + // dynamicanimation one is not controllable from tests). + val animationFrameDriver = + ValueAnimator().apply { + setFloatValues(Float.MIN_VALUE, Float.MAX_VALUE) + duration = Long.MAX_VALUE + repeatMode = ValueAnimator.RESTART + repeatCount = ValueAnimator.INFINITE + start() + pause() + addUpdateListener { + val isAnimationFinished = updateOutputValue(currentPlayTime) + if (isAnimationFinished) { + pause() + } + } + } + + fun ensureFrameRequested() { + if (animationFrameDriver.isPaused) { + animationFrameDriver.resume() + debugInspector?.isAnimating = true + } + } + + fun pauseFrameRequests() { + if (animationFrameDriver.isRunning) { + animationFrameDriver.pause() + debugInspector?.isAnimating = false + } + } + + /** `true` until disposed with [MotionValue.dispose]. */ + var isActive = true + set(value) { + field = value + debugInspector?.isActive = value + } + + var debugInspector: DebugInspector? = null + + val listeners = mutableListOf() + + fun dispose() { + check(isActive) { "ViewMotionValue[$label] is already disposed" } + pauseFrameRequests() + animationFrameDriver.end() + isActive = false + listeners.clear() + } + + // indicates whether doAnimationFrame is called continuously (as opposed to being + // suspended for an undetermined amount of time in between frames). + var isAnimatingUninterrupted = false + + fun updateOutputValue(frameTimeMillis: Long): Boolean { + check(isActive) { "ViewMotionValue($label) is already disposed." } + + currentAnimationTimeNanos = frameTimeMillis * 1_000_000L + + // Read currentComputedValues only once and update it, if necessary + val currentValues = currentComputedValues + + debugInspector?.run { + frame = + FrameData( + currentInput, + currentDirection, + currentGestureDragOffset, + currentAnimationTimeNanos, + currentSpringState, + currentValues.segment, + currentValues.animation, + ) + } + + listeners.fastForEach { it.onMotionValueUpdated(motionValue) } + + // Prepare last* state + if (isAnimatingUninterrupted) { + directMappedVelocity = + computeDirectMappedVelocity(currentAnimationTimeNanos - lastFrameTimeNanos) + } else { + directMappedVelocity = 0f + } + + var isAnimationFinished = isStable + if (lastSegment != currentValues.segment) { + lastSegment = currentValues.segment + isAnimationFinished = false + } + + if (lastGuaranteeState != currentValues.guarantee) { + lastGuaranteeState = currentValues.guarantee + isAnimationFinished = false + } + + if (lastAnimation != currentValues.animation) { + lastAnimation = currentValues.animation + isAnimationFinished = false + } + + if (lastSpringState != currentSpringState) { + lastSpringState = currentSpringState + isAnimationFinished = false + } + + if (lastInput != currentInput) { + lastInput = currentInput + isAnimationFinished = false + } + + if (lastGestureDragOffset != currentGestureDragOffset) { + lastGestureDragOffset = currentGestureDragOffset + isAnimationFinished = false + } + + if (lastDirection != currentDirection) { + lastDirection = currentDirection + isAnimationFinished = false + } + + lastFrameTimeNanos = currentAnimationTimeNanos + isAnimatingUninterrupted = !isAnimationFinished + + return isAnimationFinished + } +} diff --git a/mechanics/testing/Android.bp b/mechanics/testing/Android.bp new file mode 100644 index 0000000..ac49d16 --- /dev/null +++ b/mechanics/testing/Android.bp @@ -0,0 +1,37 @@ +// Copyright (C) 2025 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 { + default_applicable_licenses: ["Android-Apache-2.0"], + default_team: "trendy_team_motion", +} + +android_library { + name: "mechanics-testing", + manifest: "AndroidManifest.xml", + srcs: [ + "src/**/*.kt", + ], + static_libs: [ + "//frameworks/libs/systemui/mechanics:mechanics", + "platform-test-annotations", + "PlatformMotionTestingCompose", + "androidx.compose.runtime_runtime", + "androidx.compose.ui_ui-test-junit4", + "testables", + "truth", + ], + kotlincflags: ["-Xjvm-default=all"], +} diff --git a/mechanics/testing/AndroidManifest.xml b/mechanics/testing/AndroidManifest.xml new file mode 100644 index 0000000..20c40b0 --- /dev/null +++ b/mechanics/testing/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + diff --git a/mechanics/testing/src/com/android/mechanics/testing/ComposeMotionValueToolkit.kt b/mechanics/testing/src/com/android/mechanics/testing/ComposeMotionValueToolkit.kt new file mode 100644 index 0000000..0144a16 --- /dev/null +++ b/mechanics/testing/src/com/android/mechanics/testing/ComposeMotionValueToolkit.kt @@ -0,0 +1,176 @@ +/* + * 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.testing + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot +import com.android.mechanics.DistanceGestureContext +import com.android.mechanics.MotionValue +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.MotionSpec +import kotlinx.coroutines.Dispatchers +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.runBlocking +import platform.test.motion.MotionTestRule +import platform.test.motion.compose.runMonotonicClockTest +import platform.test.motion.golden.FrameId +import platform.test.motion.golden.TimeSeries +import platform.test.motion.golden.TimestampFrameId + +/** Toolkit to support [MotionValue] motion tests. */ +data object ComposeMotionValueToolkit : MotionValueToolkit() { + + override fun goldenTest( + motionTestRule: MotionTestRule<*>, + spec: MotionSpec, + createDerived: (underTest: MotionValue) -> List, + initialValue: Float, + initialDirection: InputDirection, + directionChangeSlop: Float, + stableThreshold: Float, + verifyTimeSeries: TimeSeries.() -> VerifyTimeSeriesResult, + capture: CaptureTimeSeriesFn, + testInput: suspend InputScope.() -> Unit, + ) = runMonotonicClockTest { + val frameEmitter = MutableStateFlow(0) + + val testHarness = + ComposeMotionValueTestHarness( + initialValue, + initialDirection, + spec, + stableThreshold, + directionChangeSlop, + frameEmitter.asStateFlow(), + createDerived, + ) + val underTest = testHarness.underTest + val derived = testHarness.derived + + val motionValueCaptures = buildList { + add(MotionValueCapture(underTest.debugInspector())) + derived.forEach { add(MotionValueCapture(it.debugInspector(), "${it.label}-")) } + } + + val keepRunningJobs = (derived + underTest).map { launch { it.keepRunning() } } + + val recordingJob = launch { testInput.invoke(testHarness) } + + val frameIds = mutableListOf() + + fun recordFrame(frameId: TimestampFrameId) { + frameIds.add(frameId) + motionValueCaptures.forEach { it.captureCurrentFrame(capture) } + } + runBlocking(Dispatchers.Main) { + val startFrameTime = testScheduler.currentTime + while (!recordingJob.isCompleted) { + recordFrame(TimestampFrameId(testScheduler.currentTime - startFrameTime)) + + // Emulate setting input *before* the frame advances. This ensures the `testInput` + // coroutine will continue if needed. The specific value for frameEmitter is + // irrelevant, it only requires to be unique per frame. + frameEmitter.tryEmit(testScheduler.currentTime) + testScheduler.runCurrent() + // Whenever keepRunning was suspended, allow the snapshotFlow to wake up + Snapshot.sendApplyNotifications() + + // Now advance the test clock + testScheduler.advanceTimeBy(FrameDuration) + // Since the tests capture the debugInspector output, make sure keepRunning() + // was able to complete the frame. + testScheduler.runCurrent() + } + } + + val timeSeries = createTimeSeries(frameIds, motionValueCaptures) + motionValueCaptures.forEach { it.debugger.dispose() } + keepRunningJobs.forEach { it.cancel() } + verifyTimeSeries(motionTestRule, timeSeries, verifyTimeSeries) + } +} + +private class ComposeMotionValueTestHarness( + initialInput: Float, + initialDirection: InputDirection, + spec: MotionSpec, + stableThreshold: Float, + directionChangeSlop: Float, + val onFrame: StateFlow, + createDerived: (underTest: MotionValue) -> List, +) : 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, + ) + + val derived = createDerived(underTest) + + override fun updateInput(value: Float) { + input = value + gestureContext.dragOffset = value + } + + override suspend fun awaitStable() { + val debugInspectors = buildList { + add(underTest.debugInspector()) + addAll(derived.map { it.debugInspector() }) + } + try { + + onFrame + // Since this is a state-flow, the current frame is counted too. + .drop(1) + .takeWhile { debugInspectors.any { !it.frame.isStable } } + .collect {} + } finally { + debugInspectors.forEach { it.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 fun reset(position: Float, direction: InputDirection) { + input = position + gestureContext.reset(position, direction) + } +} diff --git a/mechanics/testing/src/com/android/mechanics/testing/DataPointTypes.kt b/mechanics/testing/src/com/android/mechanics/testing/DataPointTypes.kt new file mode 100644 index 0000000..013a0dd --- /dev/null +++ b/mechanics/testing/src/com/android/mechanics/testing/DataPointTypes.kt @@ -0,0 +1,69 @@ +/* + * 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 +import com.android.mechanics.spring.SpringState +import com.android.mechanics.testing.DataPointTypes.springParameters +import com.android.mechanics.testing.DataPointTypes.springState +import org.json.JSONObject +import platform.test.motion.golden.DataPointType +import platform.test.motion.golden.UnknownTypeException + +fun SpringParameters.asDataPoint() = springParameters.makeDataPoint(this) + +fun SpringState.asDataPoint() = springState.makeDataPoint(this) + +object DataPointTypes { + val springParameters: DataPointType = + DataPointType( + "springParameters", + jsonToValue = { + with(it as? JSONObject ?: throw UnknownTypeException()) { + SpringParameters( + getDouble("stiffness").toFloat(), + getDouble("dampingRatio").toFloat(), + ) + } + }, + valueToJson = { + JSONObject().apply { + put("stiffness", it.stiffness) + put("dampingRatio", it.dampingRatio) + } + }, + ) + + val springState: DataPointType = + DataPointType( + "springState", + jsonToValue = { + with(it as? JSONObject ?: throw UnknownTypeException()) { + SpringState( + getDouble("displacement").toFloat(), + getDouble("velocity").toFloat(), + ) + } + }, + valueToJson = { + JSONObject().apply { + put("displacement", it.displacement) + put("velocity", it.velocity) + } + }, + ) +} diff --git a/mechanics/testing/src/com/android/mechanics/testing/FakeMotionSpecBuilderContext.kt b/mechanics/testing/src/com/android/mechanics/testing/FakeMotionSpecBuilderContext.kt new file mode 100644 index 0000000..93855f4 --- /dev/null +++ b/mechanics/testing/src/com/android/mechanics/testing/FakeMotionSpecBuilderContext.kt @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2025 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 androidx.compose.ui.unit.Density +import com.android.mechanics.spec.builder.MaterialSprings +import com.android.mechanics.spec.builder.MotionBuilderContext +import com.android.mechanics.spring.SpringParameters + +/** + * [MotionBuilderContext] implementation for unit tests. + * + * Only use when the specifics of the spring parameters do not matter for the test. + * + * While the values are copied from the current material motion tokens, this can (and likely will) + * get out of sync with the material tokens, and is not intended reflect the up-to-date tokens, but + * provide a stable definitions of "some" spring parameters. + */ +class FakeMotionSpecBuilderContext(density: Float = 1f) : + MotionBuilderContext, Density by Density(density) { + override val spatial = + MaterialSprings( + SpringParameters(700.0f, 0.9f), + SpringParameters(1400.0f, 0.9f), + SpringParameters(300.0f, 0.9f), + MotionBuilderContext.StableThresholdSpatial, + ) + + override val effects = + MaterialSprings( + SpringParameters(1600.0f, 1.0f), + SpringParameters(3800.0f, 1.0f), + SpringParameters(800.0f, 1.0f), + MotionBuilderContext.StableThresholdEffects, + ) + + companion object { + val Default = FakeMotionSpecBuilderContext() + } +} diff --git a/mechanics/testing/src/com/android/mechanics/testing/FeatureCaptures.kt b/mechanics/testing/src/com/android/mechanics/testing/FeatureCaptures.kt new file mode 100644 index 0000000..d8ef1cf --- /dev/null +++ b/mechanics/testing/src/com/android/mechanics/testing/FeatureCaptures.kt @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2025 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.debug.DebugInspector +import com.android.mechanics.spec.SemanticKey +import com.android.mechanics.spring.SpringParameters +import com.android.mechanics.spring.SpringState +import platform.test.motion.golden.DataPointType +import platform.test.motion.golden.FeatureCapture +import platform.test.motion.golden.asDataPoint + +/** Feature captures on MotionValue's [DebugInspector] */ +object FeatureCaptures { + /** Input value of the current frame. */ + val input = FeatureCapture("input") { it.frame.input.asDataPoint() } + + /** Gesture direction of the current frame. */ + val gestureDirection = + FeatureCapture("gestureDirection") { + it.frame.gestureDirection.name.asDataPoint() + } + + /** Animated output value of the current frame. */ + val output = FeatureCapture("output") { it.frame.output.asDataPoint() } + + /** Output target value of the current frame. */ + val outputTarget = + FeatureCapture("outputTarget") { + it.frame.outputTarget.asDataPoint() + } + + /** Spring parameters currently in use. */ + val springParameters = + FeatureCapture("springParameters") { + it.frame.springParameters.asDataPoint() + } + + /** Spring state currently in use. */ + val springState = + FeatureCapture("springState") { + it.frame.springState.asDataPoint() + } + + /** Whether the spring is currently stable. */ + val isStable = + FeatureCapture("isStable") { it.frame.isStable.asDataPoint() } + + /** A semantic value to capture in the golden. */ + fun semantics( + key: SemanticKey, + dataPointType: DataPointType, + name: String = key.debugLabel, + ): FeatureCapture { + return FeatureCapture(name) { dataPointType.makeDataPoint(it.frame.semantic(key)) } + } +} diff --git a/mechanics/testing/src/com/android/mechanics/testing/MotionSpecSubject.kt b/mechanics/testing/src/com/android/mechanics/testing/MotionSpecSubject.kt new file mode 100644 index 0000000..9816d01 --- /dev/null +++ b/mechanics/testing/src/com/android/mechanics/testing/MotionSpecSubject.kt @@ -0,0 +1,308 @@ +/* + * Copyright (C) 2025 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.spec.Breakpoint +import com.android.mechanics.spec.BreakpointKey +import com.android.mechanics.spec.DirectionalMotionSpec +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.SemanticKey +import com.android.mechanics.testing.BreakpointSubject.Companion.BreakpointKeys +import com.android.mechanics.testing.BreakpointSubject.Companion.BreakpointPositions +import com.google.common.truth.Correspondence.transforming +import com.google.common.truth.FailureMetadata +import com.google.common.truth.FloatSubject +import com.google.common.truth.IterableSubject +import com.google.common.truth.Subject +import com.google.common.truth.Subject.Factory +import com.google.common.truth.Truth + +/** Subject to verify the definition of a [MotionSpec]. */ +class MotionSpecSubject +internal constructor(failureMetadata: FailureMetadata, private val actual: MotionSpec?) : + Subject(failureMetadata, actual) { + + fun minDirection(): DirectionalMotionSpecSubject { + isNotNull() + + return check("min") + .about(DirectionalMotionSpecSubject.SubjectFactory) + .that(actual?.minDirection) + } + + fun maxDirection(): DirectionalMotionSpecSubject { + isNotNull() + + return check("max") + .about(DirectionalMotionSpecSubject.SubjectFactory) + .that(actual?.maxDirection) + } + + fun bothDirections(): DirectionalMotionSpecSubject { + isNotNull() + check("both directions same").that(actual?.minDirection).isEqualTo(actual?.maxDirection) + return check("both") + .about(DirectionalMotionSpecSubject.SubjectFactory) + .that(actual?.maxDirection) + } + + companion object { + + /** Returns a factory to be used with [Truth.assertAbout]. */ + val SubjectFactory = Factory { failureMetadata: FailureMetadata, subject: MotionSpec? -> + MotionSpecSubject(failureMetadata, subject) + } + + /** Shortcut for `Truth.assertAbout(motionSpec()).that(spec)`. */ + fun assertThat(spec: MotionSpec): MotionSpecSubject = + Truth.assertAbout(SubjectFactory).that(spec) + } +} + +/** Subject to verify the definition of a [DirectionalMotionSpec]. */ +class DirectionalMotionSpecSubject +internal constructor(failureMetadata: FailureMetadata, private val actual: DirectionalMotionSpec?) : + Subject(failureMetadata, actual) { + + /** Assert on breakpoints, excluding the implicit start and end breakpoints. */ + fun breakpoints(): BreakpointsSubject { + isNotNull() + + return check("breakpoints").about(BreakpointsSubject.SubjectFactory).that(actual) + } + + fun breakpointsPositionsMatch(vararg positions: Float) { + isNotNull() + + return check("breakpoints") + .about(BreakpointsSubject.SubjectFactory) + .that(actual) + .positions() + .containsExactlyElementsIn(positions.toTypedArray()) + .inOrder() + } + + /** Assert on the mappings. */ + fun mappings(): MappingsSubject { + isNotNull() + + return check("mappings").about(MappingsSubject.SubjectFactory).that(actual) + } + + /** Assert that the mappings contain exactly the specified mappings, in order . */ + fun mappingsMatch(vararg mappings: Mapping) { + isNotNull() + + check("mappings") + .about(MappingsSubject.SubjectFactory) + .that(actual) + .containsExactlyElementsIn(mappings) + .inOrder() + } + + /** Assert that the mappings contain exactly the specified [Fixed] mappings, in order . */ + fun fixedMappingsMatch(vararg fixedMappingValues: Float) { + isNotNull() + + check("fixed mappings") + .about(MappingsSubject.SubjectFactory) + .that(actual) + .comparingElementsUsing( + transforming({ (it as? Mapping.Fixed)?.value }, "Fixed.value") + ) + .containsExactlyElementsIn(fixedMappingValues.toTypedArray()) + .inOrder() + } + + /** Assert on the semantics. */ + fun semantics(): SemanticsSubject { + isNotNull() + + return check("semantics").about(SemanticsSubject.SubjectFactory).that(actual) + } + + companion object { + + /** Returns a factory to be used with [Truth.assertAbout]. */ + val SubjectFactory = + Factory { failureMetadata: FailureMetadata, subject: DirectionalMotionSpec? -> + DirectionalMotionSpecSubject(failureMetadata, subject) + } + + /** Shortcut for `Truth.assertAbout(directionalMotionSpec()).that(spec)`. */ + fun assertThat(spec: DirectionalMotionSpec): DirectionalMotionSpecSubject = + Truth.assertAbout(SubjectFactory).that(spec) + } +} + +/** Subject to assert on the list of breakpoints of a [DirectionalMotionSpec]. */ +class BreakpointsSubject( + failureMetadata: FailureMetadata, + private val actual: DirectionalMotionSpec?, +) : IterableSubject(failureMetadata, actual?.breakpoints?.let { it.slice(1 until it.size - 1) }) { + + fun keys() = comparingElementsUsing(BreakpointKeys) + + fun positions() = comparingElementsUsing(BreakpointPositions) + + fun atPosition(position: Float): BreakpointSubject { + return check("breakpoint @ $position") + .about(BreakpointSubject.SubjectFactory) + .that(actual?.breakpoints?.find { it.position == position }) + } + + fun withKey(key: BreakpointKey): BreakpointSubject { + return check("breakpoint with $key]") + .about(BreakpointSubject.SubjectFactory) + .that(actual?.breakpoints?.find { it.key == key }) + } + + companion object { + + /** Returns a factory to be used with [Truth.assertAbout]. */ + val SubjectFactory = + Factory { failureMetadata, subject -> + BreakpointsSubject(failureMetadata, subject) + } + } +} + +/** Subject to assert on a [Breakpoint] definition. */ +class BreakpointSubject +internal constructor(failureMetadata: FailureMetadata, private val actual: Breakpoint?) : + Subject(failureMetadata, actual) { + + fun exists() { + isNotNull() + } + + fun key(): Subject { + return check("key").that(actual?.key) + } + + fun position(): FloatSubject { + return check("position").that(actual?.position) + } + + fun guarantee(): Subject { + return check("guarantee").that(actual?.guarantee) + } + + fun spring(): Subject { + return check("spring").that(actual?.spring) + } + + fun isAt(position: Float) = position().isEqualTo(position) + + fun hasKey(key: BreakpointKey) = key().isEqualTo(key) + + companion object { + val BreakpointKeys = transforming({ it?.key }, "key") + val BreakpointPositions = transforming({ it?.position }, "position") + + /** Returns a factory to be used with [Truth.assertAbout]. */ + val SubjectFactory = + Factory { failureMetadata, subject -> + BreakpointSubject(failureMetadata, subject) + } + + /** Shortcut for `Truth.assertAbout(subjectFactory).that(breakpoint)`. */ + fun assertThat(breakpoint: Breakpoint): BreakpointSubject = + Truth.assertAbout(SubjectFactory).that(breakpoint) + } +} + +/** Subject to assert on the list of mappings of a [DirectionalMotionSpec]. */ +class MappingsSubject( + failureMetadata: FailureMetadata, + private val actual: DirectionalMotionSpec?, +) : IterableSubject(failureMetadata, actual?.mappings) { + + /** Assert on the mapping at or after the specified position. */ + fun atOrAfter(position: Float): MappingSubject { + return check("mapping @ $position") + .about(MappingSubject.SubjectFactory) + .that(actual?.run { mappings[findBreakpointIndex(position)] }) + } + + companion object { + /** Returns a factory to be used with [Truth.assertAbout]. */ + val SubjectFactory = + Factory { failureMetadata, subject -> + MappingsSubject(failureMetadata, subject) + } + } +} + +/** Subject to assert on a [Mapping] function. */ +class MappingSubject +internal constructor(failureMetadata: FailureMetadata, private val actual: Mapping?) : + Subject(failureMetadata, actual) { + + fun matchesLinearMapping(in1: Float, out1: Float, in2: Float, out2: Float) { + isNotNull() + + check("input @ $in1").that(actual?.map(in1)).isEqualTo(out1) + check("input @ $in2").that(actual?.map(in2)).isEqualTo(out2) + } + + fun isFixedValue(value: Float) { + when (actual) { + is Mapping.Fixed -> check("fixed value").that(actual.value).isEqualTo(value) + is Mapping.Linear -> { + check("linear factor").that(actual.factor).isZero() + check("linear offset").that(actual.offset).isEqualTo(value) + } + + else -> failWithActual("Unexpected mapping type", actual) + } + } + + companion object { + /** Returns a factory to be used with [Truth.assertAbout]. */ + val SubjectFactory = + Factory { failureMetadata, subject -> + MappingSubject(failureMetadata, subject) + } + + /** Shortcut for `Truth.assertAbout(subjectFactory).that(mapping)`. */ + fun assertThat(mapping: Mapping): MappingSubject = + Truth.assertAbout(SubjectFactory).that(mapping) + } +} + +/** Subject to assert on the list of semantic values of a [DirectionalMotionSpec]. */ +class SemanticsSubject( + failureMetadata: FailureMetadata, + private val actual: DirectionalMotionSpec?, +) : IterableSubject(failureMetadata, actual?.semantics?.map { it.key }) { + + /** Assert on the semantic values of the. */ + fun withKey(key: SemanticKey<*>): IterableSubject { + return check("semantic $key") + .that(actual?.run { semantics.firstOrNull { it.key == key }?.values }) + } + + companion object { + /** Returns a factory to be used with [Truth.assertAbout]. */ + val SubjectFactory = + Factory { failureMetadata, subject -> + SemanticsSubject(failureMetadata, subject) + } + } +} diff --git a/mechanics/testing/src/com/android/mechanics/testing/MotionValueToolkit.kt b/mechanics/testing/src/com/android/mechanics/testing/MotionValueToolkit.kt new file mode 100644 index 0000000..a96ca99 --- /dev/null +++ b/mechanics/testing/src/com/android/mechanics/testing/MotionValueToolkit.kt @@ -0,0 +1,230 @@ +/* + * Copyright (C) 2025 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.MotionValue +import com.android.mechanics.debug.DebugInspector +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 kotlin.time.Duration.Companion.milliseconds +import platform.test.motion.MotionTestRule +import platform.test.motion.RecordedMotion.Companion.create +import platform.test.motion.golden.DataPoint +import platform.test.motion.golden.Feature +import platform.test.motion.golden.FrameId +import platform.test.motion.golden.TimeSeries +import platform.test.motion.golden.TimeSeriesCaptureScope + +/** + * Records and verifies a timeseries of the [MotionValue]'s output. + * + * Tests provide at a minimum the initial [spec], and a [testInput] function, which defines the + * [MotionValue] input over time. + * + * @param spec The initial [MotionSpec] + * @param initialValue The initial value of the [MotionValue] + * @param initialDirection The initial [InputDirection] of the [MotionValue] + * @param directionChangeSlop the minimum distance for the input to change in the opposite direction + * before the underlying GestureContext changes direction. + * @param stableThreshold The maximum remaining oscillation amplitude for the springs to be + * considered stable. + * @param verifyTimeSeries Custom verification function to write assertions on the captured time + * series. If the function returns `SkipGoldenVerification`, the timeseries won`t be compared to a + * golden. + * @param createDerived (experimental) Creates derived MotionValues + * @param capture The features to capture on each motion value. See [defaultFeatureCaptures] for + * defaults. + * @param testInput Controls the MotionValue during the test. The timeseries is being recorded until + * the function completes. + * @see ComposeMotionValueToolkit + * @see ViewMotionValueToolkit + */ +fun < + T : MotionValueToolkit, + MotionValueType, + GestureContextType, +> MotionTestRule.goldenTest( + spec: MotionSpec, + initialValue: Float = 0f, + initialDirection: InputDirection = InputDirection.Max, + directionChangeSlop: Float = 5f, + stableThreshold: Float = 0.01f, + verifyTimeSeries: VerifyTimeSeriesFn = { + VerifyTimeSeriesResult.AssertTimeSeriesMatchesGolden() + }, + createDerived: (underTest: MotionValueType) -> List = { emptyList() }, + capture: CaptureTimeSeriesFn = defaultFeatureCaptures, + testInput: suspend (InputScope).() -> Unit, +) { + toolkit.goldenTest( + this, + spec, + createDerived, + initialValue, + initialDirection, + directionChangeSlop, + stableThreshold, + verifyTimeSeries, + capture, + testInput, + ) +} + +/** Scope to control the MotionValue during a test. */ +interface InputScope { + /** Current input of the `MotionValue` */ + val input: Float + /** GestureContext created for the `MotionValue` */ + val gestureContext: GestureContextType + /** MotionValue being tested. */ + val underTest: MotionValueType + + /** Updates the input value *and* the `gestureContext.dragOffset`. */ + fun updateInput(value: Float) + + /** Resets the input value *and* the `gestureContext.dragOffset`, inclusive of direction. */ + fun reset(position: Float, direction: InputDirection) + + /** Waits for `underTest` and derived `MotionValues` to become stable. */ + suspend fun awaitStable() + + /** Waits for the next "frame" (16ms). */ + suspend fun awaitFrames(frames: Int = 1) +} + +/** Animates the input linearly from the current [input] to the [targetValue]. */ +suspend fun InputScope<*, *>.animateValueTo( + targetValue: Float, + changePerFrame: Float = abs(input - targetValue) / 5f, +) { + 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 + updateInput(currentValue) + awaitFrames() + } + + updateInput(targetValue) + awaitFrames() +} + +/** Sets the input to the [values], one value per frame. */ +suspend fun InputScope<*, *>.animatedInputSequence(vararg values: Float) { + values.forEach { + updateInput(it) + awaitFrames() + } +} + +/** Custom functions to write assertions on the recorded [TimeSeries] */ +typealias VerifyTimeSeriesFn = TimeSeries.() -> VerifyTimeSeriesResult + +/** [VerifyTimeSeriesFn] indicating whether the timeseries should be verified the golden file. */ +interface VerifyTimeSeriesResult { + data object SkipGoldenVerification : VerifyTimeSeriesResult + + data class AssertTimeSeriesMatchesGolden(val goldenName: String? = null) : + VerifyTimeSeriesResult +} + +typealias CaptureTimeSeriesFn = TimeSeriesCaptureScope.() -> Unit + +/** Default feature captures. */ +val defaultFeatureCaptures: CaptureTimeSeriesFn = { + feature(FeatureCaptures.input) + feature(FeatureCaptures.gestureDirection) + feature(FeatureCaptures.output) + feature(FeatureCaptures.outputTarget) + feature(FeatureCaptures.springParameters, name = "outputSpring") + feature(FeatureCaptures.isStable) +} + +sealed class MotionValueToolkit { + internal abstract fun goldenTest( + motionTestRule: MotionTestRule<*>, + spec: MotionSpec, + createDerived: (underTest: MotionValueType) -> List, + initialValue: Float, + initialDirection: InputDirection, + directionChangeSlop: Float, + stableThreshold: Float, + verifyTimeSeries: TimeSeries.() -> VerifyTimeSeriesResult, + capture: CaptureTimeSeriesFn, + testInput: suspend (InputScope).() -> Unit, + ) + + internal fun createTimeSeries( + frameIds: List, + motionValueCaptures: List, + ): TimeSeries { + return TimeSeries( + frameIds.toList(), + motionValueCaptures.flatMap { motionValueCapture -> + motionValueCapture.propertyCollector.entries.map { (name, dataPoints) -> + Feature("${motionValueCapture.prefix}$name", dataPoints) + } + }, + ) + } + + internal fun verifyTimeSeries( + motionTestRule: MotionTestRule<*>, + timeSeries: TimeSeries, + verificationFn: TimeSeries.() -> VerifyTimeSeriesResult, + ) { + val recordedMotion = motionTestRule.create(timeSeries, screenshots = null) + var assertTimeseriesMatchesGolden = false + var goldenName: String? = null + try { + + val result = verificationFn.invoke(recordedMotion.timeSeries) + if (result is VerifyTimeSeriesResult.AssertTimeSeriesMatchesGolden) { + assertTimeseriesMatchesGolden = true + goldenName = result.goldenName + } + } finally { + try { + motionTestRule.assertThat(recordedMotion).timeSeriesMatchesGolden(goldenName) + } catch (e: AssertionError) { + if (assertTimeseriesMatchesGolden) { + throw e + } + } + } + } + + companion object { + val FrameDuration = 16.milliseconds + } +} + +internal class MotionValueCapture(val debugger: DebugInspector, val prefix: String = "") { + val propertyCollector = mutableMapOf>>() + val captureScope = TimeSeriesCaptureScope(debugger, propertyCollector) + + fun captureCurrentFrame(captureFn: CaptureTimeSeriesFn) { + captureFn(captureScope) + } +} diff --git a/mechanics/testing/src/com/android/mechanics/testing/TimeSeries.kt b/mechanics/testing/src/com/android/mechanics/testing/TimeSeries.kt new file mode 100644 index 0000000..d72d6d1 --- /dev/null +++ b/mechanics/testing/src/com/android/mechanics/testing/TimeSeries.kt @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2025 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 platform.test.motion.golden.Feature +import platform.test.motion.golden.TimeSeries +import platform.test.motion.golden.ValueDataPoint + +val TimeSeries.input: List + get() = dataPoints("input") + +val TimeSeries.output: List + get() = dataPoints("output") + +val TimeSeries.outputTarget: List + get() = dataPoints("outputTarget") + +val TimeSeries.isStable: List + get() = dataPoints("isStable") + +/** + * Returns data points for the given [featureName]. + * + * Throws a [ClassCastException] if any data point is not a [ValueDataPoint] of type [T]. + */ +inline fun TimeSeries.dataPoints(featureName: String): List { + return (features[featureName] as Feature<*>).dataPoints.map { + (it as ValueDataPoint).value as T + } +} + +/** + * Returns data points for the given [featureName]. + * + * Returns `null` for all data points that are not a [ValueDataPoint] of type [T]. + */ +inline fun TimeSeries.nullableDataPoints(featureName: String): List { + return (features[featureName] as Feature<*>).dataPoints.map { + (it as? ValueDataPoint)?.value as T? + } +} diff --git a/mechanics/testing/src/com/android/mechanics/testing/ViewMotionValueToolkit.kt b/mechanics/testing/src/com/android/mechanics/testing/ViewMotionValueToolkit.kt new file mode 100644 index 0000000..cbe18d5 --- /dev/null +++ b/mechanics/testing/src/com/android/mechanics/testing/ViewMotionValueToolkit.kt @@ -0,0 +1,167 @@ +/* + * 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.testing + +import android.animation.AnimatorTestRule +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.view.DistanceGestureContext +import com.android.mechanics.view.ViewMotionValue +import kotlinx.coroutines.Dispatchers +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.runBlocking +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import platform.test.motion.MotionTestRule +import platform.test.motion.golden.FrameId +import platform.test.motion.golden.TimeSeries +import platform.test.motion.golden.TimestampFrameId + +/** Toolkit to support [ViewMotionValue] motion tests. */ +class ViewMotionValueToolkit(private val animatorTestRule: AnimatorTestRule) : + MotionValueToolkit() { + + override fun goldenTest( + motionTestRule: MotionTestRule<*>, + spec: MotionSpec, + createDerived: (underTest: ViewMotionValue) -> List, + initialValue: Float, + initialDirection: InputDirection, + directionChangeSlop: Float, + stableThreshold: Float, + verifyTimeSeries: TimeSeries.() -> VerifyTimeSeriesResult, + capture: CaptureTimeSeriesFn, + testInput: suspend InputScope.() -> Unit, + ) = runTest { + val frameEmitter = MutableStateFlow(0) + + val testHarness = + runBlocking(Dispatchers.Main) { + ViewMotionValueTestHarness( + initialValue, + initialDirection, + spec, + stableThreshold, + directionChangeSlop, + frameEmitter.asStateFlow(), + createDerived, + ) + .also { animatorTestRule.initNewAnimators() } + } + + val underTest = testHarness.underTest + val motionValueCapture = MotionValueCapture(underTest.debugInspector()) + val recordingJob = launch { testInput.invoke(testHarness) } + + val frameIds = mutableListOf() + + fun recordFrame(frameId: TimestampFrameId) { + frameIds.add(frameId) + motionValueCapture.captureCurrentFrame(capture) + } + + runBlocking(Dispatchers.Main) { + val startFrameTime = animatorTestRule.currentTime + while (!recordingJob.isCompleted) { + recordFrame(TimestampFrameId(animatorTestRule.currentTime - startFrameTime)) + + frameEmitter.tryEmit(animatorTestRule.currentTime) + runCurrent() + + animatorTestRule.advanceTimeBy(FrameDuration.inWholeMilliseconds) + runCurrent() + } + + val timeSeries = createTimeSeries(frameIds, listOf(motionValueCapture)) + + motionValueCapture.debugger.dispose() + underTest.dispose() + verifyTimeSeries(motionTestRule, timeSeries, verifyTimeSeries) + } + } +} + +private class ViewMotionValueTestHarness( + initialInput: Float, + initialDirection: InputDirection, + spec: MotionSpec, + stableThreshold: Float, + directionChangeSlop: Float, + val onFrame: StateFlow, + createDerived: (underTest: ViewMotionValue) -> List, +) : InputScope { + + override val gestureContext = + DistanceGestureContext(initialInput, initialDirection, directionChangeSlop) + + override val underTest = + ViewMotionValue( + initialInput, + gestureContext, + stableThreshold = stableThreshold, + initialSpec = spec, + ) + + override var input by underTest::input + + init { + require(createDerived(underTest).isEmpty()) { + "testing derived values is not yet supported" + } + } + + override fun updateInput(value: Float) { + input = value + gestureContext.dragOffset = value + } + + override suspend fun awaitStable() { + val debugInspectors = buildList { add(underTest.debugInspector()) } + try { + + onFrame + // Since this is a state-flow, the current frame is counted too. + .drop(1) + .takeWhile { debugInspectors.any { !it.frame.isStable } } + .collect {} + } finally { + debugInspectors.forEach { it.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 fun reset(position: Float, direction: InputDirection) { + input = position + gestureContext.reset(position, direction) + } +} diff --git a/mechanics/tests/Android.bp b/mechanics/tests/Android.bp new file mode 100644 index 0000000..1fa3b2c --- /dev/null +++ b/mechanics/tests/Android.bp @@ -0,0 +1,50 @@ +// 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 { + default_team: "trendy_team_motion", + default_applicable_licenses: ["Android-Apache-2.0"], +} + +android_test { + name: "mechanics_tests", + manifest: "AndroidManifest.xml", + defaults: ["MotionTestDefaults"], + test_suites: ["device-tests"], + + srcs: [ + "src/**/*.kt", + ], + + static_libs: [ + "//frameworks/libs/systemui/mechanics:mechanics", + "//frameworks/libs/systemui/mechanics:mechanics-testing", + "platform-test-annotations", + "PlatformMotionTestingCompose", + "androidx.compose.runtime_runtime", + "androidx.compose.animation_animation-core", + "androidx.compose.ui_ui-test-junit4", + "androidx.compose.ui_ui-test-manifest", + "androidx.test.runner", + "androidx.test.ext.junit", + "kotlin-test", + "testables", + "truth", + ], + associates: [ + "mechanics", + ], + asset_dirs: ["goldens"], + kotlincflags: ["-Xjvm-default=all"], +} diff --git a/mechanics/tests/AndroidManifest.xml b/mechanics/tests/AndroidManifest.xml new file mode 100644 index 0000000..049cfe2 --- /dev/null +++ b/mechanics/tests/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + diff --git a/mechanics/tests/goldens/MagneticDetach/placedAfter_afterAttach_detachesAgain.json b/mechanics/tests/goldens/MagneticDetach/placedAfter_afterAttach_detachesAgain.json new file mode 100644 index 0000000..f18ea96 --- /dev/null +++ b/mechanics/tests/goldens/MagneticDetach/placedAfter_afterAttach_detachesAgain.json @@ -0,0 +1,662 @@ +{ + "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, + 416, + 432, + 448, + 464, + 480, + 496, + 512, + 528, + 544, + 560, + 576, + 592, + 608, + 624, + 640, + 656, + 672, + 688, + 704, + 720, + 736, + 752, + 768, + 784, + 800, + 816, + 832, + 848, + 864, + 880, + 896, + 912, + 928, + 944, + 960, + 976 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 100, + 95, + 90, + 85, + 80, + 75, + 70, + 65, + 60, + 55, + 50, + 45, + 40, + 35, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 35, + 40, + 45, + 50, + 55, + 60, + 65, + 70, + 75, + 80, + 85, + 90, + 95, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "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", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 100, + 95, + 90, + 85, + 80, + 75, + 70, + 65, + 60, + 55, + 50, + 43.38443, + 36.351646, + 29.990938, + 24.672552, + 21.162388, + 18.574236, + 16.725906, + 15.440355, + 14.566638, + 13.985239, + 13.6060915, + 13.363756, + 13.212058, + 13.11921, + 13.063812, + 13.031747, + 13.013887, + 13.004453, + 13, + 13.75, + 14.5, + 16.449999, + 18.400002, + 20.35, + 22.300001, + 24.25, + 26.2, + 28.15, + 30.1, + 32.05, + 34, + 44.585567, + 58.759357, + 68.21262, + 76.507256, + 83.19111, + 88.2904, + 92.03026, + 94.689606, + 96.532425, + 97.780754, + 98.60885, + 99.14723, + 99.49028, + 99.70432, + 99.83485, + 99.9124, + 99.957054, + 99.98176, + 99.994675, + 100 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 100, + 95, + 90, + 85, + 80, + 75, + 70, + 65, + 60, + 55, + 16, + 15.25, + 14.5, + 13.75, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13, + 13.75, + 14.5, + 16.449999, + 18.400002, + 20.35, + 22.300001, + 24.25, + 26.2, + 28.15, + 30.1, + 32.05, + 90, + 95, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100, + 100 + ] + }, + { + "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": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + 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, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/MagneticDetach/placedAfter_attach_snapsToOrigin.json b/mechanics/tests/goldens/MagneticDetach/placedAfter_attach_snapsToOrigin.json new file mode 100644 index 0000000..7d668f6 --- /dev/null +++ b/mechanics/tests/goldens/MagneticDetach/placedAfter_attach_snapsToOrigin.json @@ -0,0 +1,392 @@ +{ + "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, + 416, + 432, + 448, + 464, + 480, + 496, + 512, + 528, + 544 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 100, + 95, + 90, + 85, + 80, + 75, + 70, + 65, + 60, + 55, + 50, + 45, + 40, + 35, + 30, + 25, + 20, + 15, + 10, + 5, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 100, + 95, + 90, + 85, + 80, + 75, + 70, + 65, + 60, + 55, + 50, + 43.38443, + 36.351646, + 29.990938, + 24.672552, + 20.412388, + 17.074236, + 14.475905, + 12.440355, + 6.552413, + 0.9461464, + 0.54626375, + 0.29212147, + 0.13740596, + 0.048214816, + 0.0006277391, + -0.021660766, + -0.02938723, + -0.029362231, + -0.02572238, + -0.020845085, + -0.015992891, + -0.01175198, + -0.008320414, + 0 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 100, + 95, + 90, + 85, + 80, + 75, + 70, + 65, + 60, + 55, + 16, + 15.25, + 14.5, + 13.75, + 13, + 12.25, + 11.5, + 10.75, + 10, + 5, + 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": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "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, + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/MagneticDetach/placedAfter_beforeAttach_suppressesDirectionReverse.json b/mechanics/tests/goldens/MagneticDetach/placedAfter_beforeAttach_suppressesDirectionReverse.json new file mode 100644 index 0000000..f65c772 --- /dev/null +++ b/mechanics/tests/goldens/MagneticDetach/placedAfter_beforeAttach_suppressesDirectionReverse.json @@ -0,0 +1,162 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 100, + 90.2, + 80.399994, + 70.59999, + 60.79999, + 51, + 60.8, + 70.6, + 80.4, + 90.200005, + 100, + 100 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 100, + 90.2, + 80.399994, + 70.59999, + 60.79999, + 51, + 60.8, + 70.6, + 80.4, + 90.200005, + 100, + 100 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 100, + 90.2, + 80.399994, + 70.59999, + 60.79999, + 51, + 60.8, + 70.6, + 80.4, + 90.200005, + 100, + 100 + ] + }, + { + "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": 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, + true, + true, + true, + true, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/MagneticDetach/placedAfter_beforeDetach_suppressesDirectionReverse.json b/mechanics/tests/goldens/MagneticDetach/placedAfter_beforeDetach_suppressesDirectionReverse.json new file mode 100644 index 0000000..fcc6339 --- /dev/null +++ b/mechanics/tests/goldens/MagneticDetach/placedAfter_beforeDetach_suppressesDirectionReverse.json @@ -0,0 +1,162 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 14.2, + 28.4, + 42.6, + 56.8, + 71, + 56.8, + 42.6, + 28.399998, + 14.199998, + 0, + 0 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 11.26, + 15.52, + 19.779999, + 24.04, + 28.300001, + 24.04, + 19.779999, + 15.5199995, + 11.26, + 7, + 7 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 11.26, + 15.52, + 19.779999, + 24.04, + 28.300001, + 24.04, + 19.779999, + 15.5199995, + 11.26, + 7, + 7 + ] + }, + { + "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": 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, + true, + true, + true, + true, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/MagneticDetach/placedAfter_detach_animatesDetach.json b/mechanics/tests/goldens/MagneticDetach/placedAfter_detach_animatesDetach.json new file mode 100644 index 0000000..af8198e --- /dev/null +++ b/mechanics/tests/goldens/MagneticDetach/placedAfter_detach_animatesDetach.json @@ -0,0 +1,432 @@ +{ + "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, + 416, + 432, + 448, + 464, + 480, + 496, + 512, + 528, + 544, + 560, + 576, + 592, + 608 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 5, + 10, + 15, + 20, + 25, + 30, + 35, + 40, + 45, + 50, + 55, + 60, + 65, + 70, + 75, + 80, + 85, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90 + ] + }, + { + "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", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 5, + 10, + 11.5, + 13, + 14.5, + 16, + 17.5, + 19, + 20.5, + 22, + 23.5, + 25, + 26.5, + 28, + 29.5, + 31, + 32.5, + 34, + 39.29379, + 48.383503, + 57.851955, + 66.20173, + 72.95019, + 78.10937, + 81.899025, + 84.597176, + 86.4689, + 87.738045, + 88.58072, + 89.129074, + 89.4788, + 89.69721, + 89.83055, + 89.909874, + 89.95562, + 89.98097, + 89.99428, + 90 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 5, + 10, + 11.5, + 13, + 14.5, + 16, + 17.5, + 19, + 20.5, + 22, + 23.5, + 25, + 26.5, + 28, + 29.5, + 31, + 32.5, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90, + 90 + ] + }, + { + "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 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/MagneticDetach/placedAfter_placedWithDifferentBaseMapping.json b/mechanics/tests/goldens/MagneticDetach/placedAfter_placedWithDifferentBaseMapping.json new file mode 100644 index 0000000..a52fa05 --- /dev/null +++ b/mechanics/tests/goldens/MagneticDetach/placedAfter_placedWithDifferentBaseMapping.json @@ -0,0 +1,392 @@ +{ + "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, + 416, + 432, + 448, + 464, + 480, + 496, + 512, + 528, + 544 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + -10, + 6, + 22, + 38, + 54, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70, + 70 + ] + }, + { + "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", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 100, + 52, + 3.9999924, + -44.000008, + -92.000015, + -140, + -214.33502, + -311.3978, + -404.9687, + -484.4225, + -547.1691, + -594.3692, + -628.61395, + -652.7499, + -669.3473, + -680.51227, + -687.87, + -692.62244, + -695.6304, + -697.49365, + -698.6208, + -699.2841, + -699.66156, + -699.867, + -699.9719, + -700.02014, + -700.03784, + -700.04016, + -700.03577, + -700.02905, + -700.0223, + -700.0164, + -700.0117, + -700.0081, + -700 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 100, + 52, + 3.9999924, + -44.000008, + -92.000015, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700, + -700 + ] + }, + { + "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": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + } + ] + }, + { + "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, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/MagneticDetach/placedBefore_afterAttach_detachesAgain.json b/mechanics/tests/goldens/MagneticDetach/placedBefore_afterAttach_detachesAgain.json new file mode 100644 index 0000000..846fb16 --- /dev/null +++ b/mechanics/tests/goldens/MagneticDetach/placedBefore_afterAttach_detachesAgain.json @@ -0,0 +1,662 @@ +{ + "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, + 416, + 432, + 448, + 464, + 480, + 496, + 512, + 528, + 544, + 560, + 576, + 592, + 608, + 624, + 640, + 656, + 672, + 688, + 704, + 720, + 736, + 752, + 768, + 784, + 800, + 816, + 832, + 848, + 864, + 880, + 896, + 912, + 928, + 944, + 960, + 976 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + -100, + -95, + -90, + -85, + -80, + -75, + -70, + -65, + -60, + -55, + -50, + -45, + -40, + -35, + -30, + -30, + -30, + -30, + -30, + -30, + -30, + -30, + -30, + -30, + -30, + -30, + -30, + -30, + -30, + -30, + -35, + -40, + -45, + -50, + -55, + -60, + -65, + -70, + -75, + -80, + -85, + -90, + -95, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Min", + "Min", + "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", + "Max", + "Max", + "Max", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + -100, + -95, + -90, + -85, + -80, + -75, + -70, + -65, + -60, + -55, + -50, + -43.38443, + -36.351646, + -29.990938, + -24.672552, + -21.162388, + -18.574236, + -16.725906, + -15.440355, + -14.566638, + -13.985239, + -13.6060915, + -13.363756, + -13.212058, + -13.11921, + -13.063812, + -13.031747, + -13.013887, + -13.004453, + -13, + -13.75, + -14.5, + -16.45, + -18.4, + -20.35, + -22.3, + -24.25, + -26.2, + -28.15, + -30.1, + -32.05, + -34, + -44.585567, + -58.759357, + -68.21262, + -76.507256, + -83.19111, + -88.2904, + -92.03026, + -94.689606, + -96.532425, + -97.780754, + -98.60885, + -99.14723, + -99.49028, + -99.70432, + -99.83485, + -99.9124, + -99.957054, + -99.98176, + -99.994675, + -100 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + -100, + -95, + -90, + -85, + -80, + -75, + -70, + -65, + -60, + -55, + -16, + -15.25, + -14.5, + -13.75, + -13, + -13, + -13, + -13, + -13, + -13, + -13, + -13, + -13, + -13, + -13, + -13, + -13, + -13, + -13, + -13, + -13.75, + -14.5, + -16.45, + -18.4, + -20.35, + -22.3, + -24.25, + -26.2, + -28.15, + -30.1, + -32.05, + -90, + -95, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -100, + -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 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + 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, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/MagneticDetach/placedBefore_attach_snapsToOrigin.json b/mechanics/tests/goldens/MagneticDetach/placedBefore_attach_snapsToOrigin.json new file mode 100644 index 0000000..8e6d26a --- /dev/null +++ b/mechanics/tests/goldens/MagneticDetach/placedBefore_attach_snapsToOrigin.json @@ -0,0 +1,392 @@ +{ + "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, + 416, + 432, + 448, + 464, + 480, + 496, + 512, + 528, + 544 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + -100, + -95, + -90, + -85, + -80, + -75, + -70, + -65, + -60, + -55, + -50, + -45, + -40, + -35, + -30, + -25, + -20, + -15, + -10, + -5, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Min", + "Min", + "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", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + -100, + -95, + -90, + -85, + -80, + -75, + -70, + -65, + -60, + -55, + -50, + -43.38443, + -36.351646, + -29.990938, + -24.672552, + -20.412388, + -17.074236, + -14.475905, + -12.440355, + -6.552413, + -0.9461464, + -0.54626375, + -0.29212147, + -0.13740596, + -0.048214816, + -0.0006277391, + 0.021660766, + 0.02938723, + 0.029362231, + 0.02572238, + 0.020845085, + 0.015992891, + 0.01175198, + 0.008320414, + 0 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + -100, + -95, + -90, + -85, + -80, + -75, + -70, + -65, + -60, + -55, + -16, + -15.25, + -14.5, + -13.75, + -13, + -12.25, + -11.5, + -10.75, + -10, + -5, + 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": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "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, + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/MagneticDetach/placedBefore_beforeAttach_suppressesDirectionReverse.json b/mechanics/tests/goldens/MagneticDetach/placedBefore_beforeAttach_suppressesDirectionReverse.json new file mode 100644 index 0000000..80f6813 --- /dev/null +++ b/mechanics/tests/goldens/MagneticDetach/placedBefore_beforeAttach_suppressesDirectionReverse.json @@ -0,0 +1,162 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + -100, + -90.2, + -80.399994, + -70.59999, + -60.79999, + -51, + -60.8, + -70.6, + -80.4, + -90.200005, + -100, + -100 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Min", + "Max", + "Max", + "Max", + "Max", + "Max", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + -100, + -90.2, + -80.399994, + -70.59999, + -60.79999, + -51, + -60.8, + -70.6, + -80.4, + -90.200005, + -100, + -100 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + -100, + -90.2, + -80.399994, + -70.59999, + -60.79999, + -51, + -60.8, + -70.6, + -80.4, + -90.200005, + -100, + -100 + ] + }, + { + "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": 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, + true, + true, + true, + true, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/MagneticDetach/placedBefore_beforeDetach_suppressesDirectionReverse.json b/mechanics/tests/goldens/MagneticDetach/placedBefore_beforeDetach_suppressesDirectionReverse.json new file mode 100644 index 0000000..0a08476 --- /dev/null +++ b/mechanics/tests/goldens/MagneticDetach/placedBefore_beforeDetach_suppressesDirectionReverse.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": [ + 0, + -14.2, + -28.4, + -42.6, + -56.8, + -71, + -56.8, + -42.6, + -28.399998, + -14.199998, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Min", + "Min", + "Min", + "Min", + "Min", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + -13.995375, + -17.821867, + -21.595097, + -25.403616, + -29.284393, + -24.725756, + -20.24163, + -15.81998, + -11.447464, + -7.1117826, + -7.0626464, + -7.031971, + -7.013703, + -7.003483, + -6.999998 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + -11.259998, + -15.519999, + -19.779999, + -24.039999, + -28.3, + -24.039999, + -19.779999, + -15.519998, + -11.259998, + -6.999998, + -6.999998, + -6.999998, + -6.999998, + -6.999998, + -6.999998 + ] + }, + { + "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 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/MagneticDetach/placedBefore_detach_animatesDetach.json b/mechanics/tests/goldens/MagneticDetach/placedBefore_detach_animatesDetach.json new file mode 100644 index 0000000..6f9df8e --- /dev/null +++ b/mechanics/tests/goldens/MagneticDetach/placedBefore_detach_animatesDetach.json @@ -0,0 +1,432 @@ +{ + "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, + 416, + 432, + 448, + 464, + 480, + 496, + 512, + 528, + 544, + 560, + 576, + 592, + 608 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + -5, + -10, + -15, + -20, + -25, + -30, + -35, + -40, + -45, + -50, + -55, + -60, + -65, + -70, + -75, + -80, + -85, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + -5, + -9.999998, + -11.499998, + -12.999998, + -14.499998, + -15.999998, + -17.499998, + -18.999998, + -20.5, + -22, + -23.499998, + -24.999998, + -26.499998, + -27.999998, + -29.499998, + -30.999998, + -32.5, + -34, + -39.29379, + -48.383503, + -57.851955, + -66.20174, + -72.950195, + -78.10937, + -81.89903, + -84.597176, + -86.4689, + -87.738045, + -88.58072, + -89.129074, + -89.4788, + -89.69721, + -89.83055, + -89.909874, + -89.95562, + -89.98097, + -89.99428, + -90 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + -5, + -9.999998, + -11.499998, + -12.999998, + -14.499998, + -15.999998, + -17.499998, + -18.999998, + -20.5, + -22, + -23.499998, + -24.999998, + -26.499998, + -27.999998, + -29.499998, + -30.999998, + -32.5, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90, + -90 + ] + }, + { + "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 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/MagneticDetach/placedBefore_placedWithDifferentBaseMapping.json b/mechanics/tests/goldens/MagneticDetach/placedBefore_placedWithDifferentBaseMapping.json new file mode 100644 index 0000000..3ae3c28 --- /dev/null +++ b/mechanics/tests/goldens/MagneticDetach/placedBefore_placedWithDifferentBaseMapping.json @@ -0,0 +1,392 @@ +{ + "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, + 416, + 432, + 448, + 464, + 480, + 496, + 512, + 528, + 544 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 10, + -6, + -22, + -38, + -54, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70, + -70 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + -100, + 52.204758, + 83.69019, + 113.146576, + 143.9473, + 177.50067, + 240.57437, + 329.32672, + 416.95862, + 492.2793, + 552.2154, + 597.5444, + 630.5683, + 653.9236, + 670.03204, + 680.8976, + 688.07654, + 692.7254, + 695.6756, + 697.5083, + 698.62054, + 699.2776, + 699.65326, + 699.85913, + 699.9653, + 700.0149, + 700.0339, + 700.0373, + 700.03375, + 700.02765, + 700.02136, + 700.0158, + 700.01135, + 700.0079, + 700 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + -100, + -52, + -3.9999924, + 44.000008, + 92.000015, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700, + 700 + ] + }, + { + "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": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + }, + { + "stiffness": 800, + "dampingRatio": 0.95 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/changeDirection_flipsBetweenDirectionalSegments.json b/mechanics/tests/goldens/changeDirection_flipsBetweenDirectionalSegments.json new file mode 100644 index 0000000..c3f2364 --- /dev/null +++ b/mechanics/tests/goldens/changeDirection_flipsBetweenDirectionalSegments.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": [ + 2, + 1.6, + 1.2, + 0.8000001, + 0.40000007, + 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" + ] + }, + { + "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 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 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 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + 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..167e4ea --- /dev/null +++ b/mechanics/tests/goldens/changingInput_addsAnimationToMapping_becomesStable.json @@ -0,0 +1,72 @@ +{ + "frame_ids": [ + 0, + 16, + 32 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 0.5, + 1.1 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 0, + 0.05119291 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 0.55 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + false + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/criticallyDamped_matchesGolden.json b/mechanics/tests/goldens/criticallyDamped_matchesGolden.json new file mode 100644 index 0000000..5dbf8b0 --- /dev/null +++ b/mechanics/tests/goldens/criticallyDamped_matchesGolden.json @@ -0,0 +1,814 @@ +{ + "frame_ids": [ + 0, + 10, + 20, + 30, + 40, + 50, + 60, + 70, + 80, + 90, + 100, + 110, + 120, + 130, + 140, + 150, + 160, + 170, + 180, + 190, + 200, + 210, + 220, + 230, + 240, + 250, + 260, + 270, + 280, + 290, + 300, + 310, + 320, + 330, + 340, + 350, + 360, + 370, + 380, + 390, + 400, + 410, + 420, + 430, + 440, + 450, + 460, + 470, + 480, + 490, + 500, + 510, + 520, + 530, + 540, + 550, + 560, + 570, + 580, + 590, + 600, + 610, + 620, + 630, + 640, + 650, + 660, + 670, + 680, + 690, + 700, + 710, + 720, + 730, + 740, + 750, + 760, + 770, + 780, + 790, + 800, + 810, + 820, + 830, + 840, + 850, + 860, + 870, + 880, + 890, + 900, + 910, + 920, + 930, + 940, + 950, + 960, + 970 + ], + "features": [ + { + "name": "displacement", + "type": "float", + "data_points": [ + 10, + 9.953212, + 9.824769, + 9.630637, + 9.38448, + 9.0979595, + 8.780986, + 8.44195, + 8.087921, + 7.7248235, + 7.357589, + 6.9902925, + 6.6262727, + 6.2682314, + 5.9183273, + 5.578254, + 5.2493095, + 4.932455, + 4.628369, + 4.33749, + 4.0600586, + 3.7961493, + 3.545701, + 3.3085418, + 3.0844104, + 2.8729749, + 2.6738486, + 2.4866037, + 2.3107822, + 2.1459055, + 1.9914826, + 1.8470172, + 1.7120124, + 1.585976, + 1.4684237, + 1.3588821, + 1.256891, + 1.1620055, + 1.0737969, + 0.9918535, + 0.91578174, + 0.84520626, + 0.77976984, + 0.7191335, + 0.6629762, + 0.61099464, + 0.5629026, + 0.51843065, + 0.4773252, + 0.43934828, + 0.4042767, + 0.37190142, + 0.3420269, + 0.31447032, + 0.2890611, + 0.26564008, + 0.24405895, + 0.22417964, + 0.20587368, + 0.18902166, + 0.17351262, + 0.15924358, + 0.14611898, + 0.13405024, + 0.122955225, + 0.11275793, + 0.10338796, + 0.09478021, + 0.086874455, + 0.07961504, + 0.07295055, + 0.06683349, + 0.061220028, + 0.05606971, + 0.051345225, + 0.047012165, + 0.04303882, + 0.039395962, + 0.036056675, + 0.032996174, + 0.030191636, + 0.02762206, + 0.025268128, + 0.023112064, + 0.021137528, + 0.019329496, + 0.017674157, + 0.016158825, + 0.014771842, + 0.013502505, + 0.0123409815, + 0.011278247, + 0.01030602, + 0.009416698, + 0.008603305, + 0.007859444, + 0.007179248, + 0.006557336 + ] + }, + { + "name": "velocity", + "type": "float", + "data_points": [ + 0, + -9.048374, + -16.374615, + -22.224546, + -26.812801, + -30.326532, + -32.928696, + -34.760967, + -35.946312, + -36.591267, + -36.78794, + -36.615818, + -36.143303, + -35.42913, + -34.523575, + -33.469524, + -32.303444, + -31.0562, + -29.753801, + -28.41804, + -27.067059, + -25.71585, + -24.376696, + -23.059534, + -21.772308, + -20.52125, + -19.31113, + -18.145489, + -17.026817, + -15.956734, + -14.93612, + -13.965252, + -13.043904, + -12.171444, + -11.34691, + -10.569083, + -9.836539, + -9.147704, + -8.500893, + -7.894345, + -7.326255, + -6.794796, + -6.2981415, + -5.83448, + -5.402029, + -4.9990478, + -4.6238437, + -4.2747793, + -3.9502778, + -3.648825, + -3.3689728, + -3.10934, + -2.8686128, + -2.645544, + -2.438953, + -2.2477236, + -2.070803, + -1.9071996, + -1.7559812, + -1.616272, + -1.4872509, + -1.3681489, + -1.2582467, + -1.1568717, + -1.0633963, + -0.9772352, + -0.89784265, + -0.8247107, + -0.7573669, + -0.69537175, + -0.6383172, + -0.5858244, + -0.5375417, + -0.49314323, + -0.45232698, + -0.41481322, + -0.38034305, + -0.3486769, + -0.31959325, + -0.29288736, + -0.26837006, + -0.24586667, + -0.2252159, + -0.20626894, + -0.18888852, + -0.17294809, + -0.15833096, + -0.14492963, + -0.13264509, + -0.121386126, + -0.11106881, + -0.101615876, + -0.092956245, + -0.085024536, + -0.07776062, + -0.07110924, + -0.06501959, + -0.059444997 + ] + }, + { + "name": "stable", + "type": "boolean", + "data_points": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + { + "name": "parameters", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + }, + { + "stiffness": 100, + "dampingRatio": 1 + } + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/derivedValue_hasAnimationLifecycleOnItsOwn.json b/mechanics/tests/goldens/derivedValue_hasAnimationLifecycleOnItsOwn.json new file mode 100644 index 0000000..f33165d --- /dev/null +++ b/mechanics/tests/goldens/derivedValue_hasAnimationLifecycleOnItsOwn.json @@ -0,0 +1,534 @@ +{ + "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 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 0.1, + 0.2, + 0.3, + 0.4, + 0.5, + 0.6, + 0.70000005, + 0.8000001, + 0.9000001, + 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", + "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.6651724, + 0.7667498, + 0.8429822, + 0.89796525, + 0.93623626, + 0.9619781, + 0.9786911, + 0.98912483, + 0.9953385, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 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": 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 + } + ] + }, + { + "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, + true, + true, + true, + true, + true + ] + }, + { + "name": "derived-input", + "type": "float", + "data_points": [ + 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, + 1, + 1, + 1 + ] + }, + { + "name": "derived-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" + ] + }, + { + "name": "derived-output", + "type": "float", + "data_points": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0.9953138, + 0.89982635, + 0.74407, + 0.5794298, + 0.4309871, + 0.308447, + 0.21313861, + 0.14231732, + 0.09167635, + 0.056711916, + 0.033384826, + 0.018371828, + 0.009094476, + 0.0036405649, + 0 + ] + }, + { + "name": "derived-outputTarget", + "type": "float", + "data_points": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "name": "derived-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": 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": "derived-isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/derivedValue_reflectsInputChangeInSameFrame.json b/mechanics/tests/goldens/derivedValue_reflectsInputChangeInSameFrame.json new file mode 100644 index 0000000..28014eb --- /dev/null +++ b/mechanics/tests/goldens/derivedValue_reflectsInputChangeInSameFrame.json @@ -0,0 +1,458 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 0.1, + 0.2, + 0.3, + 0.4, + 0.5, + 0.6, + 0.70000005, + 0.8000001, + 0.9000001, + 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", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 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 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 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": 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, + true + ] + }, + { + "name": "derived-input", + "type": "float", + "data_points": [ + 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 + ] + }, + { + "name": "derived-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" + ] + }, + { + "name": "derived-output", + "type": "float", + "data_points": [ + 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 + ] + }, + { + "name": "derived-outputTarget", + "type": "float", + "data_points": [ + 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 + ] + }, + { + "name": "derived-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": 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": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + } + ] + }, + { + "name": "derived-isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + } + ] +} \ 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..def9a2b --- /dev/null +++ b/mechanics/tests/goldens/directionChange_maxToMin_appliesGuarantee_afterDirectionChange.json @@ -0,0 +1,172 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 2, + 1.5, + 1, + 0.5, + 0, + -0.5, + -1, + -1.5, + -2, + -2, + -2, + -2, + -2 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "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.04164828, + 0.008622912, + 0 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 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 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + 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..a4dea67 --- /dev/null +++ b/mechanics/tests/goldens/directionChange_maxToMin_changesSegmentWithDirectionChange.json @@ -0,0 +1,252 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320 + ], + "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 + ] + }, + { + "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" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 0.9303996, + 0.7829481, + 0.61738, + 0.46381494, + 0.3348276, + 0.23325022, + 0.15701781, + 0.10203475, + 0.06376373, + 0.038021944, + 0.021308914, + 0.010875157, + 0.004661471, + 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 + ] + }, + { + "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 + } + ] + }, + { + "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 + ] + } + ] +} \ 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..c338c01 --- /dev/null +++ b/mechanics/tests/goldens/directionChange_minToMax_changesSegmentWithDirectionChange.json @@ -0,0 +1,252 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320 + ], + "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 + ] + }, + { + "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" + ] + }, + { + "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 + ] + }, + { + "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 + ] + }, + { + "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 + } + ] + }, + { + "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 + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/doNothingBeforeThreshold.json b/mechanics/tests/goldens/doNothingBeforeThreshold.json new file mode 100644 index 0000000..15b6249 --- /dev/null +++ b/mechanics/tests/goldens/doNothingBeforeThreshold.json @@ -0,0 +1,92 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 3, + 6, + 10, + 10 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "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 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + 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..f5f7612 --- /dev/null +++ b/mechanics/tests/goldens/emptySpec_outputMatchesInput_withoutAnimation.json @@ -0,0 +1,102 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 20, + 40, + 60, + 80, + 100 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 20, + 40, + 60, + 80, + 100 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 20, + 40, + 60, + 80, + 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 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/hideAnimation.json b/mechanics/tests/goldens/hideAnimation.json new file mode 100644 index 0000000..1ed61bf --- /dev/null +++ b/mechanics/tests/goldens/hideAnimation.json @@ -0,0 +1,322 @@ +{ + "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, + 416, + 432 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 36, + 33, + 30, + 27, + 24, + 21, + 18, + 15, + 12, + 9, + 6, + 3, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 27, + 27, + 27, + 24, + 21, + 18, + 15, + 12, + 9, + 6.1834006, + 4.0823064, + 2.5971997, + 1.58311, + 0.9141716, + 0.4889884, + 0.2301146, + 0.08085263, + 0.001194974, + -0.03613234, + -0.049089864, + -0.049071845, + -0.042999808, + -0.034852397, + -0.026743067, + -0.019653551, + -0.013916051, + -0.009518558, + 0 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 27, + 27, + 27, + 24, + 21, + 18, + 15, + 12, + 9, + 0, + 0, + 0, + 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 + }, + { + "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, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/hideAnimationOnThreshold.json b/mechanics/tests/goldens/hideAnimationOnThreshold.json new file mode 100644 index 0000000..aa75de7 --- /dev/null +++ b/mechanics/tests/goldens/hideAnimationOnThreshold.json @@ -0,0 +1,322 @@ +{ + "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, + 416, + 432 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 36, + 33, + 30, + 27, + 24, + 21, + 18, + 15, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 27, + 27, + 27, + 24, + 21, + 18, + 15, + 12, + 8, + 5.40525, + 3.5262613, + 2.2135293, + 1.327303, + 0.74965316, + 0.38740715, + 0.17046088, + 0.04813948, + -0.014901603, + -0.042484254, + -0.05010864, + -0.04747553, + -0.04038017, + -0.03207883, + -0.02424037, + -0.017586533, + -0.012307796, + -0.0083231535, + 0 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 27, + 27, + 27, + 24, + 21, + 18, + 15, + 12, + 0, + 0, + 0, + 0, + 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 + }, + { + "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, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/overDamped_matchesGolden.json b/mechanics/tests/goldens/overDamped_matchesGolden.json new file mode 100644 index 0000000..2fcf21a --- /dev/null +++ b/mechanics/tests/goldens/overDamped_matchesGolden.json @@ -0,0 +1,2142 @@ +{ + "frame_ids": [ + 0, + 10, + 20, + 30, + 40, + 50, + 60, + 70, + 80, + 90, + 100, + 110, + 120, + 130, + 140, + 150, + 160, + 170, + 180, + 190, + 200, + 210, + 220, + 230, + 240, + 250, + 260, + 270, + 280, + 290, + 300, + 310, + 320, + 330, + 340, + 350, + 360, + 370, + 380, + 390, + 400, + 410, + 420, + 430, + 440, + 450, + 460, + 470, + 480, + 490, + 500, + 510, + 520, + 530, + 540, + 550, + 560, + 570, + 580, + 590, + 600, + 610, + 620, + 630, + 640, + 650, + 660, + 670, + 680, + 690, + 700, + 710, + 720, + 730, + 740, + 750, + 760, + 770, + 780, + 790, + 800, + 810, + 820, + 830, + 840, + 850, + 860, + 870, + 880, + 890, + 900, + 910, + 920, + 930, + 940, + 950, + 960, + 970, + 980, + 990, + 1000, + 1010, + 1020, + 1030, + 1040, + 1050, + 1060, + 1070, + 1080, + 1090, + 1100, + 1110, + 1120, + 1130, + 1140, + 1150, + 1160, + 1170, + 1180, + 1190, + 1200, + 1210, + 1220, + 1230, + 1240, + 1250, + 1260, + 1270, + 1280, + 1290, + 1300, + 1310, + 1320, + 1330, + 1340, + 1350, + 1360, + 1370, + 1380, + 1390, + 1400, + 1410, + 1420, + 1430, + 1440, + 1450, + 1460, + 1470, + 1480, + 1490, + 1500, + 1510, + 1520, + 1530, + 1540, + 1550, + 1560, + 1570, + 1580, + 1590, + 1600, + 1610, + 1620, + 1630, + 1640, + 1650, + 1660, + 1670, + 1680, + 1690, + 1700, + 1710, + 1720, + 1730, + 1740, + 1750, + 1760, + 1770, + 1780, + 1790, + 1800, + 1810, + 1820, + 1830, + 1840, + 1850, + 1860, + 1870, + 1880, + 1890, + 1900, + 1910, + 1920, + 1930, + 1940, + 1950, + 1960, + 1970, + 1980, + 1990, + 2000, + 2010, + 2020, + 2030, + 2040, + 2050, + 2060, + 2070, + 2080, + 2090, + 2100, + 2110, + 2120, + 2130, + 2140, + 2150, + 2160, + 2170, + 2180, + 2190, + 2200, + 2210, + 2220, + 2230, + 2240, + 2250, + 2260, + 2270, + 2280, + 2290, + 2300, + 2310, + 2320, + 2330, + 2340, + 2350, + 2360, + 2370, + 2380, + 2390, + 2400, + 2410, + 2420, + 2430, + 2440, + 2450, + 2460, + 2470, + 2480, + 2490, + 2500, + 2510, + 2520, + 2530, + 2540, + 2550, + 2560, + 2570, + 2580, + 2590, + 2600, + 2610, + 2620, + 2630 + ], + "features": [ + { + "name": "displacement", + "type": "float", + "data_points": [ + 10, + 9.956085, + 9.844659, + 9.688895, + 9.504694, + 9.302948, + 9.091103, + 8.874231, + 8.655778, + 8.438063, + 8.222635, + 8.010515, + 7.802359, + 7.598574, + 7.399398, + 7.204951, + 7.015275, + 6.83036, + 6.6501584, + 6.474601, + 6.3036017, + 6.1370664, + 5.9748945, + 5.8169837, + 5.663229, + 5.5135264, + 5.367773, + 5.2258673, + 5.0877094, + 4.9532013, + 4.8222475, + 4.6947546, + 4.5706315, + 4.4497895, + 4.332142, + 4.2176046, + 4.1060953, + 3.997534, + 3.891843, + 3.7889464, + 3.68877, + 3.5912423, + 3.496293, + 3.4038541, + 3.3138592, + 3.2262437, + 3.1409447, + 3.057901, + 2.9770527, + 2.8983421, + 2.8217125, + 2.747109, + 2.6744778, + 2.603767, + 2.5349257, + 2.4679046, + 2.4026554, + 2.3391314, + 2.2772868, + 2.2170773, + 2.1584597, + 2.1013918, + 2.0458329, + 1.9917428, + 1.939083, + 1.8878154, + 1.8379031, + 1.7893106, + 1.7420027, + 1.6959457, + 1.6511065, + 1.6074526, + 1.564953, + 1.523577, + 1.483295, + 1.444078, + 1.4058979, + 1.3687272, + 1.3325393, + 1.2973082, + 1.2630085, + 1.2296157, + 1.1971058, + 1.1654553, + 1.1346418, + 1.1046429, + 1.0754371, + 1.0470035, + 1.0193217, + 0.99237174, + 0.9661343, + 0.94059056, + 0.9157222, + 0.8915113, + 0.86794055, + 0.84499294, + 0.82265204, + 0.80090183, + 0.7797267, + 0.7591114, + 0.73904115, + 0.71950155, + 0.70047855, + 0.6819585, + 0.6639281, + 0.6463744, + 0.62928486, + 0.6126471, + 0.59644926, + 0.58067966, + 0.565327, + 0.55038023, + 0.53582865, + 0.5216618, + 0.50786954, + 0.49444193, + 0.48136932, + 0.46864232, + 0.45625183, + 0.44418892, + 0.43244496, + 0.4210115, + 0.40988034, + 0.39904347, + 0.38849312, + 0.3782217, + 0.36822185, + 0.35848638, + 0.34900832, + 0.33978084, + 0.33079734, + 0.32205135, + 0.31353658, + 0.30524695, + 0.29717648, + 0.2893194, + 0.28167003, + 0.27422294, + 0.26697272, + 0.2599142, + 0.25304228, + 0.24635206, + 0.23983873, + 0.2334976, + 0.22732413, + 0.22131388, + 0.21546254, + 0.2097659, + 0.20421988, + 0.19882049, + 0.19356385, + 0.1884462, + 0.18346384, + 0.17861322, + 0.17389084, + 0.16929333, + 0.16481736, + 0.16045974, + 0.15621732, + 0.15208708, + 0.14806603, + 0.1441513, + 0.14034006, + 0.1366296, + 0.13301723, + 0.12950037, + 0.1260765, + 0.12274315, + 0.11949793, + 0.116338514, + 0.11326262, + 0.11026806, + 0.10735267, + 0.10451435, + 0.10175108, + 0.09906087, + 0.09644179, + 0.093891956, + 0.091409534, + 0.088992745, + 0.08663985, + 0.08434917, + 0.082119055, + 0.0799479, + 0.077834144, + 0.07577628, + 0.07377282, + 0.07182233, + 0.06992341, + 0.068074696, + 0.06627486, + 0.06452261, + 0.06281669, + 0.06115587, + 0.059538964, + 0.057964806, + 0.056432266, + 0.054940246, + 0.053487673, + 0.052073505, + 0.050696727, + 0.04935635, + 0.04805141, + 0.046780974, + 0.045544125, + 0.044339977, + 0.043167666, + 0.042026352, + 0.040915214, + 0.039833453, + 0.03878029, + 0.037754975, + 0.036756765, + 0.03578495, + 0.034838825, + 0.033917718, + 0.033020962, + 0.032147918, + 0.031297956, + 0.030470464, + 0.029664852, + 0.028880538, + 0.028116962, + 0.027373575, + 0.026649842, + 0.025945244, + 0.025259275, + 0.024591442, + 0.023941265, + 0.023308279, + 0.022692028, + 0.02209207, + 0.021507975, + 0.020939322, + 0.020385705, + 0.019846724, + 0.019321995, + 0.018811138, + 0.018313788, + 0.017829588, + 0.01735819, + 0.016899254, + 0.016452452, + 0.016017463, + 0.015593976, + 0.015181685, + 0.014780294, + 0.014389516, + 0.01400907, + 0.013638682, + 0.013278087, + 0.012927026, + 0.012585246, + 0.012252503, + 0.011928557, + 0.011613177, + 0.011306135, + 0.011007211, + 0.010716191, + 0.010432864, + 0.010157028, + 0.009888485, + 0.009627042, + 0.009372512 + ] + }, + { + "name": "velocity", + "type": "float", + "data_points": [ + 0, + -8.228306, + -13.676142, + -17.215311, + -19.445917, + -20.780996, + -21.504791, + -21.812872, + -21.839752, + -21.677917, + -21.390915, + -21.022373, + -20.602211, + -20.150906, + -19.68244, + -19.206331, + -18.72902, + -18.254831, + -17.786642, + -17.32633, + -16.875088, + -16.433643, + -16.0024, + -15.581547, + -15.171124, + -14.771073, + -14.3812685, + -14.001543, + -13.6317005, + -13.271528, + -12.920805, + -12.579304, + -12.246796, + -11.923055, + -11.607857, + -11.300981, + -11.00221, + -10.711333, + -10.4281435, + -10.152438, + -9.884021, + -9.622699, + -9.368285, + -9.120597, + -8.879457, + -8.644693, + -8.416136, + -8.193621, + -7.976989, + -7.766084, + -7.5607557, + -7.360856, + -7.1662416, + -6.9767723, + -6.7923126, + -6.61273, + -6.4378953, + -6.267683, + -6.101971, + -5.9406404, + -5.783575, + -5.6306624, + -5.4817924, + -5.3368587, + -5.195757, + -5.058386, + -4.924647, + -4.7944436, + -4.6676826, + -4.544273, + -4.424126, + -4.307156, + -4.193279, + -4.0824122, + -3.9744768, + -3.8693953, + -3.7670918, + -3.667493, + -3.570528, + -3.4761264, + -3.3842208, + -3.294745, + -3.207635, + -3.122828, + -3.0402632, + -2.9598813, + -2.8816247, + -2.805437, + -2.7312639, + -2.6590517, + -2.5887487, + -2.5203047, + -2.45367, + -2.3887973, + -2.3256397, + -2.2641518, + -2.2042897, + -2.1460102, + -2.0892715, + -2.034033, + -1.980255, + -1.9278988, + -1.8769268, + -1.8273025, + -1.7789901, + -1.7319552, + -1.6861638, + -1.6415831, + -1.598181, + -1.5559264, + -1.5147891, + -1.4747394, + -1.4357486, + -1.3977886, + -1.3608323, + -1.3248532, + -1.2898252, + -1.2557234, + -1.2225231, + -1.1902007, + -1.1587328, + -1.1280969, + -1.098271, + -1.0692337, + -1.0409641, + -1.0134419, + -0.9866474, + -0.96056134, + -0.9351649, + -0.91044, + -0.8863688, + -0.862934, + -0.84011877, + -0.81790674, + -0.796282, + -0.775229, + -0.7547326, + -0.7347781, + -0.7153512, + -0.69643795, + -0.67802477, + -0.6600984, + -0.64264596, + -0.62565494, + -0.60911316, + -0.59300876, + -0.5773301, + -0.562066, + -0.5472055, + -0.53273785, + -0.51865274, + -0.50494003, + -0.49158987, + -0.47859266, + -0.4659391, + -0.45362008, + -0.44162676, + -0.42995054, + -0.41858304, + -0.40751606, + -0.39674172, + -0.38625222, + -0.37604007, + -0.3660979, + -0.35641858, + -0.34699517, + -0.33782095, + -0.32888928, + -0.32019374, + -0.3117281, + -0.3034863, + -0.29546237, + -0.28765061, + -0.2800454, + -0.27264124, + -0.26543283, + -0.258415, + -0.25158274, + -0.24493112, + -0.23845536, + -0.23215081, + -0.22601293, + -0.22003734, + -0.21421975, + -0.20855597, + -0.20304193, + -0.19767368, + -0.19244736, + -0.18735923, + -0.1824056, + -0.17758296, + -0.17288782, + -0.16831681, + -0.16386665, + -0.15953417, + -0.15531623, + -0.1512098, + -0.14721195, + -0.1433198, + -0.13953055, + -0.13584149, + -0.13224995, + -0.12875338, + -0.12534925, + -0.12203512, + -0.11880862, + -0.115667425, + -0.112609275, + -0.109631985, + -0.10673341, + -0.103911474, + -0.10116415, + -0.098489456, + -0.09588548, + -0.09335035, + -0.09088225, + -0.0884794, + -0.08614008, + -0.08386262, + -0.08164536, + -0.07948673, + -0.07738517, + -0.075339176, + -0.07334727, + -0.07140803, + -0.06952007, + -0.06768202, + -0.06589257, + -0.06415043, + -0.062454347, + -0.060803108, + -0.059195526, + -0.05763045, + -0.05610675, + -0.054623336, + -0.05317914, + -0.05177313, + -0.050404295, + -0.04907165, + -0.047774237, + -0.04651113, + -0.045281414, + -0.044084214, + -0.042918667, + -0.041783933, + -0.0406792, + -0.03960368, + -0.038556594, + -0.03753719, + -0.03654474, + -0.03557853, + -0.034637865, + -0.03372207, + -0.032830484, + -0.031962473, + -0.031117413, + -0.030294696, + -0.02949373, + -0.028713943, + -0.027954772, + -0.027215673, + -0.026496114, + -0.02579558, + -0.02511357 + ] + }, + { + "name": "stable", + "type": "boolean", + "data_points": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + { + "name": "parameters", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + }, + { + "stiffness": 100, + "dampingRatio": 2 + } + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/overdrag_maxDirection_neverExceedsMaxOverdrag.json b/mechanics/tests/goldens/overdrag_maxDirection_neverExceedsMaxOverdrag.json new file mode 100644 index 0000000..ef27af8 --- /dev/null +++ b/mechanics/tests/goldens/overdrag_maxDirection_neverExceedsMaxOverdrag.json @@ -0,0 +1,279 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 5, + 10, + 15, + 20, + 25, + 30, + 35, + 40, + 45, + 50, + 55, + 60, + 65, + 70, + 75, + 80, + 85, + 90, + 95, + 100 + ] + }, + { + "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" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 5, + 10, + 11.662819, + 13.302809, + 14.898373, + 16.430256, + 17.88237, + 19.242344, + 20.501678, + 21.655659, + 22.70298, + 23.645235, + 24.486334, + 25.231884, + 25.88864, + 26.464012, + 26.965673, + 27.401234, + 27.77803, + 28.102966 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 5, + 10, + 11.662819, + 13.302809, + 14.898373, + 16.430256, + 17.88237, + 19.242344, + 20.501678, + 21.655659, + 22.70298, + 23.645235, + 24.486334, + 25.231884, + 25.88864, + 26.464012, + 26.965673, + 27.401234, + 27.77803, + 28.102966 + ] + }, + { + "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 + }, + { + "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, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "name": "overdragLimit", + "type": "float", + "data_points": [ + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10 + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/overdrag_minDirection_neverExceedsMaxOverdrag.json b/mechanics/tests/goldens/overdrag_minDirection_neverExceedsMaxOverdrag.json new file mode 100644 index 0000000..6039a70 --- /dev/null +++ b/mechanics/tests/goldens/overdrag_minDirection_neverExceedsMaxOverdrag.json @@ -0,0 +1,279 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304, + 320 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + -5, + -10, + -15, + -20, + -25, + -30, + -35, + -40, + -45, + -50, + -55, + -60, + -65, + -70, + -75, + -80, + -85, + -90, + -95, + -100 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min", + "Min" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + -5, + -10, + -11.662819, + -13.302809, + -14.898373, + -16.430256, + -17.88237, + -19.242344, + -20.501678, + -21.655659, + -22.70298, + -23.645235, + -24.486334, + -25.231884, + -25.88864, + -26.464012, + -26.965673, + -27.401234, + -27.77803, + -28.102966 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + -5, + -10, + -11.662819, + -13.302809, + -14.898373, + -16.430256, + -17.88237, + -19.242344, + -20.501678, + -21.655659, + -22.70298, + -23.645235, + -24.486334, + -25.231884, + -25.88864, + -26.464012, + -26.965673, + -27.401234, + -27.77803, + -28.102966 + ] + }, + { + "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 + }, + { + "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, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "name": "overdragLimit", + "type": "float", + "data_points": [ + null, + null, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10 + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/overdrag_nonStandardBaseFunction.json b/mechanics/tests/goldens/overdrag_nonStandardBaseFunction.json new file mode 100644 index 0000000..f3823d9 --- /dev/null +++ b/mechanics/tests/goldens/overdrag_nonStandardBaseFunction.json @@ -0,0 +1,268 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192, + 208, + 224, + 240, + 256, + 272, + 288, + 304 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 5, + 10, + 15, + 20, + 25, + 30, + 35, + 40, + 45, + 50, + 55, + 60, + 65, + 70, + 75, + 80, + 85, + 90, + 95, + 100 + ] + }, + { + "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" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + -5, + -10, + -11.662819, + -13.302809, + -14.898373, + -16.430256, + -17.88237, + -19.242344, + -20.501678, + -21.655659, + -22.70298, + -23.645235, + -24.486334, + -25.231884, + -25.88864, + -26.464012, + -26.965673, + -27.401234, + -27.77803, + -28.102966 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + -5, + -10, + -11.662819, + -13.302809, + -14.898373, + -16.430256, + -17.88237, + -19.242344, + -20.501678, + -21.655659, + -22.70298, + -23.645235, + -24.486334, + -25.231884, + -25.88864, + -26.464012, + -26.965673, + -27.401234, + -27.77803, + -28.102966 + ] + }, + { + "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 + }, + { + "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, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + { + "name": "overdragLimit", + "type": "float", + "data_points": [ + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10, + -10 + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/revealAnimation.json b/mechanics/tests/goldens/revealAnimation.json new file mode 100644 index 0000000..f18dacc --- /dev/null +++ b/mechanics/tests/goldens/revealAnimation.json @@ -0,0 +1,292 @@ +{ + "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 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 3, + 6, + 9, + 12, + 15, + 18, + 21, + 24, + 27, + 30, + 33, + 36, + 36, + 36, + 36, + 36, + 36, + 36, + 36, + 36, + 36, + 36, + 36, + 36 + ] + }, + { + "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" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0, + 1.0731893, + 4.910625, + 9.1775, + 13.488271, + 17.657562, + 21.616333, + 25.358374, + 25.907566, + 26.298885, + 26.568165, + 26.74721, + 26.862015, + 26.93265, + 26.97394, + 26.996431, + 27.00737, + 27.011566, + 27.012094, + 27.010847, + 27.008924, + 27 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0, + 9, + 12, + 15, + 18, + 21, + 24, + 27, + 27, + 27, + 27, + 27, + 27, + 27, + 27, + 27, + 27, + 27, + 27, + 27, + 27, + 27 + ] + }, + { + "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 + }, + { + "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, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/revealAnimation_afterFixedValue.json b/mechanics/tests/goldens/revealAnimation_afterFixedValue.json new file mode 100644 index 0000000..f18dacc --- /dev/null +++ b/mechanics/tests/goldens/revealAnimation_afterFixedValue.json @@ -0,0 +1,292 @@ +{ + "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 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 3, + 6, + 9, + 12, + 15, + 18, + 21, + 24, + 27, + 30, + 33, + 36, + 36, + 36, + 36, + 36, + 36, + 36, + 36, + 36, + 36, + 36, + 36, + 36 + ] + }, + { + "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" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0, + 1.0731893, + 4.910625, + 9.1775, + 13.488271, + 17.657562, + 21.616333, + 25.358374, + 25.907566, + 26.298885, + 26.568165, + 26.74721, + 26.862015, + 26.93265, + 26.97394, + 26.996431, + 27.00737, + 27.011566, + 27.012094, + 27.010847, + 27.008924, + 27 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0, + 9, + 12, + 15, + 18, + 21, + 24, + 27, + 27, + 27, + 27, + 27, + 27, + 27, + 27, + 27, + 27, + 27, + 27, + 27, + 27, + 27 + ] + }, + { + "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 + }, + { + "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, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/segmentChange_animationAtRest_doesNotAffectVelocity.json b/mechanics/tests/goldens/segmentChange_animationAtRest_doesNotAffectVelocity.json new file mode 100644 index 0000000..84c7d82 --- /dev/null +++ b/mechanics/tests/goldens/segmentChange_animationAtRest_doesNotAffectVelocity.json @@ -0,0 +1,312 @@ +{ + "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, + 416 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.5, + 1.8, + 2.1, + 2.3999999, + 2.6999998, + 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", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 0.18297386, + 2.2765617, + 5.4437504, + 8.720676, + 11.643903, + 14.040831, + 15.895933, + 17.268913, + 18.247213, + 18.920414, + 19.368025, + 20, + 20, + 20, + 20, + 20, + 20, + 19.303997, + 17.829481, + 16.173801, + 14.638149, + 13.348276, + 12.332502, + 11.570178, + 11.020348, + 10 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10, + 10 + ] + }, + { + "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 + }, + { + "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, + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/segmentChange_appliesOutputVelocity_atSpringStart.json b/mechanics/tests/goldens/segmentChange_appliesOutputVelocity_atSpringStart.json new file mode 100644 index 0000000..964d834 --- /dev/null +++ b/mechanics/tests/goldens/segmentChange_appliesOutputVelocity_atSpringStart.json @@ -0,0 +1,272 @@ +{ + "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 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 3, + 6, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11, + 11 + ] + }, + { + "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" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 3, + 6, + 10.58992, + 13.213701, + 15.27689, + 16.823486, + 17.93786, + 18.712797, + 19.23355, + 19.571314, + 19.781935, + 19.907185, + 19.977114, + 20.01258, + 20.02758, + 20.031174, + 20.028997, + 20.024399, + 20.019238, + 20.014452, + 20.010433, + 20 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 3, + 6, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20, + 20 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "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 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/segmentChange_appliesOutputVelocity_springVelocityIsNotAppliedTwice.json b/mechanics/tests/goldens/segmentChange_appliesOutputVelocity_springVelocityIsNotAppliedTwice.json new file mode 100644 index 0000000..fce12cb --- /dev/null +++ b/mechanics/tests/goldens/segmentChange_appliesOutputVelocity_springVelocityIsNotAppliedTwice.json @@ -0,0 +1,312 @@ +{ + "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, + 416 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 3, + 6, + 9, + 12, + 15, + 18, + 21, + 21, + 21, + 21, + 21, + 21, + 21, + 21, + 21, + 21, + 21, + 21, + 21, + 21, + 21, + 21, + 21, + 21, + 21, + 21 + ] + }, + { + "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", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 3, + 6, + 9, + 14.22027, + 20.881996, + 27.33559, + 32.265293, + 34.588463, + 36.34147, + 37.611744, + 38.499744, + 39.099594, + 39.49083, + 39.73635, + 39.883526, + 39.96661, + 40.009514, + 40.028366, + 40.033657, + 40.03197, + 40.027233, + 40.021656, + 40.016376, + 40.01189, + 40.008324, + 40 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 3, + 6, + 9, + 32, + 35, + 38, + 40, + 40, + 40, + 40, + 40, + 40, + 40, + 40, + 40, + 40, + 40, + 40, + 40, + 40, + 40, + 40, + 40, + 40, + 40, + 40 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "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 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + }, + { + "stiffness": 700, + "dampingRatio": 0.9 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/segmentChange_appliesOutputVelocity_velocityAddedOnDiscontinuousSegment.json b/mechanics/tests/goldens/segmentChange_appliesOutputVelocity_velocityAddedOnDiscontinuousSegment.json new file mode 100644 index 0000000..e22cde1 --- /dev/null +++ b/mechanics/tests/goldens/segmentChange_appliesOutputVelocity_velocityAddedOnDiscontinuousSegment.json @@ -0,0 +1,372 @@ +{ + "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, + 416, + 432, + 448, + 464, + 480, + 496, + 512 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 3, + 6, + 9, + 12, + 15, + 18, + 21, + 24, + 27, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30 + ] + }, + { + "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", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 3, + 6, + 9, + 21.715681, + 38.426617, + 54.4196, + 69.30995, + 76.55414, + 77.88977, + 76.3026, + 73.54735, + 70.58736, + 67.89756, + 65.666245, + 63.924633, + 62.626797, + 61.696457, + 61.052605, + 60.62202, + 60.344193, + 60.171986, + 60.07036, + 60.01423, + 59.986275, + 59.974922, + 59.97272, + 59.975067, + 59.97924, + 59.983753, + 59.98787, + 59.991287, + 60 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 3, + 6, + 9, + 25, + 40, + 55, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60, + 60 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "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 + }, + { + "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, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/segmentChange_appliesOutputVelocity_velocityNotAddedOnContinuousSegment.json b/mechanics/tests/goldens/segmentChange_appliesOutputVelocity_velocityNotAddedOnContinuousSegment.json new file mode 100644 index 0000000..6f04279 --- /dev/null +++ b/mechanics/tests/goldens/segmentChange_appliesOutputVelocity_velocityNotAddedOnContinuousSegment.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, + 3, + 6, + 9, + 12, + 15, + 18, + 21, + 24, + 27, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30, + 30 + ] + }, + { + "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" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 3, + 6, + 9, + 21.715681, + 38.426617, + 54.4196, + 64.95479, + 65.21017, + 65.3034, + 65.30942, + 65.274, + 65.22361, + 65.172455, + 65.12727, + 65.09046, + 65.062096, + 65.04118, + 65.02634, + 65.01615, + 65.0094, + 65 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 3, + 6, + 9, + 25, + 40, + 55, + 65, + 65, + 65, + 65, + 65, + 65, + 65, + 65, + 65, + 65, + 65, + 65, + 65, + 65, + 65 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "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 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/segmentChange_guaranteeGestureDragDelta_springCompletesWithinDistance.json b/mechanics/tests/goldens/segmentChange_guaranteeGestureDragDelta_springCompletesWithinDistance.json new file mode 100644 index 0000000..755ff78 --- /dev/null +++ b/mechanics/tests/goldens/segmentChange_guaranteeGestureDragDelta_springCompletesWithinDistance.json @@ -0,0 +1,122 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 0.5, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "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 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 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 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + false, + false, + false, + false, + false, + 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..003f74b --- /dev/null +++ b/mechanics/tests/goldens/segmentChange_guaranteeInputDelta_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.5, + 2, + 2.5, + 3, + 3.5, + 4 + ] + }, + { + "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_guaranteeNone_springAnimatesIndependentOfInput.json b/mechanics/tests/goldens/segmentChange_guaranteeNone_springAnimatesIndependentOfInput.json new file mode 100644 index 0000000..58706ef --- /dev/null +++ b/mechanics/tests/goldens/segmentChange_guaranteeNone_springAnimatesIndependentOfInput.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.5, + 2, + 2.5, + 3, + 3.5, + 4, + 4.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" + ] + }, + { + "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 + ] + }, + { + "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, + false, + 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..f93fc6f --- /dev/null +++ b/mechanics/tests/goldens/segmentChange_inMaxDirection_animatedWhenReachingBreakpoint.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, + 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 + ] + }, + { + "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, + false, + 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..2355188 --- /dev/null +++ b/mechanics/tests/goldens/segmentChange_inMaxDirection_springAnimationStartedRetroactively.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": [ + 0, + 0.5, + 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" + ] + }, + { + "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 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 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 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/segmentChange_inMaxDirection_zeroDelta.json b/mechanics/tests/goldens/segmentChange_inMaxDirection_zeroDelta.json new file mode 100644 index 0000000..f68e961 --- /dev/null +++ b/mechanics/tests/goldens/segmentChange_inMaxDirection_zeroDelta.json @@ -0,0 +1,82 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 0.5, + 1, + 1 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 0, + 0 + ] + }, + { + "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 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + 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..f00535c --- /dev/null +++ b/mechanics/tests/goldens/segmentChange_inMinDirection_animatedWhenReachingBreakpoint.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, + 1, + 0.9303996, + 0.7829481, + 0.61738, + 0.46381494, + 0.3348276, + 0.23325022, + 0.15701781, + 0.10203475, + 0.06376373, + 0.038021944, + 0.021308914, + 0.010875157, + 0.004661471, + 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, + false, + 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..5efbd83 --- /dev/null +++ b/mechanics/tests/goldens/segmentChange_inMinDirection_springAnimationStartedRetroactively.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.5, + 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" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 1, + 1, + 0.9802651, + 0.8618002, + 0.70001805, + 0.5380087, + 0.3959122, + 0.28066823, + 0.19219775, + 0.1271556, + 0.08108548, + 0.04956314, + 0.028725967, + 0.0154508, + 0.007345509, + 0 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 1, + 1, + 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 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/semantics_flipsBetweenDirectionalSegments.json b/mechanics/tests/goldens/semantics_flipsBetweenDirectionalSegments.json new file mode 100644 index 0000000..2b2107e --- /dev/null +++ b/mechanics/tests/goldens/semantics_flipsBetweenDirectionalSegments.json @@ -0,0 +1,323 @@ +{ + "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 + ], + "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 + ] + }, + { + "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" + ] + }, + { + "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 + ] + }, + { + "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 + ] + }, + { + "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 + } + ] + }, + { + "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 + ] + }, + { + "name": "Foo", + "type": "string", + "data_points": [ + "zero", + "zero", + "zero", + "zero", + "zero", + "one", + "one", + "one", + "one", + "one", + "two", + "two", + "two", + "two", + "two", + "two", + "two", + "two", + "two", + "two", + "two", + "two", + "two", + "two", + "two" + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/snapSpring_updatesImmediately_matchesGolden.json b/mechanics/tests/goldens/snapSpring_updatesImmediately_matchesGolden.json new file mode 100644 index 0000000..a772271 --- /dev/null +++ b/mechanics/tests/goldens/snapSpring_updatesImmediately_matchesGolden.json @@ -0,0 +1,54 @@ +{ + "frame_ids": [ + 0, + 10, + 20 + ], + "features": [ + { + "name": "displacement", + "type": "float", + "data_points": [ + 10, + 0, + 0 + ] + }, + { + "name": "velocity", + "type": "float", + "data_points": [ + -10, + 0, + 0 + ] + }, + { + "name": "stable", + "type": "boolean", + "data_points": [ + false, + true, + true + ] + }, + { + "name": "parameters", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + }, + { + "stiffness": 100000, + "dampingRatio": 1 + } + ] + } + ] +} \ 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..4b61281 --- /dev/null +++ b/mechanics/tests/goldens/specChange_shiftSegmentBackwards_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.7, + 1.9, + 1.7769231, + 1.356505, + 0.92442614, + 0.5804193, + 0.3396739, + 0.18536365, + 0.09334825, + 0.042139973, + 0.015731357, + 0.003390434, + 0 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 1.5, + 1.7, + 1.9, + 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, + false, + 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..faf1211 --- /dev/null +++ b/mechanics/tests/goldens/specChange_shiftSegmentForward_doesNotAnimateWithinSegment_animatesSegmentChange.json @@ -0,0 +1,162 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176 + ], + "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 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "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.84658206, + 0.579976, + 0.36567324, + 0.21482599, + 0.11771336, + 0.059579656, + 0.027098525, + 0.010269003, + 0 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 1.5, + 1.3, + 1.1, + 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 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/stiffeningSpring_matchesGolden.json b/mechanics/tests/goldens/stiffeningSpring_matchesGolden.json new file mode 100644 index 0000000..78ef84e --- /dev/null +++ b/mechanics/tests/goldens/stiffeningSpring_matchesGolden.json @@ -0,0 +1,182 @@ +{ + "frame_ids": [ + 0, + 10, + 20, + 30, + 40, + 50, + 60, + 70, + 80, + 90, + 100, + 110, + 120, + 130, + 140, + 150, + 160, + 170, + 180 + ], + "features": [ + { + "name": "displacement", + "type": "float", + "data_points": [ + 10, + 9.854129, + 9.603651, + 9.21957, + 8.671186, + 7.931126, + 6.9840484, + 5.839193, + 4.544642, + 3.1971405, + 1.936856, + 0.9162949, + 0.24406782, + -0.070853025, + -0.12870777, + -0.07792437, + -0.02628906, + -0.004520869, + -0.00013658773 + ] + }, + { + "name": "velocity", + "type": "float", + "data_points": [ + -10, + -19.059385, + -30.821104, + -45.60961, + -63.405827, + -83.52585, + -104.22404, + -122.36138, + -133.45284, + -132.56017, + -116.37609, + -86.074936, + -49.053444, + -16.799938, + 1.6967986, + 6.1317945, + 3.6371715, + 1.0193218, + 0.10724213 + ] + }, + { + "name": "stable", + "type": "boolean", + "data_points": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + { + "name": "parameters", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 141.25374, + "dampingRatio": 0.335 + }, + { + "stiffness": 199.52621, + "dampingRatio": 0.37 + }, + { + "stiffness": 281.83835, + "dampingRatio": 0.40500003 + }, + { + "stiffness": 398.10718, + "dampingRatio": 0.44 + }, + { + "stiffness": 562.3414, + "dampingRatio": 0.47500002 + }, + { + "stiffness": 794.3283, + "dampingRatio": 0.51 + }, + { + "stiffness": 1122.0183, + "dampingRatio": 0.545 + }, + { + "stiffness": 1584.8934, + "dampingRatio": 0.58000004 + }, + { + "stiffness": 2238.7207, + "dampingRatio": 0.615 + }, + { + "stiffness": 3162.2776, + "dampingRatio": 0.65 + }, + { + "stiffness": 4466.8364, + "dampingRatio": 0.685 + }, + { + "stiffness": 6309.574, + "dampingRatio": 0.72 + }, + { + "stiffness": 8912.508, + "dampingRatio": 0.755 + }, + { + "stiffness": 12589.254, + "dampingRatio": 0.78999996 + }, + { + "stiffness": 17782.793, + "dampingRatio": 0.825 + }, + { + "stiffness": 25118.865, + "dampingRatio": 0.86 + }, + { + "stiffness": 35481.344, + "dampingRatio": 0.89500004 + }, + { + "stiffness": 50118.715, + "dampingRatio": 0.93 + } + ] + } + ] +} \ 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..7420c91 --- /dev/null +++ b/mechanics/tests/goldens/traverseSegmentsInOneFrame_noGuarantee_combinesDiscontinuity.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": [ + 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 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "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 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 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 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + 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..6f35e23 --- /dev/null +++ b/mechanics/tests/goldens/traverseSegmentsInOneFrame_withDirectionChange_appliesGuarantees.json @@ -0,0 +1,142 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 2.5, + 0.4, + 0.3, + 0.20000002, + 0.10000002, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "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.291457, + 0.111323975, + 0.03634805, + 0.009979475, + 0 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 2, + 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 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + false, + false, + false, + false, + false, + false, + false, + false, + 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..ea0b8f6 --- /dev/null +++ b/mechanics/tests/goldens/traverseSegmentsInOneFrame_withGuarantee_appliesGuarantees.json @@ -0,0 +1,172 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112, + 128, + 144, + 160, + 176, + 192 + ], + "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 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "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 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 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 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + 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..7baaf1d --- /dev/null +++ b/mechanics/tests/goldens/traverseSegments_maxDirection_noGuarantee_addsDiscontinuityToOngoingAnimation.json @@ -0,0 +1,292 @@ +{ + "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 + ], + "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 + ] + }, + { + "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" + ] + }, + { + "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 + ] + }, + { + "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 + ] + }, + { + "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 + } + ] + }, + { + "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 + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/underDamped_matchesGolden.json b/mechanics/tests/goldens/underDamped_matchesGolden.json new file mode 100644 index 0000000..32413ec --- /dev/null +++ b/mechanics/tests/goldens/underDamped_matchesGolden.json @@ -0,0 +1,1870 @@ +{ + "frame_ids": [ + 0, + 10, + 20, + 30, + 40, + 50, + 60, + 70, + 80, + 90, + 100, + 110, + 120, + 130, + 140, + 150, + 160, + 170, + 180, + 190, + 200, + 210, + 220, + 230, + 240, + 250, + 260, + 270, + 280, + 290, + 300, + 310, + 320, + 330, + 340, + 350, + 360, + 370, + 380, + 390, + 400, + 410, + 420, + 430, + 440, + 450, + 460, + 470, + 480, + 490, + 500, + 510, + 520, + 530, + 540, + 550, + 560, + 570, + 580, + 590, + 600, + 610, + 620, + 630, + 640, + 650, + 660, + 670, + 680, + 690, + 700, + 710, + 720, + 730, + 740, + 750, + 760, + 770, + 780, + 790, + 800, + 810, + 820, + 830, + 840, + 850, + 860, + 870, + 880, + 890, + 900, + 910, + 920, + 930, + 940, + 950, + 960, + 970, + 980, + 990, + 1000, + 1010, + 1020, + 1030, + 1040, + 1050, + 1060, + 1070, + 1080, + 1090, + 1100, + 1110, + 1120, + 1130, + 1140, + 1150, + 1160, + 1170, + 1180, + 1190, + 1200, + 1210, + 1220, + 1230, + 1240, + 1250, + 1260, + 1270, + 1280, + 1290, + 1300, + 1310, + 1320, + 1330, + 1340, + 1350, + 1360, + 1370, + 1380, + 1390, + 1400, + 1410, + 1420, + 1430, + 1440, + 1450, + 1460, + 1470, + 1480, + 1490, + 1500, + 1510, + 1520, + 1530, + 1540, + 1550, + 1560, + 1570, + 1580, + 1590, + 1600, + 1610, + 1620, + 1630, + 1640, + 1650, + 1660, + 1670, + 1680, + 1690, + 1700, + 1710, + 1720, + 1730, + 1740, + 1750, + 1760, + 1770, + 1780, + 1790, + 1800, + 1810, + 1820, + 1830, + 1840, + 1850, + 1860, + 1870, + 1880, + 1890, + 1900, + 1910, + 1920, + 1930, + 1940, + 1950, + 1960, + 1970, + 1980, + 1990, + 2000, + 2010, + 2020, + 2030, + 2040, + 2050, + 2060, + 2070, + 2080, + 2090, + 2100, + 2110, + 2120, + 2130, + 2140, + 2150, + 2160, + 2170, + 2180, + 2190, + 2200, + 2210, + 2220, + 2230, + 2240, + 2250, + 2260, + 2270, + 2280, + 2290 + ], + "features": [ + { + "name": "displacement", + "type": "float", + "data_points": [ + 10, + 9.951026, + 9.8084, + 9.57896, + 9.269987, + 8.889109, + 8.444205, + 7.9433208, + 7.3945727, + 6.80607, + 6.185835, + 5.5417304, + 4.881393, + 4.2121716, + 3.5410736, + 2.8747165, + 2.2192867, + 1.5805038, + 0.9635933, + 0.3732641, + -0.18630688, + -0.7114842, + -1.1991777, + -1.6468408, + -2.0524633, + -2.4145596, + -2.7321532, + -3.0047555, + -3.2323432, + -3.4153304, + -3.5545402, + -3.6511714, + -3.706767, + -3.7231774, + -3.7025254, + -3.6471696, + -3.5596678, + -3.4427407, + -3.2992358, + -3.1320927, + -2.9443088, + -2.738907, + -2.5189056, + -2.2872882, + -2.0469773, + -1.8008097, + -1.5515139, + -1.3016896, + -1.0537905, + -0.81010836, + -0.57276094, + -0.34368098, + -0.12460864, + 0.08291435, + 0.27754804, + 0.45815554, + 0.6238022, + 0.7737528, + 0.90746725, + 1.0245943, + 1.1249641, + 1.2085791, + 1.2756041, + 1.3263553, + 1.3612883, + 1.3809854, + 1.3861428, + 1.3775574, + 1.3561126, + 1.3227652, + 1.2785319, + 1.2244756, + 1.1616926, + 1.0912999, + 1.0144233, + 0.9321859, + 0.84569746, + 0.7560443, + 0.6642802, + 0.57141787, + 0.47842193, + 0.386202, + 0.29560724, + 0.20742154, + 0.12235984, + 0.04106513, + -0.03589359, + -0.108022496, + -0.17490335, + -0.23619318, + -0.2916232, + -0.34099713, + -0.3841888, + -0.4211394, + -0.45185402, + -0.47639796, + -0.49489257, + -0.5075107, + -0.5144723, + -0.51603925, + -0.5125105, + -0.50421697, + -0.49151662, + -0.47478923, + -0.45443153, + -0.43085238, + -0.40446806, + -0.37569776, + -0.34495947, + -0.31266588, + -0.27922073, + -0.24501546, + -0.21042602, + -0.17581023, + -0.14150535, + -0.10782593, + -0.07506216, + -0.043478478, + -0.013312436, + 0.015225975, + 0.041954778, + 0.06672015, + 0.08939626, + 0.109884866, + 0.12811466, + 0.14404039, + 0.15764181, + 0.16892236, + 0.17790781, + 0.18464467, + 0.18919855, + 0.19165242, + 0.19210477, + 0.19066778, + 0.18746541, + 0.18263154, + 0.17630802, + 0.16864295, + 0.1597888, + 0.14990067, + 0.13913468, + 0.12764633, + 0.11558912, + 0.10311311, + 0.09036367, + 0.07748037, + 0.064595945, + 0.05183541, + 0.0393153, + 0.02714303, + 0.01541639, + 0.0042231516, + -0.0063591995, + -0.016263612, + -0.025433514, + -0.033822753, + -0.041395433, + -0.04812567, + -0.053997252, + -0.059003245, + -0.06314551, + -0.066434175, + -0.068887055, + -0.070529036, + -0.07139142, + -0.07151124, + -0.07093058, + -0.06969586, + -0.067857146, + -0.06546744, + -0.06258201, + -0.059257705, + -0.055552322, + -0.051523987, + -0.047230575, + -0.042729158, + -0.038075503, + -0.033323605, + -0.028525269, + -0.023729734, + -0.018983342, + -0.014329261, + -0.00980725, + -0.005453472, + -0.0013003512, + 0.0026235213, + 0.0062934426, + 0.009688612, + 0.012792103, + 0.015590806, + 0.018075326, + 0.02023987, + 0.022082077, + 0.023602854, + 0.024806172, + 0.025698848, + 0.026290316, + 0.026592381, + 0.026618967, + 0.026385859, + 0.025910439, + 0.025211431, + 0.024308635, + 0.023222672, + 0.02197474, + 0.020586377, + 0.019079221, + 0.01747481, + 0.015794363, + 0.014058607, + 0.012287595, + 0.010500557, + 0.008715754, + 0.0069503672, + 0.005220385, + 0.0035405224, + 0.0019241521, + 0.00038325193, + -0.0010716299, + -0.0024313936, + -0.0036883915, + -0.004836418, + -0.0058706864, + -0.0067877905, + -0.0075856596, + -0.0082635, + -0.008821729, + -0.009261897, + -0.009586611, + -0.009799446 + ] + }, + { + "name": "velocity", + "type": "float", + "data_points": [ + 0, + -9.689744, + -18.721231, + -27.04521, + -34.622158, + -41.4221, + -47.42434, + -52.617123, + -56.99723, + -60.56951, + -63.346367, + -65.34719, + -66.59777, + -67.12967, + -66.979576, + -66.18867, + -64.80193, + -62.86752, + -60.436077, + -57.560135, + -54.293465, + -50.690502, + -46.80577, + -42.693356, + -38.406395, + -33.99663, + -29.513977, + -25.006151, + -20.518335, + -16.092886, + -11.769089, + -7.582956, + -3.567066, + 0.24954945, + 3.8414824, + 7.186983, + 10.26796, + 13.069937, + 15.581989, + 17.79664, + 19.70973, + 21.32027, + 22.630259, + 23.644495, + 24.370367, + 24.817629, + 24.998167, + 24.925762, + 24.615849, + 24.085262, + 23.352001, + 22.434978, + 21.353788, + 20.128475, + 18.779318, + 17.32661, + 15.790471, + 14.190657, + 12.54639, + 10.8762045, + 9.197807, + 7.527954, + 5.882341, + 4.2755146, + 2.7207994, + 1.230238, + -0.1854506, + -1.5168974, + -2.7560964, + -3.8964016, + -4.932514, + -5.8604536, + -6.6775203, + -7.3822474, + -7.974343, + -8.454623, + -8.824943, + -9.088114, + -9.247824, + -9.30855, + -9.275467, + -9.154358, + -8.951525, + -8.673694, + -8.327926, + -7.921531, + -7.461982, + -6.956829, + -6.413628, + -5.8398623, + -5.2428765, + -4.629812, + -4.0075502, + -3.3826616, + -2.7613592, + -2.1494596, + -1.5523491, + -0.97495717, + -0.42173502, + 0.10335989, + 0.59687334, + 1.0558584, + 1.4778748, + 1.8609827, + 2.2037325, + 2.5051508, + 2.7647214, + 2.982364, + 3.1584096, + 3.2935734, + 3.3889253, + 3.4458592, + 3.4660602, + 3.4514713, + 3.4042604, + 3.3267848, + 3.2215586, + 3.091218, + 2.93849, + 2.766159, + 2.5770383, + 2.3739393, + 2.1596458, + 1.936888, + 1.708319, + 1.4764937, + 1.2438502, + 1.0126922, + 0.78517485, + 0.56329256, + 0.34886897, + 0.14354916, + -0.051205866, + -0.23412266, + -0.40411672, + -0.56029207, + -0.70193887, + -0.8285295, + -0.93971306, + -1.0353087, + -1.1152971, + -1.1798112, + -1.2291268, + -1.2636507, + -1.2839093, + -1.290537, + -1.2842634, + -1.2659005, + -1.2363305, + -1.1964928, + -1.1473718, + -1.0899843, + -1.0253683, + -0.9545714, + -0.8786402, + -0.7986099, + -0.71549547, + -0.63028246, + -0.5439195, + -0.4573111, + -0.37131146, + -0.28671914, + -0.20427254, + -0.12464625, + -0.048448235, + 0.023782196, + 0.09157562, + 0.15453298, + 0.21232536, + 0.26469308, + 0.3114442, + 0.3524524, + 0.38765445, + 0.41704708, + 0.44068357, + 0.4586699, + 0.4711607, + 0.47835487, + 0.48049107, + 0.47784314, + 0.47071537, + 0.4594378, + 0.44436142, + 0.42585367, + 0.4042939, + 0.38006887, + 0.35356876, + 0.32518306, + 0.29529685, + 0.26428732, + 0.23252065, + 0.20034899, + 0.1681079, + 0.1361141, + 0.10466347, + 0.07402937, + 0.04446134, + 0.016184038, + -0.0106034735, + -0.03572817, + -0.059043236, + -0.080427945, + -0.09978733, + -0.117051594, + -0.13217531, + -0.14513649, + -0.15593536, + -0.16459312, + -0.17115049, + -0.1756662, + -0.17821535, + -0.17888775, + -0.17778617, + -0.17502461, + -0.17072651, + -0.16502303, + -0.15805133, + -0.14995287, + -0.14087182, + -0.13095346, + -0.12034274, + -0.10918287, + -0.097614065, + -0.085772336, + -0.073788404, + -0.06178678, + -0.049884874, + -0.03819231, + -0.026810283, + -0.015831092 + ] + }, + { + "name": "stable", + "type": "boolean", + "data_points": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + { + "name": "parameters", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + } + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/view/emptySpec_outputMatchesInput_withoutAnimation.json b/mechanics/tests/goldens/view/emptySpec_outputMatchesInput_withoutAnimation.json new file mode 100644 index 0000000..f5f7612 --- /dev/null +++ b/mechanics/tests/goldens/view/emptySpec_outputMatchesInput_withoutAnimation.json @@ -0,0 +1,102 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 20, + 40, + 60, + 80, + 100 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "Max", + "Max", + "Max", + "Max", + "Max", + "Max" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 0, + 20, + 40, + 60, + 80, + 100 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 20, + 40, + 60, + 80, + 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 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + true, + true, + true, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/view/gestureContext_listensToGestureContextUpdates.json b/mechanics/tests/goldens/view/gestureContext_listensToGestureContextUpdates.json new file mode 100644 index 0000000..755ff78 --- /dev/null +++ b/mechanics/tests/goldens/view/gestureContext_listensToGestureContextUpdates.json @@ -0,0 +1,122 @@ +{ + "frame_ids": [ + 0, + 16, + 32, + 48, + 64, + 80, + 96, + 112 + ], + "features": [ + { + "name": "input", + "type": "float", + "data_points": [ + 0, + 0.5, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + { + "name": "gestureDirection", + "type": "string", + "data_points": [ + "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 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 0, + 0, + 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 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + true, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/view/segmentChange_animatedWhenReachingBreakpoint.json b/mechanics/tests/goldens/view/segmentChange_animatedWhenReachingBreakpoint.json new file mode 100644 index 0000000..f93fc6f --- /dev/null +++ b/mechanics/tests/goldens/view/segmentChange_animatedWhenReachingBreakpoint.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, + 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 + ] + }, + { + "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, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/view/specChange_triggersAnimation.json b/mechanics/tests/goldens/view/specChange_triggersAnimation.json new file mode 100644 index 0000000..b237f39 --- /dev/null +++ b/mechanics/tests/goldens/view/specChange_triggersAnimation.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": [ + 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" + ] + }, + { + "name": "output", + "type": "float", + "data_points": [ + 1.5, + 1.3117526, + 0.96824056, + 0.6450497, + 0.39762264, + 0.22869362, + 0.122471645, + 0.060223386, + 0.026204487, + 0.009041936, + 0 + ] + }, + { + "name": "outputTarget", + "type": "float", + "data_points": [ + 1.5, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + }, + { + "name": "outputSpring", + "type": "springParameters", + "data_points": [ + { + "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 + } + ] + }, + { + "name": "isStable", + "type": "boolean", + "data_points": [ + true, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/goldens/zeroDisplacement_initialVelocity_matchesGolden.json b/mechanics/tests/goldens/zeroDisplacement_initialVelocity_matchesGolden.json new file mode 100644 index 0000000..062143b --- /dev/null +++ b/mechanics/tests/goldens/zeroDisplacement_initialVelocity_matchesGolden.json @@ -0,0 +1,1318 @@ +{ + "frame_ids": [ + 0, + 10, + 20, + 30, + 40, + 50, + 60, + 70, + 80, + 90, + 100, + 110, + 120, + 130, + 140, + 150, + 160, + 170, + 180, + 190, + 200, + 210, + 220, + 230, + 240, + 250, + 260, + 270, + 280, + 290, + 300, + 310, + 320, + 330, + 340, + 350, + 360, + 370, + 380, + 390, + 400, + 410, + 420, + 430, + 440, + 450, + 460, + 470, + 480, + 490, + 500, + 510, + 520, + 530, + 540, + 550, + 560, + 570, + 580, + 590, + 600, + 610, + 620, + 630, + 640, + 650, + 660, + 670, + 680, + 690, + 700, + 710, + 720, + 730, + 740, + 750, + 760, + 770, + 780, + 790, + 800, + 810, + 820, + 830, + 840, + 850, + 860, + 870, + 880, + 890, + 900, + 910, + 920, + 930, + 940, + 950, + 960, + 970, + 980, + 990, + 1000, + 1010, + 1020, + 1030, + 1040, + 1050, + 1060, + 1070, + 1080, + 1090, + 1100, + 1110, + 1120, + 1130, + 1140, + 1150, + 1160, + 1170, + 1180, + 1190, + 1200, + 1210, + 1220, + 1230, + 1240, + 1250, + 1260, + 1270, + 1280, + 1290, + 1300, + 1310, + 1320, + 1330, + 1340, + 1350, + 1360, + 1370, + 1380, + 1390, + 1400, + 1410, + 1420, + 1430, + 1440, + 1450, + 1460, + 1470, + 1480, + 1490, + 1500, + 1510, + 1520, + 1530, + 1540, + 1550, + 1560, + 1570, + 1580, + 1590, + 1600 + ], + "features": [ + { + "name": "displacement", + "type": "float", + "data_points": [ + 0, + 0.09689744, + 0.18721232, + 0.2704521, + 0.3462216, + 0.41422102, + 0.4742434, + 0.52617127, + 0.56997234, + 0.6056951, + 0.6334637, + 0.6534719, + 0.66597766, + 0.6712966, + 0.6697957, + 0.66188663, + 0.6480193, + 0.62867516, + 0.60436076, + 0.57560134, + 0.54293466, + 0.506905, + 0.46805772, + 0.42693356, + 0.38406396, + 0.3399663, + 0.29513976, + 0.2500615, + 0.20518336, + 0.16092888, + 0.117690906, + 0.07582957, + 0.035670675, + -0.0024954774, + -0.038414806, + -0.07186981, + -0.10267957, + -0.13069934, + -0.15581986, + -0.17796637, + -0.19709727, + -0.21320267, + -0.22630256, + -0.23644494, + -0.24370365, + -0.24817626, + -0.24998164, + -0.2492576, + -0.24615847, + -0.24085261, + -0.23352, + -0.22434977, + -0.21353786, + -0.20128474, + -0.18779317, + -0.1732661, + -0.15790471, + -0.14190657, + -0.1254639, + -0.108762056, + -0.09197809, + -0.075279556, + -0.058823425, + -0.042755164, + -0.027208013, + -0.012302401, + 0.0018544839, + 0.015168951, + 0.027560938, + 0.038963992, + 0.049325116, + 0.05860451, + 0.06677517, + 0.073822446, + 0.0797434, + 0.08454621, + 0.08824941, + 0.09088112, + 0.09247822, + 0.09308548, + 0.09275465, + 0.09154356, + 0.08951523, + 0.08673692, + 0.083279245, + 0.0792153, + 0.07461981, + 0.069568284, + 0.064136274, + 0.05839862, + 0.05242876, + 0.046298113, + 0.040075496, + 0.033826612, + 0.02761359, + 0.021494593, + 0.015523489, + 0.009749569, + 0.0042173467, + -0.0010336027, + -0.0059687374, + -0.010558588, + -0.014778752, + -0.01860983, + -0.022037327, + -0.02505151, + -0.027647214, + -0.029823638, + -0.031584095, + -0.03293573, + -0.03388925, + -0.03445859, + -0.034660596, + -0.03451471, + -0.0340426, + -0.033267844, + -0.03221558, + -0.030912176, + -0.029384894, + -0.027661586, + -0.025770377, + -0.023739388, + -0.021596454, + -0.019368876, + -0.017083187, + -0.014764935, + -0.0124385, + -0.01012692, + -0.007851747, + -0.0056329244, + -0.003488689, + -0.0014354915, + 0.00051205826, + 0.0023412257, + 0.004041166, + 0.005602919, + 0.007019386, + 0.008285292, + 0.009397129, + 0.010353085, + 0.011152968, + 0.01179811, + 0.012291266, + 0.012636503, + 0.012839089, + 0.012905367, + 0.01284263, + 0.012659001, + 0.012363302, + 0.011964925, + 0.011473714, + 0.01089984, + 0.01025368, + 0.009545711, + 0.008786398, + 0.007986096, + 0.0071549513, + 0.0063028214, + 0.005439192, + 0.0045731086, + 0.0037131126 + ] + }, + { + "name": "velocity", + "type": "float", + "data_points": [ + 10, + 9.369641, + 8.685126, + 7.956248, + 7.1926575, + 6.4037824, + 5.598745, + 4.7862935, + 3.9747388, + 3.171899, + 2.3850527, + 1.6208987, + 0.8855264, + 0.18439122, + -0.47770125, + -1.0966038, + -1.6688296, + -2.1915476, + -2.6625717, + -3.0803442, + -3.443915, + -3.7529144, + -4.007524, + -4.208442, + -4.356847, + -4.454357, + -4.5029917, + -4.5051246, + -4.4634433, + -4.3809037, + -4.2606854, + -4.1061487, + -3.920791, + -3.7082043, + -3.4720364, + -3.2159505, + -2.9435902, + -2.6585445, + -2.3643165, + -2.0642943, + -1.7617248, + -1.4596908, + -1.16109, + -0.8686183, + -0.5847551, + -0.311752, + -0.051623996, + 0.19385597, + 0.4231603, + 0.63500726, + 0.828359, + 1.0024176, + 1.1566185, + 1.2906227, + 1.4043069, + 1.497752, + 1.5712303, + 1.625192, + 1.6602504, + 1.6771663, + 1.6768323, + 1.6602561, + 1.6285444, + 1.5828861, + 1.5245361, + 1.4547995, + 1.3750156, + 1.2865434, + 1.1907467, + 1.088981, + 0.982581, + 0.8728484, + 0.76104134, + 0.648365, + 0.5359627, + 0.42490852, + 0.31620094, + 0.21075752, + 0.10941076, + 0.012904932, + -0.078105986, + -0.16305938, + -0.24148415, + -0.31299993, + -0.37731555, + -0.43422657, + -0.48361233, + -0.5254321, + -0.55972093, + -0.58658487, + -0.60619575, + -0.6187858, + -0.6246418, + -0.6240991, + -0.6175356, + -0.6053656, + -0.58803356, + -0.5660082, + -0.53977644, + -0.5098377, + -0.47669807, + -0.44086543, + -0.40284407, + -0.3631302, + -0.32220754, + -0.2805433, + -0.23858473, + -0.19675589, + -0.15545486, + -0.11505145, + -0.0758852, + -0.038263895, + -0.0024624073, + 0.03127804, + 0.06275027, + 0.09178116, + 0.11823134, + 0.1419946, + 0.16299695, + 0.1811955, + 0.19657704, + 0.20915647, + 0.21897496, + 0.22609809, + 0.23061374, + 0.23262997, + 0.23227277, + 0.22968385, + 0.22501825, + 0.21844217, + 0.21013063, + 0.20026532, + 0.18903238, + 0.17662038, + 0.16321836, + 0.14901397, + 0.13419166, + 0.11893117, + 0.103406, + 0.08778213, + 0.07221683, + 0.056857638, + 0.0418415, + 0.027294062, + 0.013329109, + 4.8148875e-05, + -0.0124598555, + -0.024118617, + -0.034864526, + -0.04464653, + -0.053425904, + -0.06117589, + -0.06788128, + -0.07353788, + -0.078151904, + -0.08173932, + -0.084325135, + -0.08594259, + -0.08663239, + -0.08644188, + -0.08542417 + ] + }, + { + "name": "stable", + "type": "boolean", + "data_points": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + { + "name": "parameters", + "type": "springParameters", + "data_points": [ + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + }, + { + "stiffness": 100, + "dampingRatio": 0.3 + } + ] + } + ] +} \ No newline at end of file diff --git a/mechanics/tests/src/com/android/mechanics/DistanceGestureContextTest.kt b/mechanics/tests/src/com/android/mechanics/DistanceGestureContextTest.kt new file mode 100644 index 0000000..20b49a8 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/DistanceGestureContextTest.kt @@ -0,0 +1,151 @@ +/* + * 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.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.spec.InputDirection +import com.google.common.truth.Truth.assertThat +import kotlin.math.nextDown +import kotlin.math.nextUp +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DistanceGestureContextTest { + + @Test + fun setDistance_maxDirection_increasingInput_keepsDirection() { + val underTest = + DistanceGestureContext( + initialDragOffset = 0f, + initialDirection = InputDirection.Max, + directionChangeSlop = 5f, + ) + + for (value in 0..6) { + underTest.dragOffset = value.toFloat() + assertThat(underTest.direction).isEqualTo(InputDirection.Max) + } + } + + @Test + fun setDistance_minDirection_decreasingInput_keepsDirection() { + val underTest = + DistanceGestureContext( + initialDragOffset = 0f, + initialDirection = InputDirection.Min, + directionChangeSlop = 5f, + ) + + for (value in 0 downTo -6) { + underTest.dragOffset = value.toFloat() + assertThat(underTest.direction).isEqualTo(InputDirection.Min) + } + } + + @Test + fun setDistance_maxDirection_decreasingInput_keepsDirection_belowDirectionChangeSlop() { + val underTest = + DistanceGestureContext( + initialDragOffset = 0f, + initialDirection = InputDirection.Max, + directionChangeSlop = 5f, + ) + + underTest.dragOffset = -5f + assertThat(underTest.direction).isEqualTo(InputDirection.Max) + } + + @Test + fun setDistance_maxDirection_decreasingInput_switchesDirection_aboveDirectionChangeSlop() { + val underTest = + DistanceGestureContext( + initialDragOffset = 0f, + initialDirection = InputDirection.Max, + directionChangeSlop = 5f, + ) + + underTest.dragOffset = (-5f).nextDown() + assertThat(underTest.direction).isEqualTo(InputDirection.Min) + } + + @Test + fun setDistance_minDirection_increasingInput_keepsDirection_belowDirectionChangeSlop() { + val underTest = + DistanceGestureContext( + initialDragOffset = 0f, + initialDirection = InputDirection.Min, + directionChangeSlop = 5f, + ) + + underTest.dragOffset = 5f + assertThat(underTest.direction).isEqualTo(InputDirection.Min) + } + + @Test + fun setDistance_minDirection_decreasingInput_switchesDirection_aboveDirectionChangeSlop() { + val underTest = + DistanceGestureContext( + initialDragOffset = 0f, + initialDirection = InputDirection.Min, + directionChangeSlop = 5f, + ) + + underTest.dragOffset = 5f.nextUp() + assertThat(underTest.direction).isEqualTo(InputDirection.Max) + } + + @Test + fun reset_resetsFurthestValue() { + val underTest = + DistanceGestureContext( + initialDragOffset = 10f, + initialDirection = InputDirection.Max, + directionChangeSlop = 1f, + ) + + underTest.reset(5f, direction = InputDirection.Max) + assertThat(underTest.direction).isEqualTo(InputDirection.Max) + assertThat(underTest.dragOffset).isEqualTo(5f) + + underTest.dragOffset -= 1f + assertThat(underTest.direction).isEqualTo(InputDirection.Max) + assertThat(underTest.dragOffset).isEqualTo(4f) + + underTest.dragOffset = underTest.dragOffset.nextDown() + assertThat(underTest.direction).isEqualTo(InputDirection.Min) + assertThat(underTest.dragOffset).isWithin(0.0001f).of(4f) + } + + @Test + fun setDirectionChangeSlop_smallerThanCurrentDelta_switchesDirection() { + val underTest = + DistanceGestureContext( + initialDragOffset = 10f, + initialDirection = InputDirection.Max, + directionChangeSlop = 5f, + ) + + underTest.dragOffset -= 2f + assertThat(underTest.direction).isEqualTo(InputDirection.Max) + assertThat(underTest.dragOffset).isEqualTo(8f) + + underTest.directionChangeSlop = 1f + assertThat(underTest.direction).isEqualTo(InputDirection.Min) + assertThat(underTest.dragOffset).isEqualTo(8f) + } +} diff --git a/mechanics/tests/src/com/android/mechanics/MotionValueLifecycleTest.kt b/mechanics/tests/src/com/android/mechanics/MotionValueLifecycleTest.kt new file mode 100644 index 0000000..72ba985 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/MotionValueLifecycleTest.kt @@ -0,0 +1,176 @@ +/* + * Copyright (C) 2025 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.LaunchedEffect +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.MotionValueTest.Companion.FakeGestureContext +import com.android.mechanics.MotionValueTest.Companion.specBuilder +import com.android.mechanics.spec.Mapping +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MotionValueLifecycleTest { + + @get:Rule(order = 0) val rule = createComposeRule() + + @Test + fun keepRunning_suspendsWithoutAnAnimation() = runTest { + val input = mutableFloatStateOf(0f) + val spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) } + 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).isTrue() + + rule.mainClock.advanceTimeByFrame() + rule.awaitIdle() + + // Produces the frame.. + assertThat(framesCount).isEqualTo(2) + // ... and is suspended again. + assertThat(inspector.isAnimating).isFalse() + + rule.mainClock.autoAdvance = true + rule.awaitIdle() + // Ensure that no more frames are produced + assertThat(framesCount).isEqualTo(2) + } + + @Test + fun keepRunning_remainsActiveWhileAnimating() = runTest { + val input = mutableFloatStateOf(0f) + val spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) } + 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) + } + + val timeBeforeAutoAdvance = rule.mainClock.currentTime + + // 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() + + rule.awaitIdle() + + // Stabilizing the spring during awaitIdle() took 160ms (obtained from looking at reference + // test runs). That time is expected to be 100% reproducible, given the starting + // state/configuration of the spring before awaitIdle(). + assertThat(rule.mainClock.currentTime).isEqualTo(timeBeforeAutoAdvance + 160) + } + + @Test + fun keepRunningWhile_stopRunningWhileStable_endsImmediately() = runTest { + val input = mutableFloatStateOf(0f) + val spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) } + val underTest = MotionValue(input::value, FakeGestureContext, spec) + + val continueRunning = mutableStateOf(true) + + rule.setContent { + LaunchedEffect(Unit) { underTest.keepRunningWhile { continueRunning.value } } + } + + val inspector = underTest.debugInspector() + + rule.awaitIdle() + + assertWithMessage("isActive").that(inspector.isActive).isTrue() + assertWithMessage("isAnimating").that(inspector.isAnimating).isFalse() + + val timeBeforeStopRunning = rule.mainClock.currentTime + continueRunning.value = false + rule.awaitIdle() + + assertWithMessage("isActive").that(inspector.isActive).isFalse() + assertWithMessage("isAnimating").that(inspector.isAnimating).isFalse() + assertThat(rule.mainClock.currentTime).isEqualTo(timeBeforeStopRunning) + } +} 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..ffb8e87 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/MotionValueTest.kt @@ -0,0 +1,675 @@ +/* + * 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 android.util.Log +import android.util.Log.TerribleFailureHandler +import androidx.compose.runtime.mutableFloatStateOf +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.spec.Breakpoint +import com.android.mechanics.spec.BreakpointKey +import com.android.mechanics.spec.Guarantee.GestureDragDelta +import com.android.mechanics.spec.Guarantee.InputDelta +import com.android.mechanics.spec.Guarantee.None +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.SegmentKey +import com.android.mechanics.spec.SemanticKey +import com.android.mechanics.spec.SemanticValue +import com.android.mechanics.spec.builder.CanBeLastSegment +import com.android.mechanics.spec.builder.DirectionalBuilderScope +import com.android.mechanics.spec.builder.MotionBuilderContext +import com.android.mechanics.spec.builder.directionalMotionSpec +import com.android.mechanics.spec.with +import com.android.mechanics.testing.ComposeMotionValueToolkit +import com.android.mechanics.testing.FakeMotionSpecBuilderContext +import com.android.mechanics.testing.FeatureCaptures +import com.android.mechanics.testing.VerifyTimeSeriesResult.AssertTimeSeriesMatchesGolden +import com.android.mechanics.testing.VerifyTimeSeriesResult.SkipGoldenVerification +import com.android.mechanics.testing.animateValueTo +import com.android.mechanics.testing.animatedInputSequence +import com.android.mechanics.testing.dataPoints +import com.android.mechanics.testing.defaultFeatureCaptures +import com.android.mechanics.testing.goldenTest +import com.android.mechanics.testing.input +import com.android.mechanics.testing.isStable +import com.android.mechanics.testing.output +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.launch +import org.junit.Rule +import org.junit.Test +import org.junit.rules.ExternalResource +import org.junit.runner.RunWith +import platform.test.motion.MotionTestRule +import platform.test.motion.compose.runMonotonicClockTest +import platform.test.motion.golden.DataPointTypes +import platform.test.motion.testing.createGoldenPathManager + +@RunWith(AndroidJUnit4::class) +class MotionValueTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Default { + private val goldenPathManager = + createGoldenPathManager("frameworks/libs/systemui/mechanics/tests/goldens") + + @get:Rule(order = 1) val motion = MotionTestRule(ComposeMotionValueToolkit, goldenPathManager) + @get:Rule(order = 2) val wtfLog = WtfLogRule() + + @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) + + AssertTimeSeriesMatchesGolden() + }, + ) { + 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) { + mapping(breakpoint = 1f, mapping = Mapping.Linear(factor = 0.5f)) + } + ) { + animateValueTo(1.1f, changePerFrame = 0.5f) + while (underTest.isStable) { + updateInput(input + 0.5f) + awaitFrames() + } + } + + @Test + fun segmentChange_inMaxDirection_animatedWhenReachingBreakpoint() = + motion.goldenTest( + spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) } + ) { + animateValueTo(1f, changePerFrame = 0.5f) + awaitStable() + } + + @Test + fun segmentChange_inMaxDirection_zeroDelta() = + motion.goldenTest(spec = specBuilder(Mapping.Zero) { fixedValueFromCurrent(0.5f) }) { + animateValueTo(1f, changePerFrame = 0.5f) + awaitStable() + } + + @Test + fun segmentChange_inMinDirection_animatedWhenReachingBreakpoint() = + motion.goldenTest( + initialValue = 2f, + initialDirection = InputDirection.Min, + spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) }, + ) { + animateValueTo(1f, changePerFrame = 0.5f) + awaitStable() + } + + @Test + fun segmentChange_inMaxDirection_springAnimationStartedRetroactively() = + motion.goldenTest( + spec = specBuilder(Mapping.Zero) { mapping(breakpoint = .75f, mapping = Mapping.One) } + ) { + animateValueTo(1f, changePerFrame = 0.5f) + awaitStable() + } + + @Test + fun segmentChange_inMinDirection_springAnimationStartedRetroactively() = + motion.goldenTest( + initialValue = 2f, + initialDirection = InputDirection.Min, + spec = specBuilder(Mapping.Zero) { mapping(breakpoint = 1.25f, mapping = Mapping.One) }, + ) { + animateValueTo(1f, changePerFrame = 0.5f) + awaitStable() + } + + @Test + fun segmentChange_guaranteeNone_springAnimatesIndependentOfInput() = + motion.goldenTest( + spec = + specBuilder(Mapping.Zero) { + fixedValue(breakpoint = 1f, guarantee = None, value = 1f) + } + ) { + animateValueTo(5f, changePerFrame = 0.5f) + awaitStable() + } + + @Test + fun segmentChange_guaranteeInputDelta_springCompletesWithinDistance() = + motion.goldenTest( + spec = + specBuilder(Mapping.Zero) { + fixedValue(breakpoint = 1f, guarantee = InputDelta(3f), value = 1f) + } + ) { + animateValueTo(4f, changePerFrame = 0.5f) + } + + @Test + fun segmentChange_guaranteeGestureDragDelta_springCompletesWithinDistance() = + motion.goldenTest( + spec = + specBuilder(Mapping.Zero) { + fixedValue(breakpoint = 1f, guarantee = GestureDragDelta(3f), value = 1f) + } + ) { + animateValueTo(1f, changePerFrame = 0.5f) + while (!underTest.isStable) { + gestureContext.dragOffset += 0.5f + awaitFrames() + } + } + + @Test + fun segmentChange_appliesOutputVelocity_atSpringStart() = + motion.goldenTest(spec = specBuilder { fixedValue(breakpoint = 10f, value = 20f) }) { + animateValueTo(11f, changePerFrame = 3f) + awaitStable() + } + + @Test + fun segmentChange_appliesOutputVelocity_springVelocityIsNotAppliedTwice() = + motion.goldenTest( + spec = + specBuilder { + fractionalInputFromCurrent(breakpoint = 10f, fraction = 1f, delta = 20f) + fixedValueFromCurrent(breakpoint = 20f) + } + ) { + animateValueTo(21f, changePerFrame = 3f) + awaitStable() + } + + @Test + fun segmentChange_appliesOutputVelocity_velocityNotAddedOnContinuousSegment() = + motion.goldenTest( + spec = + specBuilder { + fractionalInputFromCurrent(breakpoint = 10f, fraction = 5f, delta = 5f) + fixedValueFromCurrent(breakpoint = 20f) + } + ) { + animateValueTo(30f, changePerFrame = 3f) + awaitStable() + } + + @Test + fun segmentChange_appliesOutputVelocity_velocityAddedOnDiscontinuousSegment() = + motion.goldenTest( + spec = + specBuilder { + fractionalInputFromCurrent(breakpoint = 10f, fraction = 5f, delta = 5f) + fixedValueFromCurrent(breakpoint = 20f, delta = -5f) + } + ) { + animateValueTo(30f, changePerFrame = 3f) + awaitStable() + } + + @Test + // Regression test for b/409726626 + fun segmentChange_animationAtRest_doesNotAffectVelocity() = + motion.goldenTest( + spec = + specBuilder(Mapping.Zero) { + fixedValue(breakpoint = 1f, value = 20f) + fixedValue(breakpoint = 2f, value = 20f) + fixedValue(breakpoint = 3f, value = 10f) + }, + stableThreshold = 1f, + ) { + this.updateInput(1.5f) + awaitStable() + animateValueTo(3f) + awaitStable() + } + + @Test + fun specChange_shiftSegmentBackwards_doesNotAnimateWithinSegment_animatesSegmentChange() { + fun generateSpec(offset: Float) = + specBuilder(Mapping.Zero) { + targetFromCurrent(breakpoint = offset, key = B1, delta = 1f, to = 2f) + fixedValue(breakpoint = offset + 1f, key = B2, value = 0f) + } + + 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) { + targetFromCurrent(breakpoint = offset, key = B1, delta = 1f, to = 2f) + fixedValue(breakpoint = offset + 1f, key = B2, value = 0f) + } + + 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) { fixedValue(breakpoint = 1f, value = 1f) }, + initialValue = 2f, + initialDirection = InputDirection.Max, + directionChangeSlop = 3f, + ) { + animateValueTo(-2f, changePerFrame = 0.5f) + awaitStable() + } + + @Test + fun directionChange_minToMax_changesSegmentWithDirectionChange() = + motion.goldenTest( + spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) }, + 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) { + fixedValue(breakpoint = 1f, value = 1f, 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) { + fixedValue(breakpoint = 1f, value = 1f) + fixedValue(breakpoint = 2f, value = 2f) + } + ) { + animateValueTo(3f, changePerFrame = 0.2f) + awaitStable() + } + + @Test + fun traverseSegmentsInOneFrame_noGuarantee_combinesDiscontinuity() = + motion.goldenTest( + spec = + specBuilder(Mapping.Zero) { + fixedValue(breakpoint = 1f, value = 1f) + fixedValue(breakpoint = 2f, value = 2f) + } + ) { + updateInput(2.5f) + awaitStable() + } + + @Test + fun traverseSegmentsInOneFrame_withGuarantee_appliesGuarantees() = + motion.goldenTest( + spec = + specBuilder(Mapping.Zero) { + fixedValueFromCurrent(breakpoint = 1f, delta = 5f, guarantee = InputDelta(.9f)) + fixedValueFromCurrent(breakpoint = 2f, delta = 1f, guarantee = InputDelta(.9f)) + } + ) { + updateInput(2.1f) + awaitStable() + } + + @Test + fun traverseSegmentsInOneFrame_withDirectionChange_appliesGuarantees() = + motion.goldenTest( + spec = + specBuilder(Mapping.Zero) { + fixedValue(breakpoint = 1f, value = 1f, guarantee = InputDelta(1f)) + fixedValue(breakpoint = 2f, value = 2f) + }, + initialValue = 2.5f, + initialDirection = InputDirection.Max, + directionChangeSlop = 1f, + ) { + updateInput(.5f) + animateValueTo(0f) + awaitStable() + } + + @Test + fun changeDirection_flipsBetweenDirectionalSegments() { + val spec = + MotionSpec( + maxDirection = directionalMotionSpec(Mapping.Zero), + minDirection = directionalMotionSpec(Mapping.One), + ) + + motion.goldenTest( + spec = spec, + initialValue = 2f, + initialDirection = InputDirection.Max, + directionChangeSlop = 1f, + ) { + animateValueTo(0f) + awaitStable() + } + } + + @Test + fun semantics_flipsBetweenDirectionalSegments() { + val s1 = SemanticKey("Foo") + val spec = + specBuilder(Mapping.Zero, semantics = listOf(s1 with "zero")) { + fixedValue(1f, 1f, semantics = listOf(s1 with "one")) + fixedValue(2f, 2f, semantics = listOf(s1 with "two")) + } + + motion.goldenTest( + spec = spec, + capture = { + defaultFeatureCaptures() + feature(FeatureCaptures.semantics(s1, DataPointTypes.string)) + }, + ) { + animateValueTo(3f, changePerFrame = .2f) + awaitStable() + } + } + + @Test + fun semantics_returnsNullForUnknownKey() { + val underTest = MotionValue({ 1f }, FakeGestureContext) + + val s1 = SemanticKey("Foo") + + assertThat(underTest[s1]).isNull() + } + + @Test + fun semantics_returnsValueMatchingSegment() { + val s1 = SemanticKey("Foo") + val spec = + specBuilder(Mapping.Zero, semantics = listOf(s1 with "zero")) { + fixedValue(1f, 1f, semantics = listOf(s1 with "one")) + fixedValue(2f, 2f, semantics = listOf(s1 with "two")) + } + + val input = mutableFloatStateOf(0f) + val underTest = MotionValue(input::value, FakeGestureContext, spec) + + assertThat(underTest[s1]).isEqualTo("zero") + input.floatValue = 2f + assertThat(underTest[s1]).isEqualTo("two") + } + + @Test + fun segment_returnsCurrentSegmentKey() { + val spec = + specBuilder(Mapping.Zero) { + fixedValue(1f, 1f, key = B1) + fixedValue(2f, 2f, key = B2) + } + + val input = mutableFloatStateOf(1f) + val underTest = MotionValue(input::value, FakeGestureContext, spec) + + assertThat(underTest.segmentKey).isEqualTo(SegmentKey(B1, B2, InputDirection.Max)) + input.floatValue = 2f + assertThat(underTest.segmentKey) + .isEqualTo(SegmentKey(B2, Breakpoint.maxLimit.key, InputDirection.Max)) + } + + @Test + fun derivedValue_reflectsInputChangeInSameFrame() { + motion.goldenTest( + spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 0.5f, value = 1f) }, + createDerived = { primary -> + listOf(MotionValue.createDerived(primary, MotionSpec.Empty, label = "derived")) + }, + verifyTimeSeries = { + // the output of the derived value must match the primary value + assertThat(output) + .containsExactlyElementsIn(dataPoints("derived-output")) + .inOrder() + // and its never animated. + assertThat(dataPoints("derived-isStable")).doesNotContain(false) + + AssertTimeSeriesMatchesGolden() + }, + ) { + animateValueTo(1f, changePerFrame = 0.1f) + awaitStable() + } + } + + @Test + fun derivedValue_hasAnimationLifecycleOnItsOwn() { + motion.goldenTest( + spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 0.5f, value = 1f) }, + createDerived = { primary -> + listOf( + MotionValue.createDerived( + primary, + specBuilder(Mapping.One) { fixedValue(breakpoint = 0.5f, value = 0f) }, + label = "derived", + ) + ) + }, + ) { + animateValueTo(1f, changePerFrame = 0.1f) + awaitStable() + } + } + + @Test + fun nonFiniteNumbers_producesNaN_recoversOnSubsequentFrames() { + motion.goldenTest( + spec = MotionSpec(directionalMotionSpec({ if (it >= 1f) Float.NaN else 0f })), + verifyTimeSeries = { + assertThat(output.drop(1).take(5)) + .containsExactlyElementsIn(listOf(0f, Float.NaN, Float.NaN, 0f, 0f)) + .inOrder() + SkipGoldenVerification + }, + ) { + animatedInputSequence(0f, 1f, 1f, 0f, 0f) + } + + assertThat(wtfLog.hasLoggedFailures()).isFalse() + } + + @Test + fun nonFiniteNumbers_segmentChange_skipsAnimation() { + motion.goldenTest( + spec = MotionSpec.Empty, + verifyTimeSeries = { + // The mappings produce a non-finite number during a segment change. + // The animation thereof is skipped to avoid poisoning the state with non-finite + // numbers + assertThat(output.drop(1).take(5)) + .containsExactlyElementsIn(listOf(0f, 1f, Float.NaN, 0f, 0f)) + .inOrder() + SkipGoldenVerification + }, + ) { + animatedInputSequence(0f, 1f) + underTest.spec = specBuilder { + mapping(breakpoint = 0f) { if (it >= 1f) Float.NaN else 0f } + } + + awaitFrames() + + animatedInputSequence(0f, 0f) + } + + val loggedFailures = wtfLog.removeLoggedFailures() + assertThat(loggedFailures).hasSize(1) + assertThat(loggedFailures.first()).startsWith("Delta between mappings is undefined") + } + + @Test + fun nonFiniteNumbers_segmentTraverse_skipsAnimation() { + motion.goldenTest( + spec = + specBuilder(Mapping.Zero) { + mapping(breakpoint = 1f) { if (it < 2f) Float.NaN else 2f } + }, + verifyTimeSeries = { + // The mappings produce a non-finite number during a breakpoint traversal. + // The animation thereof is skipped to avoid poisoning the state with non-finite + // numbers + assertThat(output.drop(1).take(6)) + .containsExactlyElementsIn(listOf(0f, 0f, Float.NaN, Float.NaN, 2f, 2f)) + .inOrder() + SkipGoldenVerification + }, + ) { + animatedInputSequence(0f, 0.5f, 1f, 1.5f, 2f, 3f) + } + val loggedFailures = wtfLog.removeLoggedFailures() + assertThat(loggedFailures).hasSize(1) + assertThat(loggedFailures.first()).startsWith("Delta between breakpoints is undefined") + } + + @Test + fun keepRunning_concurrentInvocationThrows() = runMonotonicClockTest { + val underTest = MotionValue({ 1f }, FakeGestureContext, label = "Foo") + 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("MotionValue(Foo) is already running") + } + assertThat(realJob.isActive).isTrue() + realJob.cancel() + } + + @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) + } + + class WtfLogRule : ExternalResource() { + private val loggedFailures = mutableListOf() + + private lateinit var oldHandler: TerribleFailureHandler + + override fun before() { + oldHandler = + Log.setWtfHandler { tag, what, _ -> + if (tag == MotionValue.TAG) { + loggedFailures.add(checkNotNull(what.message)) + } + } + } + + override fun after() { + Log.setWtfHandler(oldHandler) + + // In eng-builds, some misconfiguration in a MotionValue would cause a crash. However, + // in tests (and in production), we want animations to proceed even with such errors. + // When a test ends, we should check loggedFailures, if they were expected. + assertThat(loggedFailures).isEmpty() + } + + fun hasLoggedFailures() = loggedFailures.isNotEmpty() + + fun removeLoggedFailures(): List { + if (loggedFailures.isEmpty()) error("loggedFailures is empty") + val list = loggedFailures.toList() + loggedFailures.clear() + return list + } + } + + companion object { + val B1 = BreakpointKey("breakpoint1") + val B2 = BreakpointKey("breakpoint2") + val FakeGestureContext = + object : GestureContext { + override val direction: InputDirection + get() = InputDirection.Max + + override val dragOffset: Float + get() = 0f + } + + private val Springs = FakeMotionSpecBuilderContext.Default.spatial + + fun specBuilder( + initialMapping: Mapping = Mapping.Identity, + semantics: List> = emptyList(), + init: DirectionalBuilderScope.() -> CanBeLastSegment, + ): MotionSpec { + return MotionSpec( + directionalMotionSpec(Springs.default, initialMapping, semantics, init), + resetSpring = Springs.fast, + ) + } + } +} diff --git a/mechanics/tests/src/com/android/mechanics/debug/MotionValueDebuggerTest.kt b/mechanics/tests/src/com/android/mechanics/debug/MotionValueDebuggerTest.kt new file mode 100644 index 0000000..dfe69b8 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/debug/MotionValueDebuggerTest.kt @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2025 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.Box +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.MotionValue +import com.android.mechanics.ProvidedGestureContext +import com.android.mechanics.spec.InputDirection +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MotionValueDebuggerTest { + + private val input: () -> Float = { 0f } + private val gestureContext = + ProvidedGestureContext(dragOffset = 0f, direction = InputDirection.Max) + + @get:Rule(order = 0) val rule = createComposeRule() + + @Test + fun debugMotionValue_registersMotionValue_whenAddingToComposition() { + val debuggerState = MotionValueDebuggerState() + var hasValue by mutableStateOf(false) + + rule.setContent { + Box(modifier = Modifier.motionValueDebugger(debuggerState)) { + if (hasValue) { + val toDebug = remember { MotionValue(input, gestureContext) } + Box(modifier = Modifier.debugMotionValue(toDebug)) + } + } + } + + assertThat(debuggerState.observedMotionValues).isEmpty() + + hasValue = true + rule.waitForIdle() + + assertThat(debuggerState.observedMotionValues).hasSize(1) + } + + @Test + fun debugMotionValue_unregistersMotionValue_whenLeavingComposition() { + val debuggerState = MotionValueDebuggerState() + var hasValue by mutableStateOf(true) + + rule.setContent { + Box(modifier = Modifier.motionValueDebugger(debuggerState)) { + if (hasValue) { + val toDebug = remember { MotionValue(input, gestureContext) } + Box(modifier = Modifier.debugMotionValue(toDebug)) + } + } + } + + assertThat(debuggerState.observedMotionValues).hasSize(1) + + hasValue = false + rule.waitForIdle() + assertThat(debuggerState.observedMotionValues).isEmpty() + } + + @Test + fun debugMotionValue_noDebugger_isNoOp() { + rule.setContent { + val toDebug = remember { MotionValue(input, gestureContext) } + Box(modifier = Modifier.debugMotionValue(toDebug)) + } + } +} diff --git a/mechanics/tests/src/com/android/mechanics/effects/MagneticDetachTest.kt b/mechanics/tests/src/com/android/mechanics/effects/MagneticDetachTest.kt new file mode 100644 index 0000000..988f72e --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/effects/MagneticDetachTest.kt @@ -0,0 +1,252 @@ +/* + * Copyright (C) 2025 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.effects + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.effects.MagneticDetach.Defaults.AttachPosition +import com.android.mechanics.effects.MagneticDetach.Defaults.DetachPosition +import com.android.mechanics.effects.MagneticDetach.State.Attached +import com.android.mechanics.effects.MagneticDetach.State.Detached +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.builder.EffectPlacemenType +import com.android.mechanics.spec.builder.MotionBuilderContext +import com.android.mechanics.spec.builder.spatialMotionSpec +import com.android.mechanics.testing.ComposeMotionValueToolkit +import com.android.mechanics.testing.FakeMotionSpecBuilderContext +import com.android.mechanics.testing.MotionSpecSubject.Companion.assertThat +import com.android.mechanics.testing.VerifyTimeSeriesResult.AssertTimeSeriesMatchesGolden +import com.android.mechanics.testing.animateValueTo +import com.android.mechanics.testing.goldenTest +import kotlin.test.fail +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import platform.test.motion.MotionTestRule +import platform.test.motion.testing.createGoldenPathManager +import platform.test.screenshot.PathConfig +import platform.test.screenshot.PathElementNoContext + +@RunWith(AndroidJUnit4::class) +class MagneticDetachSpecTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Default { + + @Test + fun magneticDetach_matchesSpec() { + val underTests = spatialMotionSpec { after(10f, MagneticDetach()) } + + assertThat(underTests).maxDirection().breakpoints().positions().containsExactly(10f, 90f) + assertThat(underTests) + .minDirection() + .breakpoints() + .positions() + .containsExactly(10f, 50f, 90f) + } + + @Test + fun attachDetachSemantics_placedAfter_isAppliedOutside() { + val underTests = spatialMotionSpec { after(10f, MagneticDetach()) } + + assertThat(underTests) + .maxDirection() + .semantics() + .withKey(MagneticDetach.Defaults.AttachDetachState) + .containsExactly(Attached, Attached, Detached) + + assertThat(underTests) + .minDirection() + .semantics() + .withKey(MagneticDetach.Defaults.AttachDetachState) + .containsExactly(Attached, Attached, Detached, Detached) + } + + @Test + fun attachValueSemantics_placedAfter_isAppliedInside() { + val underTests = spatialMotionSpec { after(10f, MagneticDetach()) } + + assertThat(underTests) + .maxDirection() + .semantics() + .withKey(MagneticDetach.Defaults.AttachedValue) + .containsExactly(null, 10f, null) + + assertThat(underTests) + .minDirection() + .semantics() + .withKey(MagneticDetach.Defaults.AttachedValue) + .containsExactly(null, 10f, null, null) + } + + @Test + fun attachDetachSemantics_placedBefore_isAppliedOutside() { + val underTests = spatialMotionSpec { before(10f, MagneticDetach()) } + + assertThat(underTests) + .maxDirection() + .semantics() + .withKey(MagneticDetach.Defaults.AttachDetachState) + .containsExactly(Detached, Detached, Attached, Attached) + + assertThat(underTests) + .minDirection() + .semantics() + .withKey(MagneticDetach.Defaults.AttachDetachState) + .containsExactly(Detached, Attached, Attached) + } + + @Test + fun attachValueSemantics_placedBefore_isAppliedInside() { + val underTests = spatialMotionSpec { before(10f, MagneticDetach()) } + + assertThat(underTests) + .maxDirection() + .semantics() + .withKey(MagneticDetach.Defaults.AttachedValue) + .containsExactly(null, null, 10f, null) + + assertThat(underTests) + .minDirection() + .semantics() + .withKey(MagneticDetach.Defaults.AttachedValue) + .containsExactly(null, 10f, null) + } +} + +@RunWith(Parameterized::class) +class MagneticDetachGoldenTest(private val placement: EffectPlacemenType) : + MotionBuilderContext by FakeMotionSpecBuilderContext.Default { + + companion object { + @Parameterized.Parameters(name = "{0}") + @JvmStatic + fun placements() = listOf(EffectPlacemenType.After, EffectPlacemenType.Before) + } + + private val goldenPathManager = + createGoldenPathManager( + "frameworks/libs/systemui/mechanics/tests/goldens", + PathConfig( + PathElementNoContext("effect", isDir = true) { "MagneticDetach" }, + PathElementNoContext("placement", isDir = false) { "placed${placement.name}" }, + ), + ) + + @get:Rule val motion = MotionTestRule(ComposeMotionValueToolkit, goldenPathManager) + + private val directionSign: Float + get() = + when (placement) { + EffectPlacemenType.After -> 1f + EffectPlacemenType.Before -> -1f + else -> fail() + } + + private fun createTestSpec() = spatialMotionSpec { + if (placement == EffectPlacemenType.After) { + after(10f, MagneticDetach()) + } else if (placement == EffectPlacemenType.Before) { + before(-10f, MagneticDetach()) + } + } + + @Test + fun detach_animatesDetach() { + motion.goldenTest( + createTestSpec(), + verifyTimeSeries = { AssertTimeSeriesMatchesGolden("detach_animatesDetach") }, + ) { + animateValueTo((DetachPosition.toPx() + 10f) * directionSign, changePerFrame = 5f) + awaitStable() + } + } + + @Test + fun attach_snapsToOrigin() { + motion.goldenTest( + createTestSpec(), + initialValue = (DetachPosition.toPx() + 20f) * directionSign, + initialDirection = InputDirection.Min, + verifyTimeSeries = { AssertTimeSeriesMatchesGolden("attach_snapsToOrigin") }, + ) { + animateValueTo(0f, changePerFrame = 5f) + awaitStable() + } + } + + @Test + fun beforeAttach_suppressesDirectionReverse() { + motion.goldenTest( + createTestSpec(), + initialValue = (DetachPosition.toPx() + 20f) * directionSign, + initialDirection = InputDirection.Min, + verifyTimeSeries = { + AssertTimeSeriesMatchesGolden("beforeAttach_suppressesDirectionReverse") + }, + ) { + animateValueTo((AttachPosition.toPx() + 11f) * directionSign) + animateValueTo((DetachPosition.toPx() + 20f) * directionSign) + awaitStable() + } + } + + @Test + fun afterAttach_detachesAgain() { + motion.goldenTest( + createTestSpec(), + initialValue = (DetachPosition.toPx() + 20f) * directionSign, + initialDirection = InputDirection.Min, + verifyTimeSeries = { AssertTimeSeriesMatchesGolden("afterAttach_detachesAgain") }, + ) { + animateValueTo((AttachPosition.toPx() / 2f + 10f) * directionSign, changePerFrame = 5f) + awaitStable() + animateValueTo((DetachPosition.toPx() + 20f) * directionSign, changePerFrame = 5f) + awaitStable() + } + } + + @Test + fun beforeDetach_suppressesDirectionReverse() { + motion.goldenTest( + createTestSpec(), + verifyTimeSeries = { + AssertTimeSeriesMatchesGolden("beforeDetach_suppressesDirectionReverse") + }, + ) { + animateValueTo((DetachPosition.toPx() - 9f) * directionSign) + animateValueTo(0f) + awaitStable() + } + } + + @Test + fun placedWithDifferentBaseMapping() { + motion.goldenTest( + spatialMotionSpec(baseMapping = Mapping.Linear(factor = -10f)) { + if (placement == EffectPlacemenType.After) { + after(-10f, MagneticDetach()) + } else if (placement == EffectPlacemenType.Before) { + before(10f, MagneticDetach()) + } + }, + initialValue = (-10f) * directionSign, + verifyTimeSeries = { AssertTimeSeriesMatchesGolden("placedWithDifferentBaseMapping") }, + ) { + animateValueTo((DetachPosition.toPx() - 10f) * directionSign) + awaitStable() + } + } +} diff --git a/mechanics/tests/src/com/android/mechanics/effects/OverdragTest.kt b/mechanics/tests/src/com/android/mechanics/effects/OverdragTest.kt new file mode 100644 index 0000000..c0bd8cc --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/effects/OverdragTest.kt @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2025 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.effects + +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.builder.MotionBuilderContext +import com.android.mechanics.spec.builder.spatialMotionSpec +import com.android.mechanics.testing.CaptureTimeSeriesFn +import com.android.mechanics.testing.ComposeMotionValueToolkit +import com.android.mechanics.testing.FakeMotionSpecBuilderContext +import com.android.mechanics.testing.FeatureCaptures +import com.android.mechanics.testing.VerifyTimeSeriesResult +import com.android.mechanics.testing.animateValueTo +import com.android.mechanics.testing.defaultFeatureCaptures +import com.android.mechanics.testing.goldenTest +import com.android.mechanics.testing.input +import com.android.mechanics.testing.nullableDataPoints +import com.android.mechanics.testing.output +import com.google.common.truth.Truth.assertThat +import kotlin.math.abs +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import platform.test.motion.MotionTestRule +import platform.test.motion.golden.DataPointTypes +import platform.test.motion.testing.createGoldenPathManager + +@RunWith(AndroidJUnit4::class) +class OverdragTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Default { + private val goldenPathManager = + createGoldenPathManager("frameworks/libs/systemui/mechanics/tests/goldens") + + @get:Rule val motion = MotionTestRule(ComposeMotionValueToolkit, goldenPathManager) + + @Test + fun overdrag_maxDirection_neverExceedsMaxOverdrag() { + motion.goldenTest( + spatialMotionSpec { after(10f, Overdrag(maxOverdrag = 20.dp)) }, + capture = captureOverdragFeatures, + verifyTimeSeries = { + assertThat(output.filter { it > 30 }).isEmpty() + VerifyTimeSeriesResult.AssertTimeSeriesMatchesGolden() + }, + ) { + animateValueTo(100f, changePerFrame = 5f) + } + } + + @Test + fun overdrag_minDirection_neverExceedsMaxOverdrag() { + motion.goldenTest( + spatialMotionSpec { before(-10f, Overdrag(maxOverdrag = 20.dp)) }, + capture = captureOverdragFeatures, + initialDirection = InputDirection.Min, + verifyTimeSeries = { + assertThat(output.filter { it < -30 }).isEmpty() + + VerifyTimeSeriesResult.AssertTimeSeriesMatchesGolden() + }, + ) { + animateValueTo(-100f, changePerFrame = 5f) + } + } + + @Test + fun overdrag_nonStandardBaseFunction() { + motion.goldenTest( + spatialMotionSpec(baseMapping = { -it }) { after(10f, Overdrag(maxOverdrag = 20.dp)) }, + capture = captureOverdragFeatures, + initialValue = 5f, + verifyTimeSeries = { + assertThat(output.filter { it < -30 }).isEmpty() + VerifyTimeSeriesResult.AssertTimeSeriesMatchesGolden() + }, + ) { + animateValueTo(100f, changePerFrame = 5f) + } + } + + @Test + fun semantics_exposesOverdragLimitWhileOverdragging() { + motion.goldenTest( + spatialMotionSpec { + before(-10f, Overdrag()) + after(10f, Overdrag()) + }, + capture = captureOverdragFeatures, + verifyTimeSeries = { + val isOverdragging = input.map { abs(it) >= 10 } + val hasOverdragLimit = nullableDataPoints("overdragLimit").map { it != null } + assertThat(hasOverdragLimit).isEqualTo(isOverdragging) + VerifyTimeSeriesResult.SkipGoldenVerification + }, + ) { + animateValueTo(20f, changePerFrame = 5f) + reset(0f, InputDirection.Min) + animateValueTo(-20f, changePerFrame = 5f) + } + } + + companion object { + val captureOverdragFeatures: CaptureTimeSeriesFn = { + defaultFeatureCaptures() + feature( + FeatureCaptures.semantics( + Overdrag.Defaults.OverdragLimit, + DataPointTypes.float, + "overdragLimit", + ) + ) + } + } +} diff --git a/mechanics/tests/src/com/android/mechanics/effects/RevealOnThresholdTest.kt b/mechanics/tests/src/com/android/mechanics/effects/RevealOnThresholdTest.kt new file mode 100644 index 0000000..c0b5eb7 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/effects/RevealOnThresholdTest.kt @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2025 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.effects + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.builder.MotionBuilderContext +import com.android.mechanics.spec.builder.spatialMotionSpec +import com.android.mechanics.testing.ComposeMotionValueToolkit +import com.android.mechanics.testing.FakeMotionSpecBuilderContext +import com.android.mechanics.testing.MotionSpecSubject.Companion.assertThat +import com.android.mechanics.testing.animateValueTo +import com.android.mechanics.testing.goldenTest +import com.google.common.truth.Truth.assertThat +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 RevealOnThresholdTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Default { + + private val goldenPathManager = + createGoldenPathManager("frameworks/libs/systemui/mechanics/tests/goldens") + + @get:Rule val motion = MotionTestRule(ComposeMotionValueToolkit, goldenPathManager) + + @Test + fun matchesSpec() { + val underTests = spatialMotionSpec(Mapping.Zero) { between(3f, 30f, RevealOnThreshold()) } + + val minSize = RevealOnThreshold.Defaults.MinSize.toPx() + + assertThat(3f + minSize).isLessThan(30f) + + assertThat(underTests) + .maxDirection() + .breakpoints() + .positions() + .containsExactly(3f, 3f + minSize, 30f) + + assertThat(underTests) + .minDirection() + .breakpoints() + .positions() + .containsExactly(3f, 3f + minSize, 30f) + } + + @Test + fun revealAnimation() { + motion.goldenTest( + spatialMotionSpec(Mapping.Zero) { between(3f, 30f, RevealOnThreshold()) } + ) { + animateValueTo(36f, changePerFrame = 3f) + awaitStable() + } + } + + @Test + fun revealAnimation_afterFixedValue() { + motion.goldenTest( + spatialMotionSpec(Mapping.Zero) { between(3f, 30f, RevealOnThreshold()) } + ) { + animateValueTo(36f, changePerFrame = 3f) + awaitStable() + } + } + + @Test + fun hideAnimation() { + motion.goldenTest( + spatialMotionSpec(Mapping.Zero) { between(3f, 30f, RevealOnThreshold()) }, + initialValue = 36f, + initialDirection = InputDirection.Min, + ) { + animateValueTo(0f, changePerFrame = 3f) + awaitStable() + } + } + + @Test + fun doNothingBeforeThreshold() { + motion.goldenTest( + spatialMotionSpec(Mapping.Zero) { between(3f, 30f, RevealOnThreshold()) } + ) { + animateValueTo(2f + RevealOnThreshold.Defaults.MinSize.toPx(), changePerFrame = 3f) + awaitStable() + } + } + + @Test + fun hideAnimationOnThreshold() { + motion.goldenTest( + spatialMotionSpec(Mapping.Zero) { between(3f, 30f, RevealOnThreshold()) }, + initialValue = 36f, + initialDirection = InputDirection.Min, + ) { + animateValueTo(3f + RevealOnThreshold.Defaults.MinSize.toPx(), changePerFrame = 3f) + awaitStable() + } + } +} diff --git a/mechanics/tests/src/com/android/mechanics/spec/DirectionalMotionSpecTest.kt b/mechanics/tests/src/com/android/mechanics/spec/DirectionalMotionSpecTest.kt new file mode 100644 index 0000000..30c8513 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/spec/DirectionalMotionSpecTest.kt @@ -0,0 +1,200 @@ +/* + * 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.spec + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.spec.builder.directionalMotionSpec +import com.android.mechanics.spring.SpringParameters +import com.google.common.truth.Truth.assertThat +import kotlin.math.nextDown +import kotlin.math.nextUp +import kotlin.test.assertFailsWith +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DirectionalMotionSpecTest { + + @Test + fun noBreakpoints_throws() { + assertFailsWith { + DirectionalMotionSpec(emptyList(), emptyList()) + } + } + + @Test + fun wrongSentinelBreakpoints_throws() { + val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None) + val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None) + + assertFailsWith { + DirectionalMotionSpec(listOf(breakpoint1, breakpoint2), listOf(Mapping.Identity)) + } + } + + @Test + fun tooFewMappings_throws() { + assertFailsWith { + DirectionalMotionSpec(listOf(Breakpoint.minLimit, Breakpoint.maxLimit), emptyList()) + } + } + + @Test + fun tooManyMappings_throws() { + assertFailsWith { + DirectionalMotionSpec( + listOf(Breakpoint.minLimit, Breakpoint.maxLimit), + listOf(Mapping.One, Mapping.Two), + ) + } + } + + @Test + fun breakpointsOutOfOrder_throws() { + val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None) + val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None) + assertFailsWith { + DirectionalMotionSpec( + listOf(Breakpoint.minLimit, breakpoint2, breakpoint1, Breakpoint.maxLimit), + listOf(Mapping.Zero, Mapping.One, Mapping.Two), + ) + } + } + + @Test + fun findBreakpointIndex_returnsMinForEmptySpec() { + val underTest = DirectionalMotionSpec.Empty + + assertThat(underTest.findBreakpointIndex(0f)).isEqualTo(0) + assertThat(underTest.findBreakpointIndex(Float.MAX_VALUE)).isEqualTo(0) + assertThat(underTest.findBreakpointIndex(-Float.MAX_VALUE)).isEqualTo(0) + } + + @Test + fun findBreakpointIndex_throwsForNonFiniteInput() { + val underTest = DirectionalMotionSpec.Empty + + assertFailsWith { underTest.findBreakpointIndex(Float.NaN) } + assertFailsWith { + underTest.findBreakpointIndex(Float.NEGATIVE_INFINITY) + } + assertFailsWith { + underTest.findBreakpointIndex(Float.POSITIVE_INFINITY) + } + } + + @Test + fun findBreakpointIndex_atBreakpoint_returnsIndex() { + val underTest = + directionalMotionSpec(Spring) { mapping(breakpoint = 10f, mapping = Mapping.Identity) } + + assertThat(underTest.findBreakpointIndex(10f)).isEqualTo(1) + } + + @Test + fun findBreakpointIndex_afterBreakpoint_returnsPreviousIndex() { + val underTest = + directionalMotionSpec(Spring) { mapping(breakpoint = 10f, mapping = Mapping.Identity) } + + assertThat(underTest.findBreakpointIndex(10f.nextUp())).isEqualTo(1) + } + + @Test + fun findBreakpointIndex_beforeBreakpoint_returnsIndex() { + val underTest = + directionalMotionSpec(Spring) { mapping(breakpoint = 10f, mapping = Mapping.Identity) } + + assertThat(underTest.findBreakpointIndex(10f.nextDown())).isEqualTo(0) + } + + @Test + fun findBreakpointIndexByKey_returnsIndex() { + val underTest = + directionalMotionSpec(Spring) { + mapping(breakpoint = 10f, key = B1, mapping = Mapping.Identity) + } + + assertThat(underTest.findBreakpointIndex(B1)).isEqualTo(1) + } + + @Test + fun findBreakpointIndexByKey_unknown_returnsMinusOne() { + val underTest = + directionalMotionSpec(Spring) { + mapping(breakpoint = 10f, key = B1, mapping = Mapping.Identity) + } + + assertThat(underTest.findBreakpointIndex(B2)).isEqualTo(-1) + } + + @Test + fun findSegmentIndex_returnsIndexForSegment_ignoringDirection() { + val underTest = + directionalMotionSpec(Spring) { + mapping(breakpoint = 10f, key = B1, mapping = Mapping.One) + mapping(breakpoint = 20f, key = B2, mapping = Mapping.Identity) + } + + assertThat(underTest.findSegmentIndex(SegmentKey(B1, B2, InputDirection.Max))).isEqualTo(1) + assertThat(underTest.findSegmentIndex(SegmentKey(B1, B2, InputDirection.Min))).isEqualTo(1) + } + + @Test + fun findSegmentIndex_forInvalidKeys_returnsMinusOne() { + val underTest = + directionalMotionSpec(Spring) { + mapping(breakpoint = 10f, key = B1, mapping = Mapping.One) + mapping(breakpoint = 20f, key = B2, mapping = Mapping.One) + mapping(breakpoint = 30f, key = B3, mapping = Mapping.Identity) + } + + assertThat(underTest.findSegmentIndex(SegmentKey(B2, B1, InputDirection.Max))).isEqualTo(-1) + assertThat(underTest.findSegmentIndex(SegmentKey(B1, B3, InputDirection.Max))).isEqualTo(-1) + } + + @Test + fun semantics_tooFewValues_throws() { + assertFailsWith { + DirectionalMotionSpec( + listOf(Breakpoint.minLimit, Breakpoint.maxLimit), + listOf(Mapping.Identity), + listOf(SegmentSemanticValues(Semantic1, emptyList())), + ) + } + } + + @Test + fun semantics_tooManyValues_throws() { + assertFailsWith { + DirectionalMotionSpec( + listOf(Breakpoint.minLimit, Breakpoint.maxLimit), + listOf(Mapping.Identity), + listOf(SegmentSemanticValues(Semantic1, listOf("One", "Two"))), + ) + } + } + + companion object { + val B1 = BreakpointKey("one") + val B2 = BreakpointKey("two") + val B3 = BreakpointKey("three") + val Semantic1 = SemanticKey("Foo") + val Semantic2 = SemanticKey("Bar") + + val Spring = SpringParameters(stiffness = 100f, dampingRatio = 1f) + } +} diff --git a/mechanics/tests/src/com/android/mechanics/spec/MotionSpecDebugFormatterTest.kt b/mechanics/tests/src/com/android/mechanics/spec/MotionSpecDebugFormatterTest.kt new file mode 100644 index 0000000..1777a72 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/spec/MotionSpecDebugFormatterTest.kt @@ -0,0 +1,145 @@ +/* + * Copyright (C) 2025 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.spec + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.spec.ChangeSegmentHandlers.PreventDirectionChangeWithinCurrentSegment +import com.android.mechanics.spec.builder.MotionBuilderContext +import com.android.mechanics.spec.builder.effectsDirectionalMotionSpec +import com.android.mechanics.spec.builder.spatialDirectionalMotionSpec +import com.android.mechanics.testing.FakeMotionSpecBuilderContext +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MotionSpecDebugFormatterTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Default { + + @Test + fun motionSpec_unidirectionalSpec_formatIsUseful() { + val spec = MotionSpec(effectsDirectionalMotionSpec { fixedValue(0f, value = 1f) }) + + assertThat(formatForTest(spec.toDebugString())) + .isEqualTo( + """ +unidirectional: + @-Infinity [built-in::min|id:0x1234cdef] + Fixed(value=0.0) + @0.0 [id:0x1234cdef] spring=1600.0/1.0 + Fixed(value=1.0) + @Infinity [built-in::max|id:0x1234cdef]""" + .trimIndent() + ) + } + + @Test + fun motionSpec_bidirectionalSpec_formatIsUseful() { + val spec = + MotionSpec( + spatialDirectionalMotionSpec(Mapping.Zero) { fixedValue(0f, value = 1f) }, + spatialDirectionalMotionSpec(Mapping.One) { fixedValue(0f, value = 0f) }, + ) + + assertThat(formatForTest(spec.toDebugString())) + .isEqualTo( + """ +maxDirection: + @-Infinity [built-in::min|id:0x1234cdef] + Fixed(value=0.0) + @0.0 [id:0x1234cdef] spring=700.0/0.9 + Fixed(value=1.0) + @Infinity [built-in::max|id:0x1234cdef] +minDirection: + @-Infinity [built-in::min|id:0x1234cdef] + Fixed(value=1.0) + @0.0 [id:0x1234cdef] spring=700.0/0.9 + Fixed(value=0.0) + @Infinity [built-in::max|id:0x1234cdef]""" + .trimIndent() + ) + } + + @Test + fun motionSpec_semantics_formatIsUseful() { + val semanticKey = SemanticKey("foo") + + val spec = + MotionSpec( + effectsDirectionalMotionSpec(semantics = listOf(semanticKey with 42f)) { + fixedValue(0f, value = 1f, semantics = listOf(semanticKey with 43f)) + } + ) + + assertThat(formatForTest(spec.toDebugString())) + .isEqualTo( + """ +unidirectional: + @-Infinity [built-in::min|id:0x1234cdef] + Fixed(value=0.0) + foo[id:0x1234cdef]=42.0 + @0.0 [id:0x1234cdef] spring=1600.0/1.0 + Fixed(value=1.0) + foo[id:0x1234cdef]=43.0 + @Infinity [built-in::max|id:0x1234cdef]""" + .trimIndent() + ) + } + + @Test + fun motionSpec_segmentHandlers_formatIsUseful() { + val key1 = BreakpointKey("1") + val key2 = BreakpointKey("2") + val spec = + MotionSpec( + effectsDirectionalMotionSpec { + fixedValue(0f, value = 1f, key = key1) + fixedValue(2f, value = 2f, key = key1) + }, + segmentHandlers = + mapOf( + SegmentKey(key1, key2, InputDirection.Max) to + PreventDirectionChangeWithinCurrentSegment, + SegmentKey(key1, key2, InputDirection.Min) to + PreventDirectionChangeWithinCurrentSegment, + ), + ) + + assertThat(formatForTest(spec.toDebugString())) + .isEqualTo( + """ +unidirectional: + @-Infinity [built-in::min|id:0x1234cdef] + Fixed(value=0.0) + @0.0 [1|id:0x1234cdef] spring=1600.0/1.0 + Fixed(value=1.0) + @2.0 [1|id:0x1234cdef] spring=1600.0/1.0 + Fixed(value=2.0) + @Infinity [built-in::max|id:0x1234cdef] +segmentHandlers: + 1|id:0x1234cdef >> 2|id:0x1234cdef + 1|id:0x1234cdef << 2|id:0x1234cdef""" + .trimIndent() + ) + } + + companion object { + private val idMatcher = Regex("id:0x[0-9a-f]{8}") + + fun formatForTest(debugString: String) = + debugString.replace(idMatcher, "id:0x1234cdef").trim() + } +} diff --git a/mechanics/tests/src/com/android/mechanics/spec/MotionSpecTest.kt b/mechanics/tests/src/com/android/mechanics/spec/MotionSpecTest.kt new file mode 100644 index 0000000..260a8a7 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/spec/MotionSpecTest.kt @@ -0,0 +1,320 @@ +/* + * 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.spec + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.spec.builder.directionalMotionSpec +import com.android.mechanics.spring.SpringParameters +import com.android.mechanics.testing.BreakpointSubject.Companion.assertThat +import com.google.common.truth.Truth.assertThat +import kotlin.test.assertFailsWith +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MotionSpecTest { + + @Test + fun containsSegment_unknownSegment_returnsFalse() { + val underTest = MotionSpec.Empty + assertThat(underTest.containsSegment(SegmentKey(B1, B2, InputDirection.Max))).isFalse() + } + + @Test + fun containsSegment_symmetricSpec_knownSegment_returnsTrue() { + val underTest = + MotionSpec( + directionalMotionSpec(Spring) { + fixedValue(breakpoint = 10f, key = B1, value = 1f) + identity(breakpoint = 20f, key = B2) + } + ) + + assertThat(underTest.containsSegment(SegmentKey(B1, B2, InputDirection.Max))).isTrue() + assertThat(underTest.containsSegment(SegmentKey(B1, B2, InputDirection.Min))).isTrue() + } + + @Test + fun containsSegment_asymmetricSpec_knownMaxDirectionSegment_trueOnlyInMaxDirection() { + val underTest = + MotionSpec( + maxDirection = + directionalMotionSpec(Spring) { + fixedValue(breakpoint = 10f, key = B1, value = 1f) + identity(breakpoint = 20f, key = B2) + }, + minDirection = DirectionalMotionSpec.Empty, + ) + + assertThat(underTest.containsSegment(SegmentKey(B1, B2, InputDirection.Max))).isTrue() + assertThat(underTest.containsSegment(SegmentKey(B1, B2, InputDirection.Min))).isFalse() + } + + @Test + fun containsSegment_asymmetricSpec_knownMinDirectionSegment_trueOnlyInMinDirection() { + val underTest = + MotionSpec( + maxDirection = DirectionalMotionSpec.Empty, + minDirection = + directionalMotionSpec(Spring) { + fixedValue(breakpoint = 10f, key = B1, value = 1f) + identity(breakpoint = 20f, key = B2) + }, + ) + + assertThat(underTest.containsSegment(SegmentKey(B1, B2, InputDirection.Max))).isFalse() + assertThat(underTest.containsSegment(SegmentKey(B1, B2, InputDirection.Min))).isTrue() + } + + @Test + fun segmentAtInput_emptySpec_maxDirection_segmentDataIsCorrect() { + val underTest = MotionSpec.Empty + + val segmentAtInput = underTest.segmentAtInput(0f, InputDirection.Max) + + assertThat(segmentAtInput.spec).isSameInstanceAs(underTest) + assertThat(segmentAtInput.minBreakpoint).isSameInstanceAs(Breakpoint.minLimit) + assertThat(segmentAtInput.maxBreakpoint).isSameInstanceAs(Breakpoint.maxLimit) + assertThat(segmentAtInput.direction).isEqualTo(InputDirection.Max) + assertThat(segmentAtInput.mapping).isEqualTo(Mapping.Identity) + } + + @Test + fun segmentAtInput_emptySpec_minDirection_segmentDataIsCorrect() { + val underTest = MotionSpec.Empty + + val segmentAtInput = underTest.segmentAtInput(0f, InputDirection.Min) + + assertThat(segmentAtInput.spec).isSameInstanceAs(underTest) + assertThat(segmentAtInput.minBreakpoint).isSameInstanceAs(Breakpoint.minLimit) + assertThat(segmentAtInput.maxBreakpoint).isSameInstanceAs(Breakpoint.maxLimit) + assertThat(segmentAtInput.direction).isEqualTo(InputDirection.Min) + assertThat(segmentAtInput.mapping).isEqualTo(Mapping.Identity) + } + + @Test + fun segmentAtInput_atBreakpointPosition() { + val underTest = + MotionSpec( + directionalMotionSpec(Spring) { + fixedValue(breakpoint = 10f, key = B1, value = 1f) + identity(breakpoint = 20f, key = B2) + } + ) + + val segmentAtInput = underTest.segmentAtInput(10f, InputDirection.Max) + + assertThat(segmentAtInput.key).isEqualTo(SegmentKey(B1, B2, InputDirection.Max)) + assertThat(segmentAtInput.minBreakpoint).isAt(10f) + assertThat(segmentAtInput.maxBreakpoint).isAt(20f) + assertThat(segmentAtInput.mapping).isEqualTo(Mapping.One) + } + + @Test + fun segmentAtInput_reverse_atBreakpointPosition() { + val underTest = + MotionSpec( + directionalMotionSpec(Spring) { + fixedValue(breakpoint = 10f, key = B1, value = 1f) + identity(breakpoint = 20f, key = B2) + } + ) + + val segmentAtInput = underTest.segmentAtInput(20f, InputDirection.Min) + + assertThat(segmentAtInput.key).isEqualTo(SegmentKey(B1, B2, InputDirection.Min)) + assertThat(segmentAtInput.minBreakpoint).isAt(10f) + assertThat(segmentAtInput.maxBreakpoint).isAt(20f) + assertThat(segmentAtInput.mapping).isEqualTo(Mapping.One) + } + + @Test + fun containsSegment_asymmetricSpec_readsFromIndicatedDirection() { + val underTest = + MotionSpec( + maxDirection = + directionalMotionSpec(Spring) { + fixedValue(breakpoint = 10f, key = B1, value = 1f) + identity(breakpoint = 20f, key = B2) + }, + minDirection = + directionalMotionSpec(Spring) { + fixedValue(breakpoint = 5f, key = B1, value = 2f) + identity(breakpoint = 25f, key = B2) + }, + ) + + val segmentAtInputMax = underTest.segmentAtInput(15f, InputDirection.Max) + assertThat(segmentAtInputMax.key).isEqualTo(SegmentKey(B1, B2, InputDirection.Max)) + assertThat(segmentAtInputMax.minBreakpoint).isAt(10f) + assertThat(segmentAtInputMax.maxBreakpoint).isAt(20f) + assertThat(segmentAtInputMax.mapping).isEqualTo(Mapping.One) + + val segmentAtInputMin = underTest.segmentAtInput(15f, InputDirection.Min) + assertThat(segmentAtInputMin.key).isEqualTo(SegmentKey(B1, B2, InputDirection.Min)) + assertThat(segmentAtInputMin.minBreakpoint).isAt(5f) + assertThat(segmentAtInputMin.maxBreakpoint).isAt(25f) + assertThat(segmentAtInputMin.mapping).isEqualTo(Mapping.Two) + } + + @Test + fun onSegmentChanged_noHandler_returnsEqualSegmentForSameInput() { + val underTest = + MotionSpec( + directionalMotionSpec(Spring) { + fixedValue(breakpoint = 10f, key = B1, value = 1f) + identity(breakpoint = 20f, key = B2) + } + ) + + val segmentAtInput = underTest.segmentAtInput(15f, InputDirection.Max) + val onChangedResult = underTest.onChangeSegment(segmentAtInput, 15f, InputDirection.Max) + assertThat(segmentAtInput).isEqualTo(onChangedResult) + } + + @Test + fun onSegmentChanged_noHandler_returnsNewSegmentForNewInput() { + val underTest = + MotionSpec( + directionalMotionSpec(Spring) { + fixedValue(breakpoint = 10f, key = B1, value = 1f) + identity(breakpoint = 20f, key = B2) + } + ) + + val segmentAtInput = underTest.segmentAtInput(15f, InputDirection.Max) + val onChangedResult = underTest.onChangeSegment(segmentAtInput, 15f, InputDirection.Min) + assertThat(segmentAtInput).isNotEqualTo(onChangedResult) + + assertThat(onChangedResult.key).isEqualTo(SegmentKey(B1, B2, InputDirection.Min)) + } + + @Test + fun onSegmentChanged_withHandlerReturningNull_returnsSegmentAtInput() { + val underTest = + MotionSpec( + directionalMotionSpec(Spring) { + fixedValue(breakpoint = 10f, key = B1, value = 1f) + identity(breakpoint = 20f, key = B2) + } + ) + .copy( + segmentHandlers = + mapOf(SegmentKey(B1, B2, InputDirection.Max) to { _, _, _ -> null }) + ) + + val segmentAtInput = underTest.segmentAtInput(15f, InputDirection.Max) + val onChangedResult = underTest.onChangeSegment(segmentAtInput, 15f, InputDirection.Min) + + assertThat(segmentAtInput).isNotEqualTo(onChangedResult) + assertThat(onChangedResult.key).isEqualTo(SegmentKey(B1, B2, InputDirection.Min)) + } + + @Test + fun onSegmentChanged_withHandlerReturningSegment_returnsHandlerResult() { + val underTest = + MotionSpec( + directionalMotionSpec(Spring) { + fixedValue(breakpoint = 10f, key = B1, value = 1f) + identity(breakpoint = 20f, key = B2) + } + ) + .copy( + segmentHandlers = + mapOf( + SegmentKey(B1, B2, InputDirection.Max) to + { _, _, _ -> + segmentAtInput(0f, InputDirection.Min) + } + ) + ) + + val segmentAtInput = underTest.segmentAtInput(15f, InputDirection.Max) + val onChangedResult = underTest.onChangeSegment(segmentAtInput, 15f, InputDirection.Min) + + assertThat(segmentAtInput).isNotEqualTo(onChangedResult) + assertThat(onChangedResult.key) + .isEqualTo(SegmentKey(Breakpoint.minLimit.key, B1, InputDirection.Min)) + } + + @Test + fun semanticState_returnsStateFromSegment() { + val underTest = + MotionSpec( + maxDirection = directionalMotionSpec(semantics = listOf(S1 with "One")), + minDirection = directionalMotionSpec(semantics = listOf(S1 with "Two")), + ) + + val maxDirectionSegment = SegmentKey(BMin, BMax, InputDirection.Max) + assertThat(underTest.semanticState(S1, maxDirectionSegment)).isEqualTo("One") + + val minDirectionSegment = SegmentKey(BMin, BMax, InputDirection.Min) + assertThat(underTest.semanticState(S1, minDirectionSegment)).isEqualTo("Two") + } + + @Test + fun semanticState_unknownSegment_throws() { + val underTest = MotionSpec(directionalMotionSpec(semantics = listOf(S1 with "One"))) + + val unknownSegment = SegmentKey(BMin, B1, InputDirection.Max) + assertFailsWith { underTest.semanticState(S1, unknownSegment) } + } + + @Test + fun semanticState_unknownSemantics_returnsNull() { + val underTest = MotionSpec(directionalMotionSpec(semantics = listOf(S1 with "One"))) + + val maxDirectionSegment = SegmentKey(BMin, BMax, InputDirection.Max) + assertThat(underTest.semanticState(S2, maxDirectionSegment)).isNull() + } + + @Test + fun semantics_returnsAllValuesForSegment() { + val underTest = + MotionSpec( + directionalMotionSpec(Spring, semantics = listOf(S1 with "One", S2 with "AAA")) { + identity(breakpoint = 0f, key = B1, semantics = listOf(S2 with "BBB")) + identity(breakpoint = 2f, key = B2, semantics = listOf(S1 with "Two")) + } + ) + + assertThat(underTest.semantics(SegmentKey(BMin, B1, InputDirection.Max))) + .containsExactly(S1 with "One", S2 with "AAA") + assertThat(underTest.semantics(SegmentKey(B1, B2, InputDirection.Max))) + .containsExactly(S1 with "One", S2 with "BBB") + assertThat(underTest.semantics(SegmentKey(B2, BMax, InputDirection.Max))) + .containsExactly(S1 with "Two", S2 with "BBB") + } + + @Test + fun semantics_unknownSegment_throws() { + val underTest = MotionSpec.Empty + val unknownSegment = SegmentKey(BMin, B1, InputDirection.Max) + assertFailsWith { underTest.semantics(unknownSegment) } + } + + companion object { + val BMin = Breakpoint.minLimit.key + val B1 = BreakpointKey("one") + val B2 = BreakpointKey("two") + val BMax = Breakpoint.maxLimit.key + val S1 = SemanticKey("Foo") + val S2 = SemanticKey("Bar") + + val Spring = SpringParameters(stiffness = 100f, dampingRatio = 1f) + } +} diff --git a/mechanics/tests/src/com/android/mechanics/spec/SegmentTest.kt b/mechanics/tests/src/com/android/mechanics/spec/SegmentTest.kt new file mode 100644 index 0000000..f66991c --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/spec/SegmentTest.kt @@ -0,0 +1,106 @@ +/* + * 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.spec + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.spring.SpringParameters +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SegmentTest { + + private val fakeSpec = MotionSpec.Empty + + @Test + fun segmentData_isValidForInput_betweenBreakpointsSameDirection_isTrue() { + val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None) + val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None) + val underTest = + SegmentData(fakeSpec, breakpoint1, breakpoint2, InputDirection.Max, Mapping.Identity) + + assertThat(underTest.isValidForInput(15f, InputDirection.Max)).isTrue() + } + + @Test + fun segmentData_isValidForInput_betweenBreakpointsOppositeDirection_isFalse() { + val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None) + val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None) + val underTest = + SegmentData(fakeSpec, breakpoint1, breakpoint2, InputDirection.Max, Mapping.Identity) + + assertThat(underTest.isValidForInput(15f, InputDirection.Min)).isFalse() + } + + @Test + fun segmentData_isValidForInput_inMaxDirection_sampledAtVariousPositions_matchesExpectation() { + val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None) + val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None) + val underTest = + SegmentData(fakeSpec, breakpoint1, breakpoint2, InputDirection.Max, Mapping.Identity) + + for ((samplePosition, expectedResult) in + listOf(5f to true, 10f to true, 15f to true, 20f to false, 25f to false)) { + assertWithMessage("at $samplePosition") + .that(underTest.isValidForInput(samplePosition, InputDirection.Max)) + .isEqualTo(expectedResult) + } + } + + @Test + fun segmentData_isValidForInput_inMinDirection_sampledAtVariousPositions_matchesExpectation() { + val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None) + val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None) + val underTest = + SegmentData(fakeSpec, breakpoint1, breakpoint2, InputDirection.Min, Mapping.Identity) + + for ((samplePosition, expectedResult) in + listOf(5f to false, 10f to false, 15f to true, 20f to true, 25f to true)) { + assertWithMessage("at $samplePosition") + .that(underTest.isValidForInput(samplePosition, InputDirection.Min)) + .isEqualTo(expectedResult) + } + } + + @Test + fun segmentData_entryBreakpoint_maxDirection_returnsMinBreakpoint() { + val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None) + val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None) + val underTest = + SegmentData(fakeSpec, breakpoint1, breakpoint2, InputDirection.Max, Mapping.Identity) + + assertThat(underTest.entryBreakpoint).isSameInstanceAs(breakpoint1) + } + + @Test + fun segmentData_entryBreakpoint_minDirection_returnsMaxBreakpoint() { + val breakpoint1 = Breakpoint(B1, position = 10f, Spring, Guarantee.None) + val breakpoint2 = Breakpoint(B2, position = 20f, Spring, Guarantee.None) + val underTest = + SegmentData(fakeSpec, breakpoint1, breakpoint2, InputDirection.Min, Mapping.Identity) + + assertThat(underTest.entryBreakpoint).isSameInstanceAs(breakpoint2) + } + + companion object { + val B1 = BreakpointKey("one") + val B2 = BreakpointKey("two") + val Spring = SpringParameters(stiffness = 100f, dampingRatio = 1f) + } +} diff --git a/mechanics/tests/src/com/android/mechanics/spec/builder/DirectionalBuilderImplTest.kt b/mechanics/tests/src/com/android/mechanics/spec/builder/DirectionalBuilderImplTest.kt new file mode 100644 index 0000000..b399731 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/spec/builder/DirectionalBuilderImplTest.kt @@ -0,0 +1,295 @@ +/* + * Copyright (C) 2025 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.spec.builder + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.spec.BreakpointKey +import com.android.mechanics.spec.Guarantee +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.SemanticKey +import com.android.mechanics.spec.with +import com.android.mechanics.spring.SpringParameters +import com.android.mechanics.testing.DirectionalMotionSpecSubject.Companion.assertThat +import com.android.mechanics.testing.FakeMotionSpecBuilderContext +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DirectionalBuilderImplTest { + + @Test + fun directionalSpec_buildEmptySpec() { + val result = directionalMotionSpec() + + assertThat(result).breakpoints().isEmpty() + assertThat(result).mappings().containsExactly(Mapping.Identity) + } + + @Test + fun directionalSpec_addBreakpointsAndMappings() { + val result = + directionalMotionSpec(Spring, Mapping.Zero) { + mapping(breakpoint = 0f, mapping = Mapping.One, key = B1) + mapping(breakpoint = 10f, mapping = Mapping.Two, key = B2) + } + + assertThat(result).breakpoints().keys().containsExactly(B1, B2).inOrder() + assertThat(result).breakpoints().withKey(B1).isAt(0f) + assertThat(result).breakpoints().withKey(B2).isAt(10f) + assertThat(result) + .mappings() + .containsExactly(Mapping.Zero, Mapping.One, Mapping.Two) + .inOrder() + } + + @Test + fun directionalSpec_mappingBuilder_setsDefaultSpring() { + val result = directionalMotionSpec(Spring) { fixedValue(breakpoint = 10f, value = 20f) } + + assertThat(result).breakpoints().atPosition(10f).spring().isEqualTo(Spring) + } + + @Test + fun directionalSpec_mappingBuilder_canOverrideDefaultSpring() { + val otherSpring = SpringParameters(stiffness = 10f, dampingRatio = 0.1f) + val result = + directionalMotionSpec(Spring) { + fixedValue(breakpoint = 10f, value = 20f, spring = otherSpring) + } + + assertThat(result).breakpoints().atPosition(10f).spring().isEqualTo(otherSpring) + } + + @Test + fun directionalSpec_mappingBuilder_defaultsToNoGuarantee() { + val result = directionalMotionSpec(Spring) { fixedValue(breakpoint = 10f, value = 20f) } + + assertThat(result).breakpoints().atPosition(10f).guarantee().isEqualTo(Guarantee.None) + } + + @Test + fun directionalSpec_mappingBuilder_canSetGuarantee() { + val guarantee = Guarantee.InputDelta(10f) + val result = + directionalMotionSpec(Spring) { + fixedValue(breakpoint = 10f, value = 20f, guarantee = guarantee) + } + + assertThat(result).breakpoints().atPosition(10f).guarantee().isEqualTo(guarantee) + } + + @Test + fun directionalSpec_mappingBuilder_jumpTo_setsAbsoluteValue() { + val result = + directionalMotionSpec(Spring, Mapping.Fixed(99f)) { + fixedValue(breakpoint = 10f, value = 20f) + } + + assertThat(result).breakpoints().positions().containsExactly(10f) + assertThat(result).mappings().atOrAfter(10f).isFixedValue(20f) + } + + @Test + fun directionalSpec_mappingBuilder_jumpBy_setsRelativeValue() { + val result = + directionalMotionSpec(Spring, Mapping.Linear(factor = 0.5f)) { + // At 10f the current value is 5f (10f * 0.5f) + fixedValueFromCurrent(breakpoint = 10f, delta = 30f) + } + + assertThat(result).breakpoints().positions().containsExactly(10f) + assertThat(result).mappings().atOrAfter(10f).isFixedValue(35f) + } + + @Test + fun directionalSpec_mappingBuilder_continueWithFixedValue_usesSourceValue() { + val result = + directionalMotionSpec(Spring, Mapping.Linear(factor = 0.5f)) { + // At 5f the current value is 2.5f (5f * 0.5f) + fixedValueFromCurrent(breakpoint = 5f) + } + + assertThat(result).mappings().atOrAfter(5f).isFixedValue(2.5f) + } + + @Test + fun directionalSpec_mappingBuilder_continueWithFractionalInput_matchesLinearMapping() { + val result = + directionalMotionSpec(Spring) { + fractionalInput(breakpoint = 5f, from = 1f, fraction = .1f) + } + + assertThat(result) + .mappings() + .atOrAfter(5f) + .matchesLinearMapping(in1 = 5f, out1 = 1f, in2 = 15f, out2 = 2f) + } + + @Test + fun directionalSpec_mappingBuilder_continueWithTargetValue_matchesLinearMapping() { + val result = + directionalMotionSpec(Spring) { + target(breakpoint = 5f, from = 1f, to = 20f) + mapping(breakpoint = 30f, mapping = Mapping.Identity) + } + + assertThat(result) + .mappings() + .atOrAfter(5f) + .matchesLinearMapping(in1 = 5f, out1 = 1f, in2 = 30f, out2 = 20f) + } + + @Test + fun directionalSpec_mappingBuilder_breakpointsAtSamePosition_producesValidSegment() { + val result = + directionalMotionSpec(Spring) { + target(breakpoint = 5f, from = 1f, to = 20f) + mapping(breakpoint = 5f, mapping = Mapping.Identity) + } + assertThat(result) + .mappings() + .containsExactly(Mapping.Identity, Mapping.Fixed(1f), Mapping.Identity) + .inOrder() + } + + @Test + fun directionalSpec_mappingBuilder_identity_addsIdentityMapping() { + val result = directionalMotionSpec(Spring, Mapping.Zero) { identity(breakpoint = 10f) } + assertThat(result).mappings().containsExactly(Mapping.Zero, Mapping.Identity).inOrder() + assertThat(result).breakpoints().positions().containsExactly(10f) + } + + @Test + fun directionalSpec_mappingBuilder_identityWithDelta_producesLinearMapping() { + val result = + directionalMotionSpec(Spring, Mapping.Zero) { identity(breakpoint = 10f, delta = 2f) } + + assertThat(result) + .mappings() + .atOrAfter(10f) + .matchesLinearMapping(in1 = 10f, out1 = 12f, in2 = 20f, out2 = 22f) + } + + @Test + fun semantics_appliedForSingleSegment() { + val result = directionalMotionSpec(Mapping.Identity, listOf(S1 with "One", S2 with "Two")) + + assertThat(result).semantics().containsExactly(S1, S2) + assertThat(result).semantics().withKey(S1).containsExactly("One") + assertThat(result).semantics().withKey(S2).containsExactly("Two") + } + + @Test + fun directionalSpec_semantics_appliedForAllSegments() { + val result = + directionalMotionSpec(Spring, semantics = listOf(S1 with "One")) { + mapping(breakpoint = 0f, mapping = Mapping.Identity) + } + assertThat(result).mappings().hasSize(2) + assertThat(result).semantics().containsExactly(S1) + assertThat(result).semantics().withKey(S1).containsExactly("One", "One") + } + + @Test + fun directionalSpec_semantics_appliedForCurrentSegment() { + val result = + directionalMotionSpec(Spring, semantics = listOf(S1 with "One")) { + mapping(breakpoint = 0f, mapping = Mapping.Identity) + mapping( + breakpoint = 2f, + mapping = Mapping.Identity, + semantics = listOf(S1 with "Two"), + ) + } + assertThat(result).mappings().hasSize(3) + assertThat(result).semantics().withKey(S1).containsExactly("One", "One", "Two").inOrder() + } + + @Test + fun directionalSpec_semantics_changingUndeclaredSemantics_backfills() { + val result = + directionalMotionSpec(Spring) { + mapping( + breakpoint = 0f, + mapping = Mapping.Identity, + semantics = listOf(S1 with "Two"), + ) + } + + assertThat(result).mappings().hasSize(2) + assertThat(result).semantics().withKey(S1).containsExactly("Two", "Two").inOrder() + } + + @Test + fun directionalSpec_semantics_changeableIndividually() { + val result = + directionalMotionSpec(Spring, semantics = listOf(S1 with "One", S2 with "AAA")) { + mapping( + breakpoint = 0f, + mapping = Mapping.Identity, + semantics = listOf(S2 with "BBB"), + ) + mapping( + breakpoint = 2f, + mapping = Mapping.Identity, + semantics = listOf(S1 with "Two"), + ) + } + assertThat(result).mappings().hasSize(3) + assertThat(result).semantics().withKey(S1).containsExactly("One", "One", "Two").inOrder() + assertThat(result).semantics().withKey(S2).containsExactly("AAA", "BBB", "BBB").inOrder() + } + + @Test + fun directionalSpec_semantics_lateCompletedSegmentsRetainSemantics() { + val result = + directionalMotionSpec(Spring, semantics = listOf(S1 with "One")) { + targetFromCurrent(breakpoint = 0f, to = 10f, semantics = listOf(S1 with "Two")) + identity(breakpoint = 1f, semantics = listOf(S1 with "Three")) + } + assertThat(result).mappings().hasSize(3) + assertThat(result).semantics().withKey(S1).containsExactly("One", "Two", "Three").inOrder() + } + + @Test + fun builderContext_spatialDirectionalMotionSpec_defaultsToSpatialSpringAndIdentityMapping() { + val context = FakeMotionSpecBuilderContext.Default + + val result = with(context) { spatialDirectionalMotionSpec { fixedValue(0f, value = 1f) } } + + assertThat(result).mappings().containsExactly(Mapping.Identity, Mapping.One).inOrder() + assertThat(result).breakpoints().atPosition(0f).spring().isEqualTo(context.spatial.default) + } + + @Test + fun builderContext_effectsDirectionalMotionSpec_defaultsToEffectsSpringAndZeroMapping() { + val context = FakeMotionSpecBuilderContext.Default + + val result = with(context) { effectsDirectionalMotionSpec { fixedValue(0f, value = 1f) } } + + assertThat(result).mappings().containsExactly(Mapping.Zero, Mapping.One).inOrder() + assertThat(result).breakpoints().atPosition(0f).spring().isEqualTo(context.effects.default) + } + + companion object { + val Spring = SpringParameters(stiffness = 100f, dampingRatio = 1f) + val B1 = BreakpointKey("One") + val B2 = BreakpointKey("Two") + val S1 = SemanticKey("Foo") + val S2 = SemanticKey("Bar") + } +} diff --git a/mechanics/tests/src/com/android/mechanics/spec/builder/MotionSpecBuilderTest.kt b/mechanics/tests/src/com/android/mechanics/spec/builder/MotionSpecBuilderTest.kt new file mode 100644 index 0000000..2b6760a --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/spec/builder/MotionSpecBuilderTest.kt @@ -0,0 +1,740 @@ +/* + * Copyright (C) 2025 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.spec.builder + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.effects.FixedValue +import com.android.mechanics.spec.BreakpointKey +import com.android.mechanics.spec.Guarantee +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.SemanticKey +import com.android.mechanics.spec.SemanticValue +import com.android.mechanics.spec.with +import com.android.mechanics.spring.SpringParameters +import com.android.mechanics.testing.FakeMotionSpecBuilderContext +import com.android.mechanics.testing.MotionSpecSubject.Companion.assertThat +import com.google.common.truth.Truth.assertThat +import kotlin.test.assertFailsWith +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MotionSpecBuilderTest : MotionBuilderContext by FakeMotionSpecBuilderContext.Default { + + // placement & ordering + // placement types + // placement issues + // before & after mapping, springs etc + + @Test + fun motionSpec_empty_usesBaseMapping() { + val result = spatialMotionSpec {} + + assertThat(result).bothDirections().mappingsMatch(Mapping.Identity) + assertThat(result).bothDirections().breakpoints().isEmpty() + } + + @Test + fun placement_absoluteAfter_createsTwoSegments() { + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + after(42f, FixedValue(1f)) + } + + assertThat(result).bothDirections().mappingsMatch(Mapping.Zero, Mapping.One) + assertThat(result).bothDirections().breakpointsPositionsMatch(42f) + } + + @Test + fun placement_absoluteBefore_createsTwoSegments() { + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + before(42f, FixedValue(1f)) + } + + assertThat(result).bothDirections().mappingsMatch(Mapping.One, Mapping.Zero) + assertThat(result).bothDirections().breakpointsPositionsMatch(42f) + } + + @Test + fun placement_absoluteBetween_createsThreeSegments() { + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(42f, 43f, FixedValue(1f)) + } + + assertThat(result).bothDirections().mappingsMatch(Mapping.Zero, Mapping.One, Mapping.Zero) + assertThat(result).bothDirections().breakpointsPositionsMatch(42f, 43f) + } + + @Test + fun placement_absoluteBetweenReverse_createsThreeSegments() { + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(43f, 42f, FixedValue(1f)) + } + + assertThat(result).bothDirections().mappingsMatch(Mapping.Zero, Mapping.One, Mapping.Zero) + assertThat(result).bothDirections().breakpointsPositionsMatch(42f, 43f) + } + + @Test + fun placement_adjacent_sharesBreakpoint() { + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, FixedValue(1f)) + between(2f, 3f, FixedValue(2f)) + } + + assertThat(result).bothDirections().fixedMappingsMatch(0f, 1f, 2f, 0f) + assertThat(result).bothDirections().breakpointsPositionsMatch(1f, 2f, 3f) + } + + @Test + fun placement_multiple_baseMappingInBetween() { + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, FixedValue(1f)) + // Implicit baseMapping between 2 & 3 + between(3f, 4f, FixedValue(2f)) + } + + assertThat(result).bothDirections().fixedMappingsMatch(0f, 1f, 0f, 2f, 0f) + assertThat(result).bothDirections().breakpointsPositionsMatch(1f, 2f, 3f, 4f) + } + + @Test + fun placement_overlapping_throws() { + val exception = + assertFailsWith { + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, FixedValue(1f)) + between(1.5f, 2.5f, FixedValue(2f)) + } + } + assertThat(exception).hasMessageThat().contains("overlap") + } + + @Test + fun placement_embedded_throws() { + val exception = + assertFailsWith { + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 3f, FixedValue(1f)) + between(1.5f, 2.5f, FixedValue(2f)) + } + } + assertThat(exception).hasMessageThat().contains("overlap") + } + + @Test + fun placement_subsequent_extendsToNext() { + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + after(1f, FixedValue(1f)) + between(3f, 4f, FixedValue(2f)) + } + + assertThat(result).bothDirections().fixedMappingsMatch(0f, 1f, 2f, 0f) + assertThat(result).bothDirections().breakpointsPositionsMatch(1f, 3f, 4f) + } + + @Test + fun placement_subsequent_extendsToPrevious() { + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, FixedValue(1f)) + before(4f, FixedValue(2f)) + } + + assertThat(result).bothDirections().fixedMappingsMatch(0f, 1f, 2f, 0f) + assertThat(result).bothDirections().breakpointsPositionsMatch(1f, 2f, 4f) + } + + @Test + fun placement_subsequent_bothExtend_throws() { + val exception = + assertFailsWith { + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + after(1f, FixedValue(1f)) + before(3f, FixedValue(2f)) + } + } + assertThat(exception).hasMessageThat().contains("extend") + } + + @Test + fun placement_withFixedExtent_after_limitsEffect() { + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + after(1f, FixedValueWithExtent(1f, 2f)) + } + + assertThat(result).bothDirections().fixedMappingsMatch(0f, 1f, 0f) + assertThat(result).bothDirections().breakpointsPositionsMatch(1f, 3f) + } + + @Test + fun placement_withFixedExtent_before_limitsEffect() { + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + before(1f, FixedValueWithExtent(1f, 2f)) + } + + assertThat(result).bothDirections().fixedMappingsMatch(0f, 1f, 0f) + assertThat(result).bothDirections().breakpointsPositionsMatch(-1f, 1f) + } + + @Test + fun placement_relative_afterEffect() { + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + val effect1 = between(1f, 2f, FixedValue(1f)) + after(effect1, FixedValue(2f)) + } + + assertThat(result).bothDirections().fixedMappingsMatch(0f, 1f, 2f) + assertThat(result).bothDirections().breakpointsPositionsMatch(1f, 2f) + } + + @Test + fun placement_relative_beforeEffect() { + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + val effect1 = between(1f, 2f, FixedValue(1f)) + before(effect1, FixedValue(2f)) + } + + assertThat(result).bothDirections().fixedMappingsMatch(2f, 1f, 0f) + assertThat(result).bothDirections().breakpointsPositionsMatch(1f, 2f) + } + + @Test + fun placement_relative_chainOfMappings() { + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + val rootEffect = after(1f, FixedValueWithExtent(-1f, 2f)) + + val left = before(rootEffect, FixedValueWithExtent(-2f, 3f)) + before(left, FixedValueWithExtent(-3f, 4f)) + + val right = after(rootEffect, FixedValueWithExtent(-4f, 3f)) + after(right, FixedValueWithExtent(-5f, 4f)) + } + + assertThat(result).bothDirections().fixedMappingsMatch(0f, -3f, -2f, -1f, -4f, -5f, 0f) + assertThat(result).bothDirections().breakpointsPositionsMatch(-6f, -2f, 1f, 3f, 6f, 10f) + } + + @Test + fun placement_relative_overlappingChain_throws() { + assertFailsWith { + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + val rootEffect = between(1f, 3f, FixedValue(-1f)) + val left = before(rootEffect, FixedValue(-2f)) + after(left, FixedValue(-3f)) + } + } + } + + @Test + fun effect_differentReverseSpec() { + val effect = SimpleEffect { + forward(Mapping.One) + backward(Mapping.Two) + } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, effect) + } + + assertThat(result).maxDirection().fixedMappingsMatch(0f, 1f, 0f) + assertThat(result).maxDirection().breakpointsPositionsMatch(1f, 2f) + + assertThat(result).minDirection().fixedMappingsMatch(0f, 2f, 0f) + assertThat(result).minDirection().breakpointsPositionsMatch(1f, 2f) + } + + @Test + fun effect_separateReverseSpec_withBuilder_canProduceDifferentSegmentCount() { + val effect = + object : Effect.PlaceableBetween { + override fun EffectApplyScope.createSpec( + minLimit: Float, + minLimitKey: BreakpointKey, + maxLimit: Float, + maxLimitKey: BreakpointKey, + placement: EffectPlacement, + ) { + forward(Mapping.One) { fixedValue(breakpoint = minLimit + 0.5f, 10f) } + backward(Mapping.Two) + } + } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, effect) + } + + assertThat(result).maxDirection().fixedMappingsMatch(0f, 1f, 10f, 0f) + assertThat(result).maxDirection().breakpointsPositionsMatch(1f, 1.5f, 2f) + + assertThat(result).minDirection().fixedMappingsMatch(0f, 2f, 0f) + assertThat(result).minDirection().breakpointsPositionsMatch(1f, 2f) + } + + @Test + fun effect_identicalBackward_withBuilder_producesSameSpecInBothDirections() { + val breakpointKey = BreakpointKey("foo") + val effect = + UnidirectionalEffect(Mapping.One) { + fixedValue(breakpoint = 1.5f, value = 10f, key = breakpointKey) + } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, effect) + } + + assertThat(result).bothDirections().fixedMappingsMatch(0f, 1f, 10f, 0f) + assertThat(result).bothDirections().breakpointsPositionsMatch(1f, 1.5f, 2f) + } + + @Test + fun effect_setBreakpointBeforeMinLimit_throws() { + val rogueEffect = + UnidirectionalEffect(Mapping.One) { this.fixedValue(breakpoint = 0.5f, value = 0f) } + + assertFailsWith { + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, rogueEffect) + } + } + } + + @Test + fun effect_setBreakpointAfterMinLimit_throws() { + val rogueEffect = + UnidirectionalEffect(Mapping.One) { this.fixedValue(breakpoint = 2.5f, value = 0f) } + + assertFailsWith { + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, rogueEffect) + } + } + } + + @Test + fun effect_semantics_applyToFullInputRange() { + val semanticKey = SemanticKey("foo") + val effect = + UnidirectionalEffect( + Mapping.One, + semantics = listOf(SemanticValue(semanticKey, "initial")), + ) { + fixedValue( + breakpoint = 1.5f, + value = 2f, + semantics = listOf(SemanticValue(semanticKey, "second")), + ) + } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, effect) + } + + assertThat(result) + .maxDirection() + .semantics() + .withKey(semanticKey) + .containsExactly("initial", "initial", "second", "second") + .inOrder() + } + + @Test + fun beforeAfter_minSpring_isChangeable() { + val spring = SpringParameters(stiffness = 1f, dampingRatio = 2f) + val effect = UnidirectionalEffect(Mapping.One) { before(spring = spring) } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, effect) + } + + assertThat(result).bothDirections().breakpoints().atPosition(1f).spring().isEqualTo(spring) + } + + @Test + fun beforeAfter_maxSpring_isChangeable() { + val spring = SpringParameters(stiffness = 1f, dampingRatio = 2f) + val effect = UnidirectionalEffect(Mapping.One) { after(spring = spring) } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, effect) + } + + assertThat(result).bothDirections().breakpoints().atPosition(2f).spring().isEqualTo(spring) + } + + @Test + fun beforeAfter_conflictingSpring_secondEffectWins() { + val spring1 = SpringParameters(stiffness = 1f, dampingRatio = 2f) + val spring2 = SpringParameters(stiffness = 2f, dampingRatio = 2f) + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, UnidirectionalEffect(Mapping.One) { after(spring = spring1) }) + between(2f, 3f, UnidirectionalEffect(Mapping.One) { before(spring = spring2) }) + } + + assertThat(result).bothDirections().breakpoints().atPosition(2f).spring().isEqualTo(spring2) + } + + @Test + fun beforeAfter_minGuarantee_isChangeable() { + val guarantee = Guarantee.InputDelta(1f) + val effect = UnidirectionalEffect(Mapping.One) { before(guarantee = guarantee) } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, effect) + } + + assertThat(result) + .bothDirections() + .breakpoints() + .atPosition(1f) + .guarantee() + .isEqualTo(guarantee) + } + + @Test + fun beforeAfter_maxGuarantee_isChangeable() { + val guarantee = Guarantee.InputDelta(1f) + val effect = UnidirectionalEffect(Mapping.One) { after(guarantee = guarantee) } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, effect) + } + + assertThat(result) + .bothDirections() + .breakpoints() + .atPosition(2f) + .guarantee() + .isEqualTo(guarantee) + } + + @Test + fun beforeAfter_conflictingGuarantee_secondEffectWins() { + val guarantee1 = Guarantee.InputDelta(1f) + val guarantee2 = Guarantee.InputDelta(2f) + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, UnidirectionalEffect(Mapping.One) { after(guarantee = guarantee1) }) + between( + 2f, + 3f, + UnidirectionalEffect(Mapping.One) { before(guarantee = guarantee2) }, + ) + } + + assertThat(result) + .bothDirections() + .breakpoints() + .atPosition(2f) + .guarantee() + .isEqualTo(guarantee2) + } + + @Test + fun beforeAfter_maxSemantics_applyAfterEffect() { + val effect = + UnidirectionalEffect(Mapping.One, testSemantics("s1")) { + after(semantics = testSemantics("s1+")) + } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + val effect1 = between(1f, 2f, effect) + after(effect1, FixedValue(2f)) + } + + assertThat(result) + .maxDirection() + .semantics() + .withKey(TestSemantics) + .containsExactly("s1", "s1", "s1+") + .inOrder() + } + + @Test + fun beforeAfter_minSemantics_applyBeforeEffect() { + val effect = + UnidirectionalEffect(Mapping.One, testSemantics("s1")) { + before(semantics = testSemantics("s1-")) + } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, effect) + before(1f, FixedValue(2f)) + } + + assertThat(result) + .maxDirection() + .semantics() + .withKey(TestSemantics) + .containsExactly("s1-", "s1", "s1") + .inOrder() + } + + @Test + fun beforeAfter_conflictingSemantics_firstEffectWins() { + val effect1 = + UnidirectionalEffect(Mapping.One, testSemantics("s1")) { + after(semantics = testSemantics("s1+")) + } + val effect2 = + UnidirectionalEffect(Mapping.One, testSemantics("s2")) { + before(semantics = testSemantics("s2-")) + } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, effect1) + between(3f, 4f, effect2) + } + + assertThat(result) + .maxDirection() + .semantics() + .withKey(TestSemantics) + .containsExactly("s1", "s1", "s1+", "s2", "s2") + .inOrder() + } + + @Test + fun beforeAfter_semantics_specifiedByNextEffect_afterSemanticsIgnored() { + val effect1 = + UnidirectionalEffect(Mapping.One, testSemantics("s1")) { + after(semantics = testSemantics("s1+")) + } + + val effect2 = UnidirectionalEffect(Mapping.One, semantics = testSemantics("s2")) + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, effect1) + between(2f, 3f, effect2) + } + + assertThat(result) + .maxDirection() + .semantics() + .withKey(TestSemantics) + .containsExactly("s1", "s1", "s2", "s2") + .inOrder() + } + + @Test + fun beforeAfter_semantics_specifiedByPreviousEffect_beforeSemanticsIgnored() { + val effect1 = UnidirectionalEffect(Mapping.One, testSemantics("s1")) + + val effect2 = + UnidirectionalEffect(Mapping.One, semantics = testSemantics("s2")) { + before(semantics = testSemantics("s2-")) + } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, effect1) + between(2f, 3f, effect2) + } + + assertThat(result) + .maxDirection() + .semantics() + .withKey(TestSemantics) + .containsExactly("s1", "s1", "s2", "s2") + .inOrder() + } + + @Test + fun beforeAfter_maxMapping_applyAfterEffect() { + val effect = UnidirectionalEffect(Mapping.One) { after(mapping = Mapping.Two) } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, effect) + } + + assertThat(result).bothDirections().fixedMappingsMatch(0f, 1f, 2f) + } + + @Test + fun beforeAfter_minMapping_applyBeforeEffect() { + val effect = UnidirectionalEffect(Mapping.One) { before(mapping = Mapping.Two) } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, effect) + } + + assertThat(result).bothDirections().fixedMappingsMatch(2f, 1f, 0f) + } + + @Test + fun beforeAfter_minMapping_ignoredWhenEffectBeforeSpecified() { + val effect = UnidirectionalEffect(Mapping.One) { before(mapping = Mapping.Two) } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, effect) + before(1f, FixedValue(3f)) + } + + assertThat(result).bothDirections().fixedMappingsMatch(3f, 1f, 0f) + } + + @Test + fun beforeAfter_maxMapping_ignoredWhenEffectAfterSpecified() { + val effect = UnidirectionalEffect(Mapping.One) { after(mapping = Mapping.Two) } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + between(1f, 2f, effect) + after(2f, FixedValue(3f)) + } + + assertThat(result).bothDirections().fixedMappingsMatch(0f, 1f, 3f) + } + + @Test + fun beforeAfter_minMapping_ignoredWhenFirstEffect() { + val effect = UnidirectionalEffect(Mapping.One) { before(mapping = Mapping.Two) } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + before(0f, effect) + } + + assertThat(result).bothDirections().fixedMappingsMatch(1f, 0f) + } + + @Test + fun beforeAfter_maxMapping_ignoredWhenLastEffect() { + val effect = UnidirectionalEffect(Mapping.One) { after(mapping = Mapping.Two) } + + val result = + motionSpec(baseMapping = Mapping.Zero, defaultSpring = spatial.default) { + after(0f, effect) + } + + assertThat(result).bothDirections().fixedMappingsMatch(0f, 1f) + } + + @Test + fun order_sharedBreakpoint_betweenAndAfter_sortedCorrectly() { + val result = + spatialMotionSpec(Mapping.Zero) { + after(2f, FixedValue(2f)) + between(1f, 2f, FixedValue(1f)) + } + + assertThat(result).bothDirections().fixedMappingsMatch(0f, 1f, 2f) + } + + @Test + fun order_sharedBreakpoint_betweenAndBefore_sortedCorrectly() { + val result = + spatialMotionSpec(Mapping.Zero) { + between(1f, 2f, FixedValue(2f)) + before(1f, FixedValue(1f)) + } + + assertThat(result).bothDirections().fixedMappingsMatch(1f, 2f, 0f) + } + + @Test + fun order_sharedBreakpoint_beforeAfter_sortedCorrectly() { + val result = + spatialMotionSpec(Mapping.Zero) { + after(1f, FixedValue(2f)) + before(1f, FixedValue(1f)) + } + + assertThat(result).bothDirections().fixedMappingsMatch(1f, 2f) + } + + private class SimpleEffect(private val createSpec: EffectApplyScope.() -> Unit) : + Effect.PlaceableBetween { + override fun EffectApplyScope.createSpec( + minLimit: Float, + minLimitKey: BreakpointKey, + maxLimit: Float, + maxLimitKey: BreakpointKey, + placement: EffectPlacement, + ) { + createSpec() + } + } + + private class UnidirectionalEffect( + private val initialMapping: Mapping, + private val semantics: List> = emptyList(), + private val init: DirectionalEffectBuilderScope.() -> Unit = {}, + ) : Effect.PlaceableBetween, Effect.PlaceableAfter, Effect.PlaceableBefore { + override fun MotionBuilderContext.intrinsicSize(): Float = Float.POSITIVE_INFINITY + + override fun EffectApplyScope.createSpec( + minLimit: Float, + minLimitKey: BreakpointKey, + maxLimit: Float, + maxLimitKey: BreakpointKey, + placement: EffectPlacement, + ) { + unidirectional(initialMapping, semantics, init) + } + } + + private class FixedValueWithExtent(val value: Float, val extent: Float) : + Effect.PlaceableAfter, Effect.PlaceableBefore { + override fun MotionBuilderContext.intrinsicSize() = extent + + override fun EffectApplyScope.createSpec( + minLimit: Float, + minLimitKey: BreakpointKey, + maxLimit: Float, + maxLimitKey: BreakpointKey, + placement: EffectPlacement, + ) { + return unidirectional(Mapping.Fixed(value)) + } + } + + companion object { + val TestSemantics = SemanticKey("foo") + + fun testSemantics(value: String) = listOf(TestSemantics with value) + } +} diff --git a/mechanics/tests/src/com/android/mechanics/spring/ComposeAndMechanicsSpringCompatibilityTest.kt b/mechanics/tests/src/com/android/mechanics/spring/ComposeAndMechanicsSpringCompatibilityTest.kt new file mode 100644 index 0000000..d06012d --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/spring/ComposeAndMechanicsSpringCompatibilityTest.kt @@ -0,0 +1,152 @@ +/* + * 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.spring + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.SpringSpec +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.TestMonotonicFrameClock +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import org.junit.Test +import org.junit.runner.RunWith + +@OptIn(ExperimentalTestApi::class, ExperimentalCoroutinesApi::class) +@RunWith(AndroidJUnit4::class) +class ComposeAndMechanicsSpringCompatibilityTest { + + @Test + fun criticallyDamped_matchesComposeSpring() = runTestWithFrameClock { + assertMechanicsMatchesComposeSpringMovement( + SpringParameters(stiffness = 100f, dampingRatio = 1f) + ) + } + + @Test + fun underDamped_matchesComposeSpring() = runTestWithFrameClock { + assertMechanicsMatchesComposeSpringMovement( + SpringParameters(stiffness = 1000f, dampingRatio = .5f) + ) + } + + @Test + fun overDamped_matchesComposeSpring() = runTestWithFrameClock { + assertMechanicsMatchesComposeSpringMovement( + SpringParameters(stiffness = 2000f, dampingRatio = 1.5f) + ) + } + + @Test + fun withInitialVelocity_matchesComposeSpring() = runTestWithFrameClock { + assertMechanicsMatchesComposeSpringMovement( + SpringParameters(stiffness = 2000f, dampingRatio = .85f), + startDisplacement = 0f, + initialVelocity = 10f, + ) + } + + private suspend fun assertMechanicsMatchesComposeSpringMovement( + parameters: SpringParameters, + startDisplacement: Float = 10f, + initialVelocity: Float = 0f, + ) { + val byCompose = computeComposeSpringValues(startDisplacement, initialVelocity, parameters) + + val byMechanics = + computeMechanicsSpringValues(startDisplacement, initialVelocity, parameters) + + assertSpringValuesMatch(byMechanics, byCompose) + } + + private suspend fun computeComposeSpringValues( + displacement: Float, + initialVelocity: Float, + parameters: SpringParameters, + ) = buildList { + Animatable(displacement, DisplacementThreshold).animateTo( + 0f, + parameters.asSpringSpec(), + initialVelocity, + ) { + add(SpringState(value, velocity)) + } + } + + private fun computeMechanicsSpringValues( + displacement: Float, + initialVelocity: Float, + parameters: SpringParameters, + ) = buildList { + var state = SpringState(displacement, initialVelocity) + while (!state.isStable(parameters, DisplacementThreshold)) { + add(state) + state = state.calculateUpdatedState(FrameDelayNanos, parameters) + } + } + + private fun assertSpringValuesMatch( + byMechanics: List, + byCompose: List, + ) { + // Last element by compose is zero displacement, zero velocity + assertThat(byCompose.last()).isEqualTo(SpringState.AtRest) + + // Mechanics computes when the spring is stable differently. Allow some variance. + assertThat(abs(byMechanics.size - byCompose.size)).isAtMost(2) + + // All frames until either one is considered stable must produce the same displacement + // and velocity + val maxFramesToExactlyCompare = min(byMechanics.size, byCompose.size - 1) + val tolerance = 0.0001f + for (i in 0 until maxFramesToExactlyCompare) { + val mechanics = byMechanics[i] + val compose = byCompose[i] + assertThat(mechanics.displacement).isWithin(tolerance).of(compose.displacement) + assertThat(mechanics.velocity).isWithin(tolerance).of(compose.velocity) + } + + // Afterwards, the displacement must be within displacementThreshold. + for (i in maxFramesToExactlyCompare until max(byMechanics.size, byCompose.size)) { + val mechanics = byMechanics.elementAtOrNull(i) ?: SpringState.AtRest + val compose = byCompose.elementAtOrNull(i) ?: SpringState.AtRest + assertThat(mechanics.displacement) + .isWithin(DisplacementThreshold) + .of(compose.displacement) + } + } + + private fun SpringParameters.asSpringSpec(): SpringSpec { + return SpringSpec(dampingRatio, stiffness) + } + + private fun runTestWithFrameClock(testBody: suspend () -> Unit) = runTest { + val testScope: TestScope = this + withContext(TestMonotonicFrameClock(testScope, FrameDelayNanos)) { testBody() } + } + + companion object { + private val FrameDelayNanos: Long = 16_000_000L + private val DisplacementThreshold: Float = 0.01f + } +} diff --git a/mechanics/tests/src/com/android/mechanics/spring/SpringParameterTest.kt b/mechanics/tests/src/com/android/mechanics/spring/SpringParameterTest.kt new file mode 100644 index 0000000..c8f6c38 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/spring/SpringParameterTest.kt @@ -0,0 +1,58 @@ +/* + * 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.spring + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SpringParameterTest { + + @Test + fun lerp_interpolatesDampingLinearly() { + val start = SpringParameters(stiffness = 100f, dampingRatio = 1.5f) + val stop = SpringParameters(stiffness = 100f, dampingRatio = 0.5f) + + assertThat(lerp(start, stop, 0f).dampingRatio).isEqualTo(1.5f) + assertThat(lerp(start, stop, .25f).dampingRatio).isEqualTo(1.25f) + assertThat(lerp(start, stop, .5f).dampingRatio).isEqualTo(1f) + assertThat(lerp(start, stop, 1f).dampingRatio).isEqualTo(.5f) + } + + @Test + fun lerp_interpolatesStiffnessLogarithmically() { + val start = SpringParameters(stiffness = 100f, dampingRatio = 1f) + val stop = SpringParameters(stiffness = 500_000f, dampingRatio = 1f) + + assertThat(lerp(start, stop, 0f).stiffness).isEqualTo(100f) + assertThat(lerp(start, stop, .25f).stiffness).isWithin(1f).of(840f) + assertThat(lerp(start, stop, .5f).stiffness).isWithin(1f).of(7_071f) + assertThat(lerp(start, stop, .75f).stiffness).isWithin(1f).of(59_460f) + assertThat(lerp(start, stop, 1f).stiffness).isEqualTo(500_000f) + } + + @Test + fun lerp_limitsFraction() { + val start = SpringParameters(stiffness = 100f, dampingRatio = 0.5f) + val stop = SpringParameters(stiffness = 1000f, dampingRatio = 1.5f) + + assertThat(lerp(start, stop, -1f)).isEqualTo(start) + assertThat(lerp(start, stop, +2f)).isEqualTo(stop) + } +} diff --git a/mechanics/tests/src/com/android/mechanics/spring/SpringStateTest.kt b/mechanics/tests/src/com/android/mechanics/spring/SpringStateTest.kt new file mode 100644 index 0000000..3bae23c --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/spring/SpringStateTest.kt @@ -0,0 +1,144 @@ +/* + * 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.spring + +import android.platform.test.annotations.MotionTest +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.testing.asDataPoint +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import platform.test.motion.MotionTestRule +import platform.test.motion.RecordedMotion.Companion.create +import platform.test.motion.golden.DataPoint +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.asDataPoint +import platform.test.motion.testing.createGoldenPathManager + +@RunWith(AndroidJUnit4::class) +@MotionTest +class SpringStateTest { + private val goldenPathManager = + createGoldenPathManager("frameworks/libs/systemui/mechanics/tests/goldens") + + @get:Rule val motion = MotionTestRule(Unit, goldenPathManager) + + @Test + fun criticallyDamped_matchesGolden() { + val parameters = SpringParameters(stiffness = 100f, dampingRatio = 1f) + val initialState = SpringState(displacement = 10f) + + assertSpringMotionMatchesGolden(initialState) { parameters } + } + + @Test + fun overDamped_matchesGolden() { + val parameters = SpringParameters(stiffness = 100f, dampingRatio = 2f) + val initialState = SpringState(displacement = 10f) + + assertSpringMotionMatchesGolden(initialState) { parameters } + } + + @Test + fun underDamped_matchesGolden() { + val parameters = SpringParameters(stiffness = 100f, dampingRatio = .3f) + val initialState = SpringState(displacement = 10f) + + assertSpringMotionMatchesGolden(initialState) { parameters } + } + + @Test + fun zeroDisplacement_initialVelocity_matchesGolden() { + val parameters = SpringParameters(stiffness = 100f, dampingRatio = .3f) + val initialState = SpringState(displacement = 0f, velocity = 10f) + + assertSpringMotionMatchesGolden(initialState) { parameters } + } + + @Test + fun snapSpring_updatesImmediately_matchesGolden() { + val initialState = SpringState(displacement = 10f, velocity = -10f) + + assertSpringMotionMatchesGolden(initialState) { SpringParameters.Snap } + } + + @Test + fun stiffeningSpring_matchesGolden() { + val parameters = SpringParameters(stiffness = 100f, dampingRatio = .3f) + val initialState = SpringState(displacement = 10f, velocity = -10f) + + assertSpringMotionMatchesGolden(initialState) { + lerp(parameters, SpringParameters.Snap, it / 200f) + } + } + + private fun assertSpringMotionMatchesGolden( + initialState: SpringState, + stableThreshold: Float = 0.01f, + sampleFrequencyHz: Float = 100f, + springParameters: (timeMillis: Long) -> SpringParameters, + ) { + val sampleDurationMillis = (1_000f / sampleFrequencyHz).toLong() + + val frameIds = mutableListOf() + + val displacement = mutableListOf>() + val velocity = mutableListOf>() + val isStable = mutableListOf>() + val params = mutableListOf>() + + var iterationTimeMillis = 0L + var keepRecording = 2 + + var springState = initialState + while (keepRecording > 0 && frameIds.size < 1000) { + frameIds.add(TimestampFrameId(iterationTimeMillis)) + + val parameters = springParameters(iterationTimeMillis) + val currentlyStable = springState.isStable(parameters, stableThreshold) + if (currentlyStable) { + keepRecording-- + } + + displacement.add(springState.displacement.asDataPoint()) + velocity.add(springState.velocity.asDataPoint()) + isStable.add(currentlyStable.asDataPoint()) + params.add(parameters.asDataPoint()) + + val elapsedNanos = sampleDurationMillis * 1_000_000 + springState = springState.calculateUpdatedState(elapsedNanos, parameters) + iterationTimeMillis += sampleDurationMillis + } + + val timeSeries = + TimeSeries( + frameIds.toList(), + listOf( + Feature("displacement", displacement), + Feature("velocity", velocity), + Feature("stable", isStable), + Feature("parameters", params), + ), + ) + + val recordedMotion = motion.create(timeSeries, screenshots = null) + motion.assertThat(recordedMotion).timeSeriesMatchesGolden() + } +} diff --git a/mechanics/tests/src/com/android/mechanics/view/ViewGestureContextTest.kt b/mechanics/tests/src/com/android/mechanics/view/ViewGestureContextTest.kt new file mode 100644 index 0000000..dbc6cb0 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/view/ViewGestureContextTest.kt @@ -0,0 +1,202 @@ +/* + * Copyright (C) 2025 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.view + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.spec.InputDirection +import com.google.common.truth.Truth.assertThat +import kotlin.math.nextDown +import kotlin.math.nextUp +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ViewGestureContextTest { + + @Test + fun update_maxDirection_increasingInput_keepsDirection() { + val underTest = + DistanceGestureContext( + initialDragOffset = 0f, + initialDirection = InputDirection.Max, + directionChangeSlop = 5f, + ) + + for (value in 0..6) { + underTest.dragOffset = value.toFloat() + assertThat(underTest.direction).isEqualTo(InputDirection.Max) + } + } + + @Test + fun update_minDirection_decreasingInput_keepsDirection() { + val underTest = + DistanceGestureContext( + initialDragOffset = 0f, + initialDirection = InputDirection.Min, + directionChangeSlop = 5f, + ) + + for (value in 0 downTo -6) { + underTest.dragOffset = value.toFloat() + assertThat(underTest.direction).isEqualTo(InputDirection.Min) + } + } + + @Test + fun update_maxDirection_decreasingInput_keepsDirection_belowDirectionChangeSlop() { + val underTest = + DistanceGestureContext( + initialDragOffset = 0f, + initialDirection = InputDirection.Max, + directionChangeSlop = 5f, + ) + + underTest.dragOffset = -5f + assertThat(underTest.direction).isEqualTo(InputDirection.Max) + } + + @Test + fun update_maxDirection_decreasingInput_switchesDirection_aboveDirectionChangeSlop() { + val underTest = + DistanceGestureContext( + initialDragOffset = 0f, + initialDirection = InputDirection.Max, + directionChangeSlop = 5f, + ) + + underTest.dragOffset = (-5f).nextDown() + assertThat(underTest.direction).isEqualTo(InputDirection.Min) + } + + @Test + fun update_minDirection_increasingInput_keepsDirection_belowDirectionChangeSlop() { + val underTest = + DistanceGestureContext( + initialDragOffset = 0f, + initialDirection = InputDirection.Min, + directionChangeSlop = 5f, + ) + + underTest.dragOffset = 5f + assertThat(underTest.direction).isEqualTo(InputDirection.Min) + } + + @Test + fun update_minDirection_decreasingInput_switchesDirection_aboveDirectionChangeSlop() { + val underTest = + DistanceGestureContext( + initialDragOffset = 0f, + initialDirection = InputDirection.Min, + directionChangeSlop = 5f, + ) + + underTest.dragOffset = 5f.nextUp() + assertThat(underTest.direction).isEqualTo(InputDirection.Max) + } + + @Test + fun reset_resetsFurthestValue() { + val underTest = + DistanceGestureContext( + initialDragOffset = 10f, + initialDirection = InputDirection.Max, + directionChangeSlop = 1f, + ) + + underTest.reset(5f, direction = InputDirection.Max) + assertThat(underTest.direction).isEqualTo(InputDirection.Max) + assertThat(underTest.dragOffset).isEqualTo(5f) + + underTest.dragOffset -= 1f + assertThat(underTest.direction).isEqualTo(InputDirection.Max) + assertThat(underTest.dragOffset).isEqualTo(4f) + + underTest.dragOffset = underTest.dragOffset.nextDown() + assertThat(underTest.direction).isEqualTo(InputDirection.Min) + assertThat(underTest.dragOffset).isWithin(0.0001f).of(4f) + } + + @Test + fun callback_invokedOnChange() { + val underTest = + DistanceGestureContext( + initialDragOffset = 0f, + initialDirection = InputDirection.Max, + directionChangeSlop = 5f, + ) + + var invocationCount = 0 + underTest.addUpdateCallback { invocationCount++ } + + assertThat(invocationCount).isEqualTo(0) + underTest.dragOffset += 1 + assertThat(invocationCount).isEqualTo(1) + } + + @Test + fun callback_invokedOnReset() { + val underTest = + DistanceGestureContext( + initialDragOffset = 0f, + initialDirection = InputDirection.Max, + directionChangeSlop = 5f, + ) + + var invocationCount = 0 + underTest.addUpdateCallback { invocationCount++ } + + assertThat(invocationCount).isEqualTo(0) + underTest.reset(0f, InputDirection.Max) + assertThat(invocationCount).isEqualTo(1) + } + + @Test + fun callback_ignoredForSameValues() { + val underTest = + DistanceGestureContext( + initialDragOffset = 0f, + initialDirection = InputDirection.Max, + directionChangeSlop = 5f, + ) + + var invocationCount = 0 + underTest.addUpdateCallback { invocationCount++ } + + assertThat(invocationCount).isEqualTo(0) + underTest.dragOffset += 0 + assertThat(invocationCount).isEqualTo(0) + } + + @Test + fun callback_removeUpdateCallback_removesCallback() { + val underTest = + DistanceGestureContext( + initialDragOffset = 0f, + initialDirection = InputDirection.Max, + directionChangeSlop = 5f, + ) + + var invocationCount = 0 + val callback = GestureContextUpdateListener { invocationCount++ } + underTest.addUpdateCallback(callback) + assertThat(invocationCount).isEqualTo(0) + underTest.removeUpdateCallback(callback) + underTest.dragOffset += 1 + assertThat(invocationCount).isEqualTo(0) + } +} diff --git a/mechanics/tests/src/com/android/mechanics/view/ViewMotionBuilderContextTest.kt b/mechanics/tests/src/com/android/mechanics/view/ViewMotionBuilderContextTest.kt new file mode 100644 index 0000000..10dd4f6 --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/view/ViewMotionBuilderContextTest.kt @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2025 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(ExperimentalMaterial3ExpressiveApi::class) + +package com.android.mechanics.view + +import android.content.Context +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MotionScheme +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.spec.builder.MotionBuilderContext +import com.android.mechanics.spec.builder.rememberMotionBuilderContext +import com.google.common.truth.Truth +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ViewMotionBuilderContextTest { + + @get:Rule(order = 0) val rule = createComposeRule() + + @Test + fun materialSprings_standardScheme_matchesComposeDefinition() { + lateinit var viewContext: Context + lateinit var composeReference: MotionBuilderContext + + rule.setContent { + viewContext = LocalContext.current + MaterialTheme(motionScheme = MotionScheme.standard()) { + composeReference = rememberMotionBuilderContext() + } + } + + val underTest = standardViewMotionBuilderContext(viewContext) + + Truth.assertThat(underTest.density).isEqualTo(composeReference.density) + Truth.assertThat(underTest.spatial).isEqualTo(composeReference.spatial) + Truth.assertThat(underTest.effects).isEqualTo(composeReference.effects) + } + + @Test + fun materialSprings_expressiveScheme_matchesComposeDefinition() { + lateinit var viewContext: Context + lateinit var composeReference: MotionBuilderContext + + rule.setContent { + viewContext = LocalContext.current + MaterialTheme(motionScheme = MotionScheme.expressive()) { + composeReference = rememberMotionBuilderContext() + } + } + + val underTest = expressiveViewMotionBuilderContext(viewContext) + + Truth.assertThat(underTest.density).isEqualTo(composeReference.density) + Truth.assertThat(underTest.spatial).isEqualTo(composeReference.spatial) + Truth.assertThat(underTest.effects).isEqualTo(composeReference.effects) + } +} diff --git a/mechanics/tests/src/com/android/mechanics/view/ViewMotionValueTest.kt b/mechanics/tests/src/com/android/mechanics/view/ViewMotionValueTest.kt new file mode 100644 index 0000000..7d7fdcd --- /dev/null +++ b/mechanics/tests/src/com/android/mechanics/view/ViewMotionValueTest.kt @@ -0,0 +1,261 @@ +/* + * Copyright (C) 2025 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.view + +import android.platform.test.annotations.MotionTest +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.mechanics.MotionValueTest.Companion.B1 +import com.android.mechanics.MotionValueTest.Companion.B2 +import com.android.mechanics.MotionValueTest.Companion.specBuilder +import com.android.mechanics.spec.Breakpoint +import com.android.mechanics.spec.Guarantee.GestureDragDelta +import com.android.mechanics.spec.InputDirection +import com.android.mechanics.spec.Mapping +import com.android.mechanics.spec.MotionSpec +import com.android.mechanics.spec.SegmentKey +import com.android.mechanics.spec.SemanticKey +import com.android.mechanics.spec.with +import com.android.mechanics.testing.VerifyTimeSeriesResult.AssertTimeSeriesMatchesGolden +import com.android.mechanics.testing.ViewMotionValueToolkit +import com.android.mechanics.testing.animateValueTo +import com.android.mechanics.testing.goldenTest +import com.android.mechanics.testing.input +import com.android.mechanics.testing.isStable +import com.android.mechanics.testing.output +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import platform.test.motion.MotionTestRule +import platform.test.motion.testing.createGoldenPathManager +import platform.test.screenshot.PathConfig +import platform.test.screenshot.PathElementNoContext + +/** + * NOTE: This only tests the lifecycle of ViewMotionValue, plus some basic animations. + * + * Most code is shared with MotionValue, and tested there. + */ +@RunWith(AndroidJUnit4::class) +@MotionTest +class ViewMotionValueTest { + private val goldenPathManager = + createGoldenPathManager( + "frameworks/libs/systemui/mechanics/tests/goldens", + // The ViewMotionValue goldens do not currently match MotionValue goldens, because + // the ViewMotionValue computes the output at the beginning of the new frame, while + // MotionValue computes it at when read. Therefore, the output of these goldens is + // delayed by one frame. + PathConfig(PathElementNoContext("base", isDir = true, { "view" })), + ) + + // @get:Rule(order = 1) val activityRule = + // ActivityScenarioRule(EmptyTestActivity::class.java) + @get:Rule(order = 2) val animatorTestRule = android.animation.AnimatorTestRule(this) + + @get:Rule(order = 3) + val motion = MotionTestRule(ViewMotionValueToolkit(animatorTestRule), 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) + + AssertTimeSeriesMatchesGolden() + }, + ) { + animateValueTo(100f) + } + + @Test + fun segmentChange_animatedWhenReachingBreakpoint() = + motion.goldenTest( + spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) } + ) { + animateValueTo(1f, changePerFrame = 0.5f) + awaitStable() + } + + @Test + fun semantics_returnsValueMatchingSegment() = runTest { + runBlocking(Dispatchers.Main) { + val s1 = SemanticKey("Foo") + val spec = + specBuilder(Mapping.Zero, semantics = listOf(s1 with "zero")) { + fixedValue(1f, 1f, semantics = listOf(s1 with "one")) + fixedValue(2f, 2f, semantics = listOf(s1 with "two")) + } + + val gestureContext = DistanceGestureContext(0f, InputDirection.Max, 5f) + val underTest = ViewMotionValue(0f, gestureContext, spec) + + assertThat(underTest[s1]).isEqualTo("zero") + underTest.input = 2f + animatorTestRule.advanceTimeBy(16L) + assertThat(underTest[s1]).isEqualTo("two") + } + } + + @Test + fun segment_returnsCurrentSegmentKey() = runTest { + runBlocking(Dispatchers.Main) { + val spec = + specBuilder(Mapping.Zero) { + fixedValue(1f, 1f, key = B1) + fixedValue(2f, 2f, key = B2) + } + + val gestureContext = DistanceGestureContext(0f, InputDirection.Max, 5f) + val underTest = ViewMotionValue(1f, gestureContext, spec) + + assertThat(underTest.segmentKey).isEqualTo(SegmentKey(B1, B2, InputDirection.Max)) + underTest.input = 2f + animatorTestRule.advanceTimeBy(16L) + assertThat(underTest.segmentKey) + .isEqualTo(SegmentKey(B2, Breakpoint.maxLimit.key, InputDirection.Max)) + } + } + + @Test + fun gestureContext_listensToGestureContextUpdates() = + motion.goldenTest( + spec = + specBuilder(Mapping.Zero) { + fixedValue(breakpoint = 1f, guarantee = GestureDragDelta(3f), value = 1f) + } + ) { + animateValueTo(1f, changePerFrame = 0.5f) + while (!underTest.isStable) { + gestureContext.dragOffset += 0.5f + awaitFrames() + } + } + + @Test + fun specChange_triggersAnimation() { + fun generateSpec(offset: Float) = + specBuilder(Mapping.Zero) { + targetFromCurrent(breakpoint = offset, key = B1, delta = 1f, to = 2f) + fixedValue(breakpoint = offset + 1f, key = B2, value = 0f) + } + + motion.goldenTest(spec = generateSpec(0f), initialValue = .5f) { + underTest.spec = generateSpec(1f) + awaitFrames() + awaitStable() + } + } + + @Test + fun update_triggersCallback() = runTest { + runBlocking(Dispatchers.Main) { + val gestureContext = DistanceGestureContext(0f, InputDirection.Max, 5f) + val underTest = ViewMotionValue(0f, gestureContext, MotionSpec.Empty) + + var invocationCount = 0 + underTest.addUpdateCallback { invocationCount++ } + underTest.input = 1f + repeat(60) { animatorTestRule.advanceTimeBy(16L) } + + assertThat(invocationCount).isEqualTo(2) + } + } + + @Test + fun update_setSameValue_doesNotTriggerCallback() = runTest { + runBlocking(Dispatchers.Main) { + val gestureContext = DistanceGestureContext(0f, InputDirection.Max, 5f) + val underTest = ViewMotionValue(0f, gestureContext, MotionSpec.Empty) + + var invocationCount = 0 + underTest.addUpdateCallback { invocationCount++ } + underTest.input = 0f + repeat(60) { animatorTestRule.advanceTimeBy(16L) } + + assertThat(invocationCount).isEqualTo(0) + } + } + + @Test + fun update_triggersCallbacksWhileAnimating() = runTest { + runBlocking(Dispatchers.Main) { + val gestureContext = DistanceGestureContext(0f, InputDirection.Max, 5f) + val spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) } + val underTest = ViewMotionValue(0f, gestureContext, spec) + + var invocationCount = 0 + underTest.addUpdateCallback { invocationCount++ } + underTest.input = 1f + repeat(60) { animatorTestRule.advanceTimeBy(16L) } + + assertThat(invocationCount).isEqualTo(17) + } + } + + @Test + fun removeCallback_doesNotTriggerAfterRemoving() = runTest { + runBlocking(Dispatchers.Main) { + val gestureContext = DistanceGestureContext(0f, InputDirection.Max, 5f) + val spec = specBuilder(Mapping.Zero) { fixedValue(breakpoint = 1f, value = 1f) } + val underTest = ViewMotionValue(0f, gestureContext, spec) + + var invocationCount = 0 + val callback = ViewMotionValueListener { invocationCount++ } + underTest.addUpdateCallback(callback) + underTest.input = 0.5f + animatorTestRule.advanceTimeBy(16L) + assertThat(invocationCount).isEqualTo(2) + + underTest.removeUpdateCallback(callback) + underTest.input = 1f + repeat(60) { animatorTestRule.advanceTimeBy(16L) } + + assertThat(invocationCount).isEqualTo(2) + } + } + + @Test + fun debugInspector_sameInstance_whileInUse() = runTest { + runBlocking(Dispatchers.Main) { + val gestureContext = DistanceGestureContext(0f, InputDirection.Max, 5f) + val underTest = ViewMotionValue(0f, gestureContext, MotionSpec.Empty) + + val originalInspector = underTest.debugInspector() + assertThat(underTest.debugInspector()).isSameInstanceAs(originalInspector) + } + } + + @Test + fun debugInspector_newInstance_afterUnused() = runTest { + runBlocking(Dispatchers.Main) { + val gestureContext = DistanceGestureContext(0f, InputDirection.Max, 5f) + val underTest = ViewMotionValue(0f, gestureContext, MotionSpec.Empty) + + val originalInspector = underTest.debugInspector() + originalInspector.dispose() + assertThat(underTest.debugInspector()).isNotSameInstanceAs(originalInspector) + } + } +} From 7db7f20f31da12bcd1ec17dcbd5adc3949cc7dfd Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Tue, 18 Nov 2025 21:12:29 +0700 Subject: [PATCH 13/19] feat: Mechanics --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 47ceb44..0c13fb8 100644 --- a/README.md +++ b/README.md @@ -7,4 +7,6 @@ A brief explanation of what each library does: * `contextualeducationlib`: Store "education" type * `displaylib`: Handling presumably desktop displays * `iconloaderlib`: Handling all of Launcher3 and Lawnchair icons +* `mechanics`: Complement the `animationlib` * `msdllib`: Multi-Sensory-Design-Language, handling all new vibrations in Launcher3 Android 16 +* `viewcapturelib`: Capture views... yep that's really that it From 2eb34a4332bb139a41fb7bab878b06dd1952ff75 Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Thu, 20 Nov 2025 21:33:48 +0700 Subject: [PATCH 14/19] Revert "feat: searchuilib Android 16 QPR1" This reverts commit bb5f5584fac4e74c46ac41210bfdeebeec04943d. --- searchuilib/Android.bp | 28 ++++ searchuilib/build.gradle | 10 ++ .../com/android/app/search/LayoutType.java | 155 ++++++++++++++++++ .../com/android/app/search/ResultType.java | 96 +++++++++++ 4 files changed, 289 insertions(+) create mode 100644 searchuilib/Android.bp create mode 100644 searchuilib/build.gradle create mode 100644 searchuilib/src/com/android/app/search/LayoutType.java create mode 100644 searchuilib/src/com/android/app/search/ResultType.java diff --git a/searchuilib/Android.bp b/searchuilib/Android.bp new file mode 100644 index 0000000..2b25616 --- /dev/null +++ b/searchuilib/Android.bp @@ -0,0 +1,28 @@ +// Copyright (C) 2020 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 { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +android_library { + name: "search_ui", + + sdk_version: "current", + min_sdk_version: "26", + + srcs: [ + "src/**/*.java", + ], +} diff --git a/searchuilib/build.gradle b/searchuilib/build.gradle new file mode 100644 index 0000000..76551cf --- /dev/null +++ b/searchuilib/build.gradle @@ -0,0 +1,10 @@ +apply plugin: 'com.android.library' + +android { + namespace "com.android.app.search" + sourceSets { + main { + java.srcDirs = ['src'] + } + } +} diff --git a/searchuilib/src/com/android/app/search/LayoutType.java b/searchuilib/src/com/android/app/search/LayoutType.java new file mode 100644 index 0000000..1adb0ea --- /dev/null +++ b/searchuilib/src/com/android/app/search/LayoutType.java @@ -0,0 +1,155 @@ +/* + * Copyright (C) 2020 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.app.search; + +import androidx.annotation.StringDef; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Constants to be used with {@link SearchTarget}. + */ +public class LayoutType { + + @StringDef(value = { + ICON_SINGLE_VERTICAL_TEXT, + ICON_HORIZONTAL_TEXT, + HORIZONTAL_MEDIUM_TEXT, + EXTRA_TALL_ICON_ROW, + SMALL_ICON_HORIZONTAL_TEXT, + SMALL_ICON_HORIZONTAL_TEXT_THUMBNAIL, + ICON_CONTAINER, + THUMBNAIL_CONTAINER, + BIG_ICON_MEDIUM_HEIGHT_ROW, + THUMBNAIL, + ICON_SLICE, + WIDGET_PREVIEW, + WIDGET_LIVE, + PEOPLE_TILE, + TEXT_HEADER, + DIVIDER, + EMPTY_DIVIDER, + CALCULATOR, + SECTION_HEADER, + TALL_CARD_WITH_IMAGE_NO_ICON, + TEXT_HEADER_ROW, + QS_TILE, + PLACEHOLDER, + RICHANSWER_PLACEHOLDER, + EMPTY_STATE, + SEARCH_SETTINGS, + }) + @Retention(RetentionPolicy.SOURCE) + public @interface SearchLayoutType {} + + // ------ + // | icon | + // ------ + // text + public static final String ICON_SINGLE_VERTICAL_TEXT = "icon"; + + // Below three layouts (to be deprecated) and two layouts render + // {@link SearchTarget}s in following layout. + // ------ ------ ------ + // | | title |(opt)| |(opt)| + // | icon | subtitle (optional) | icon| | icon| + // ------ ------ ------ + @Deprecated + public static final String ICON_SINGLE_HORIZONTAL_TEXT = "icon_text_row"; + @Deprecated + public static final String ICON_DOUBLE_HORIZONTAL_TEXT = "icon_texts_row"; + @Deprecated + public static final String ICON_DOUBLE_HORIZONTAL_TEXT_BUTTON = "icon_texts_button"; + + // will replace ICON_DOUBLE_* ICON_SINGLE_* layouts + public static final String ICON_HORIZONTAL_TEXT = "icon_row"; + public static final String HORIZONTAL_MEDIUM_TEXT = "icon_row_medium"; + public static final String EXTRA_TALL_ICON_ROW = "extra_tall_icon_row"; + public static final String SMALL_ICON_HORIZONTAL_TEXT = "short_icon_row"; + public static final String SMALL_ICON_HORIZONTAL_TEXT_THUMBNAIL = "short_icon_row_thumbnail"; + + // This layout contains a series of icon results (currently up to 4 per row). + // The container does not support stretching for its children, and can only contain + // {@link #ICON_SINGLE_VERTICAL_TEXT} layout types. + public static final String ICON_CONTAINER = "icon_container"; + + // This layout contains a series of thumbnails (currently up to 3 per row). + // The container supports stretching for its children, and can only contain {@link #THUMBNAIL} + // layout types. + public static final String THUMBNAIL_CONTAINER = "thumbnail_container"; + + // This layout creates a container for people grouping + // Only available above version code 2 + public static final String BIG_ICON_MEDIUM_HEIGHT_ROW = "big_icon_medium_row"; + + // This layout creates square thumbnail image (currently 3 column) + public static final String THUMBNAIL = "thumbnail"; + + // This layout contains an icon and slice + public static final String ICON_SLICE = "slice"; + + // Widget bitmap preview + public static final String WIDGET_PREVIEW = "widget_preview"; + + // Live widget search result + public static final String WIDGET_LIVE = "widget_live"; + + // Layout type used to display people tiles using shortcut info + public static final String PEOPLE_TILE = "people_tile"; + + // Deprecated + // text based header to group various layouts in low confidence section of the results. + public static final String TEXT_HEADER = "header"; + + // horizontal bar to be inserted between fallback search results and low confidence section + public static final String EMPTY_DIVIDER = "empty_divider"; + + @Deprecated(since = "Use EMPTY_DIVIDER instead") + public static final String DIVIDER = EMPTY_DIVIDER; + + // layout representing quick calculations + public static final String CALCULATOR = "calculator"; + + // From version code 4, if TEXT_HEADER_ROW is used, no need to insert this on-device + // section header. + public static final String SECTION_HEADER = "section_header"; + + // layout for a tall card with header and image, and no icon. + public static final String TALL_CARD_WITH_IMAGE_NO_ICON = "tall_card_with_image_no_icon"; + + // Layout for a text header + // Available for SearchUiManager proxy service to use above version code 3 + public static final String TEXT_HEADER_ROW = "text_header_row"; + + // Layout for a quick settings tile + public static final String QS_TILE = "qs_tile"; + + // Placeholder for web suggest. + public static final String PLACEHOLDER = "placeholder"; + + // Placeholder for rich answer cards. + // Only available on or above version code 3. + public static final String RICHANSWER_PLACEHOLDER = "richanswer_placeholder"; + + + // layout representing the empty, no query state + public static final String EMPTY_STATE = "empty_state"; + + // layout representing search settings + public static final String SEARCH_SETTINGS = "launcher_settings"; +} diff --git a/searchuilib/src/com/android/app/search/ResultType.java b/searchuilib/src/com/android/app/search/ResultType.java new file mode 100644 index 0000000..5340220 --- /dev/null +++ b/searchuilib/src/com/android/app/search/ResultType.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2020 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.app.search; + +/** + * Constants to be used with {@link android.app.search.SearchContext} and + * {@link android.app.search.SearchTarget}. + * + * Note, a result type could be a of two types. + * For example, unpublished settings result type could be in slices: + * resultType = SETTING | SLICE + */ +public class ResultType { + + // published corpus by 3rd party app, supported by SystemService + public static final int APPLICATION = 1 << 0; + public static final int SHORTCUT = 1 << 1; + public static final int SLICE = 1 << 6; + public static final int WIDGETS = 1 << 7; + + // Not extracted from any of the SystemService + public static final int PEOPLE = 1 << 2; + public static final int ACTION = 1 << 3; + public static final int SETTING = 1 << 4; + public static final int IMAGE = 1 << 5; + + @Deprecated(since = "Use IMAGE") + public static final int SCREENSHOT = IMAGE; + + public static final int PLAY = 1 << 8; + public static final int SUGGEST = 1 << 9; + public static final int ASSISTANT = 1 << 10; + public static final int CHROMETAB = 1 << 11; + public static final int NAVVYSITE = 1 << 12; + public static final int TIPS = 1 << 13; + public static final int PEOPLE_TILE = 1 << 14; + public static final int LEGACY_SHORTCUT = 1 << 15; + public static final int MEMORY = 1 << 16; + public static final int WEB_SUGGEST = 1 << 17; + public static final int NO_FULFILLMENT = 1 << 18; + public static final int EDUCARD = 1 << 19; + public static final int SYSTEM_POINTER = 1 << 20; + public static final int VIDEO = 1 << 21; + + public static final int PUBLIC_DATA_TYPE = APPLICATION | SETTING | PLAY | WEB_SUGGEST; + public static final int PRIMITIVE_TYPE = APPLICATION | SLICE | SHORTCUT | WIDGETS | ACTION | + LEGACY_SHORTCUT; + public static final int CORPUS_TYPE = + PEOPLE | SETTING | IMAGE | PLAY | SUGGEST | ASSISTANT | CHROMETAB | NAVVYSITE | TIPS + | PEOPLE_TILE | MEMORY | WEB_SUGGEST | VIDEO; + public static final int RANK_TYPE = SYSTEM_POINTER; + public static final int UI_TYPE = EDUCARD | NO_FULFILLMENT; + + public static boolean isSlice(int resultType) { + return (resultType & SLICE) != 0; + } + + public static boolean isSystemPointer(int resultType) { + return (resultType & SYSTEM_POINTER) != 0; + } + + /** + * Returns result type integer where only {@code #CORPUS_TYPE} bit will turned on. + */ + public static int getCorpusType(int resultType) { + return (resultType & CORPUS_TYPE); + } + + /** + * Returns result type integer where only {@code #PRIMITIVE_TYPE} bit will be turned on. + */ + public static int getPrimitiveType(int resultType) { + return (resultType & PRIMITIVE_TYPE); + } + + /** + * Returns whether the given result type is privacy safe or not. + */ + public static boolean isPrivacySafe(int resultType) { + return (resultType & PUBLIC_DATA_TYPE) != 0; + } +} From f026dadbd4ce187d71276c0403a97f8292a98db4 Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Thu, 20 Nov 2025 21:38:20 +0700 Subject: [PATCH 15/19] docs: Re-add searchuilib --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 0c13fb8..e44ba63 100644 --- a/README.md +++ b/README.md @@ -9,4 +9,5 @@ A brief explanation of what each library does: * `iconloaderlib`: Handling all of Launcher3 and Lawnchair icons * `mechanics`: Complement the `animationlib` * `msdllib`: Multi-Sensory-Design-Language, handling all new vibrations in Launcher3 Android 16 +* `searchuilib`: Store search-related layout type * `viewcapturelib`: Capture views... yep that's really that it From cba0e996fd22225e6c3facfa47e785cefaf5a99c Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Thu, 20 Nov 2025 21:44:58 +0700 Subject: [PATCH 16/19] feat: Update searchuilib to Android U --- README.md | 3 +++ .../com/android/app/search/LayoutType.java | 20 +++++++++++-------- .../com/android/app/search/ResultType.java | 11 ++++------ 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index e44ba63..9b96c06 100644 --- a/README.md +++ b/README.md @@ -10,4 +10,7 @@ A brief explanation of what each library does: * `mechanics`: Complement the `animationlib` * `msdllib`: Multi-Sensory-Design-Language, handling all new vibrations in Launcher3 Android 16 * `searchuilib`: Store search-related layout type + * See [AOSP Commit][searchuilib-url] instead because it's gone private after U * `viewcapturelib`: Capture views... yep that's really that it + +[searchuilib-url]: https://cs.android.com/android/_/android/platform/frameworks/libs/systemui/+/main:searchuilib/src/com/android/app/search/;drc=ace90b2ec32d3730141387c56e8abc761c380550;bpv=1;bpt=0 diff --git a/searchuilib/src/com/android/app/search/LayoutType.java b/searchuilib/src/com/android/app/search/LayoutType.java index 1adb0ea..f510c38 100644 --- a/searchuilib/src/com/android/app/search/LayoutType.java +++ b/searchuilib/src/com/android/app/search/LayoutType.java @@ -49,10 +49,11 @@ public class LayoutType { TALL_CARD_WITH_IMAGE_NO_ICON, TEXT_HEADER_ROW, QS_TILE, + QS_TILE_CONTAINER, PLACEHOLDER, RICHANSWER_PLACEHOLDER, - EMPTY_STATE, - SEARCH_SETTINGS, + PLAY_PLACEHOLDER, + EDUCARD, }) @Retention(RetentionPolicy.SOURCE) public @interface SearchLayoutType {} @@ -119,7 +120,7 @@ public class LayoutType { // horizontal bar to be inserted between fallback search results and low confidence section public static final String EMPTY_DIVIDER = "empty_divider"; - @Deprecated(since = "Use EMPTY_DIVIDER instead") + @Deprecated(since = "LC: Use EMPTY_DIVIDER instead") public static final String DIVIDER = EMPTY_DIVIDER; // layout representing quick calculations @@ -139,6 +140,9 @@ public class LayoutType { // Layout for a quick settings tile public static final String QS_TILE = "qs_tile"; + // Layout for a quick settings tile container + public static final String QS_TILE_CONTAINER = "qs_tile_container"; + // Placeholder for web suggest. public static final String PLACEHOLDER = "placeholder"; @@ -146,10 +150,10 @@ public class LayoutType { // Only available on or above version code 3. public static final String RICHANSWER_PLACEHOLDER = "richanswer_placeholder"; + // Play placeholder + public static final String PLAY_PLACEHOLDER = "play_placeholder"; - // layout representing the empty, no query state - public static final String EMPTY_STATE = "empty_state"; - - // layout representing search settings - public static final String SEARCH_SETTINGS = "launcher_settings"; + // Only available on or above version code 8 (UP1A) + // This layout is for educard. + public static final String EDUCARD = "educard"; } diff --git a/searchuilib/src/com/android/app/search/ResultType.java b/searchuilib/src/com/android/app/search/ResultType.java index 5340220..76fc6dd 100644 --- a/searchuilib/src/com/android/app/search/ResultType.java +++ b/searchuilib/src/com/android/app/search/ResultType.java @@ -37,15 +37,11 @@ public class ResultType { public static final int ACTION = 1 << 3; public static final int SETTING = 1 << 4; public static final int IMAGE = 1 << 5; - - @Deprecated(since = "Use IMAGE") - public static final int SCREENSHOT = IMAGE; - public static final int PLAY = 1 << 8; public static final int SUGGEST = 1 << 9; public static final int ASSISTANT = 1 << 10; public static final int CHROMETAB = 1 << 11; - public static final int NAVVYSITE = 1 << 12; + public static final int SESSION_INFO = 1 << 12; public static final int TIPS = 1 << 13; public static final int PEOPLE_TILE = 1 << 14; public static final int LEGACY_SHORTCUT = 1 << 15; @@ -55,13 +51,14 @@ public class ResultType { public static final int EDUCARD = 1 << 19; public static final int SYSTEM_POINTER = 1 << 20; public static final int VIDEO = 1 << 21; + public static final int LOCATION = 1 << 22; public static final int PUBLIC_DATA_TYPE = APPLICATION | SETTING | PLAY | WEB_SUGGEST; public static final int PRIMITIVE_TYPE = APPLICATION | SLICE | SHORTCUT | WIDGETS | ACTION | LEGACY_SHORTCUT; public static final int CORPUS_TYPE = - PEOPLE | SETTING | IMAGE | PLAY | SUGGEST | ASSISTANT | CHROMETAB | NAVVYSITE | TIPS - | PEOPLE_TILE | MEMORY | WEB_SUGGEST | VIDEO; + PEOPLE | SETTING | IMAGE | PLAY | SUGGEST | ASSISTANT | CHROMETAB | TIPS + | PEOPLE_TILE | MEMORY | WEB_SUGGEST | VIDEO | LOCATION; public static final int RANK_TYPE = SYSTEM_POINTER; public static final int UI_TYPE = EDUCARD | NO_FULFILLMENT; From 0910819eb014390585988af523ad1fa4f551039e Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Thu, 20 Nov 2025 21:59:36 +0700 Subject: [PATCH 17/19] build: Just disable the disabled variant of compose --- searchuilib/src/com/android/app/search/LayoutType.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/searchuilib/src/com/android/app/search/LayoutType.java b/searchuilib/src/com/android/app/search/LayoutType.java index f510c38..23ec4e9 100644 --- a/searchuilib/src/com/android/app/search/LayoutType.java +++ b/searchuilib/src/com/android/app/search/LayoutType.java @@ -156,4 +156,10 @@ public class LayoutType { // Only available on or above version code 8 (UP1A) // This layout is for educard. public static final String EDUCARD = "educard"; + + // layout representing the empty, no query state + public static final String EMPTY_STATE = "empty_state"; + + // layout representing search settings + public static final String SEARCH_SETTINGS = "launcher_settings"; } From 3a70b299a9f0e772d58bb4d1f7b6fd39fba4ac51 Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Thu, 20 Nov 2025 21:59:36 +0700 Subject: [PATCH 18/19] feat: Update Submodule --- searchuilib/src/com/android/app/search/LayoutType.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/searchuilib/src/com/android/app/search/LayoutType.java b/searchuilib/src/com/android/app/search/LayoutType.java index f510c38..23ec4e9 100644 --- a/searchuilib/src/com/android/app/search/LayoutType.java +++ b/searchuilib/src/com/android/app/search/LayoutType.java @@ -156,4 +156,10 @@ public class LayoutType { // Only available on or above version code 8 (UP1A) // This layout is for educard. public static final String EDUCARD = "educard"; + + // layout representing the empty, no query state + public static final String EMPTY_STATE = "empty_state"; + + // layout representing search settings + public static final String SEARCH_SETTINGS = "launcher_settings"; } From 7bd36b37dd893c95d46b6a83123a41dcc675f9e2 Mon Sep 17 00:00:00 2001 From: Pun Butrach Date: Sun, 23 Nov 2025 14:40:50 +0700 Subject: [PATCH 19/19] fix: Checkout CompatTier2 --- .../src/com/android/launcher3/icons/GraphicsUtils.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/iconloaderlib/src/com/android/launcher3/icons/GraphicsUtils.java b/iconloaderlib/src/com/android/launcher3/icons/GraphicsUtils.java index 1abac90..b17b006 100644 --- a/iconloaderlib/src/com/android/launcher3/icons/GraphicsUtils.java +++ b/iconloaderlib/src/com/android/launcher3/icons/GraphicsUtils.java @@ -95,7 +95,12 @@ public static void noteNewBitmapCreated() { */ public static int getAttrColor(Context context, int attr) { TypedArray ta = context.obtainStyledAttributes(new int[]{attr}); - int colorAccent = ta.getColor(0, 0); + // pE-TODO(CompatTier2): wtf? + int colorAccent = 0; + try { + colorAccent = ta.getColor(0, 0); + } catch (UnsupportedOperationException ignored) { + } ta.recycle(); return colorAccent; }