diff --git a/paparazzi-annotations/README.md b/paparazzi-annotations/README.md new file mode 100644 index 0000000000..dddff45184 --- /dev/null +++ b/paparazzi-annotations/README.md @@ -0,0 +1,114 @@ +# `@Paparazzi` +An annotation used to generate Paparazzi snapshots for composable preview functions. + +## Installation +Add the following to your `build.gradle` file + +```groovy +apply plugin: 'app.cash.paparazzi.preview' +``` + +## Basic Usage +Apply the annotation alongside an existing preview method. The annotation processor will generate a manifest of information about this method and the previews applied. + +```kotlin +import app.cash.paparazzi.preview.Paparazzi + +@Paparazzi +@Preview +@Composable +fun MyViewPreview() { + MyView(title = "Hello, Paparazzi Annotation") +} +``` + +Run `:recordPaparazziDebug` in your module to generate preview snapshots (and optionally verify them using `:verifyPaparazziDebug`) as you normally would. + +A test class to generate snapshots for annotated previews will automatically be generated. +If you prefer to define a custom snapshot test, you mey disable test generation by adding the following to your `build.gradle` file. + +```groovy +paparazziPreview { + generateTestClass = false +} +``` + +You may implement your own test class, as shown below, to create snapshots for all previews included in the generated manifest (`paparazziAnnotations`). + +```kotlin +import app.cash.paparazzi.Paparazzi +import app.cash.paparazzi.preview.PaparazziPreviewData +import app.cash.paparazzi.preview.PaparazziValuesProvider +import app.cash.paparazzi.preview.deviceConfig +import app.cash.paparazzi.preview.snapshot +import com.android.ide.common.rendering.api.SessionParams.RenderingMode.SHRINK +import com.google.testing.junit.testparameterinjector.TestParameter +import com.google.testing.junit.testparameterinjector.TestParameterInjector +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(TestParameterInjector::class) +class PreviewTests( + @TestParameter(valuesProvider = PreviewConfigValuesProvider::class) + private val preview: PaparazziPreviewData, +) { + private class PreviewConfigValuesProvider : PaparazziValuesProvider(paparazziPreviews) + + @get:Rule + val paparazzi = Paparazzi( + deviceConfig = preview.deviceConfig(), + renderingMode = SHRINK, + ) + + @Test + fun preview() { + paparazzi.snapshot(preview) + } +} +``` + +## Preview Parameter +If your preview function accepts a parameter using `@PreviewParameter`, then snapshots will be created for each combination of preview / param. + +```kotlin +@Paparazzi +@Preview +@Composable +fun MyViewPreview(@PreviewParameter(MyTitleProvider::class) title: String) { + MyView(title = title) +} + +class MyTitleProvider : PreviewParameterProvider { + override val values = sequenceOf("Hello", "Paparazzi", "Annotation") +} +``` + +## Composable Wrapping +If you need to apply additional UI treatment around your previews, you may provide a composable wrapper within the test. + +```kotlin +paparazzi.snapshot(preview) { content -> + Box(modifier = Modifier.background(Color.Gray)) { + content() + } +} +``` + +## Preview Composition +If you have multiple preview annotations applied to a function, or have them nested behind a custom annotation, they will all be included in the snapshot manifest. + +```kotlin +@Paparazzi +@ScaledThemedPreviews +@Composable +fun MyViewPreview() { + MyView(title = "Hello, Paparazzi Annotation") +} + +@Preview(name = "small light", fontScale = 1f, uiMode = Configuration.UI_MODE_NIGHT_NO, device = PIXEL_3_XL) +@Preview(name = "small dark", fontScale = 1f, uiMode = Configuration.UI_MODE_NIGHT_YES, device = PIXEL_3_XL) +@Preview(name = "large light", fontScale = 2f, uiMode = Configuration.UI_MODE_NIGHT_NO, device = PIXEL_3_XL) +@Preview(name = "large dark", fontScale = 2f, uiMode = Configuration.UI_MODE_NIGHT_YES, device = PIXEL_3_XL) +annotation class ScaledThemedPreviews +``` diff --git a/paparazzi-annotations/api/paparazzi-annotations.api b/paparazzi-annotations/api/paparazzi-annotations.api index 913e18bbf8..80927b48fb 100644 --- a/paparazzi-annotations/api/paparazzi-annotations.api +++ b/paparazzi-annotations/api/paparazzi-annotations.api @@ -5,13 +5,15 @@ public abstract interface class app/cash/paparazzi/annotations/PaparazziPreviewD } public final class app/cash/paparazzi/annotations/PaparazziPreviewData$Default : app/cash/paparazzi/annotations/PaparazziPreviewData { - public fun (Ljava/lang/String;Lkotlin/jvm/functions/Function0;)V + public fun (Ljava/lang/String;Lapp/cash/paparazzi/annotations/PreviewData;Lkotlin/jvm/functions/Function0;)V public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Lkotlin/jvm/functions/Function0; - public final fun copy (Ljava/lang/String;Lkotlin/jvm/functions/Function0;)Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Default; - public static synthetic fun copy$default (Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Default;Ljava/lang/String;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Default; + public final fun component2 ()Lapp/cash/paparazzi/annotations/PreviewData; + public final fun component3 ()Lkotlin/jvm/functions/Function0; + public final fun copy (Ljava/lang/String;Lapp/cash/paparazzi/annotations/PreviewData;Lkotlin/jvm/functions/Function0;)Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Default; + public static synthetic fun copy$default (Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Default;Ljava/lang/String;Lapp/cash/paparazzi/annotations/PreviewData;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Default; public fun equals (Ljava/lang/Object;)Z public final fun getComposable ()Lkotlin/jvm/functions/Function0; + public final fun getPreview ()Lapp/cash/paparazzi/annotations/PreviewData; public final fun getSnapshotName ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; @@ -25,15 +27,85 @@ public final class app/cash/paparazzi/annotations/PaparazziPreviewData$Empty : a } public final class app/cash/paparazzi/annotations/PaparazziPreviewData$Error : app/cash/paparazzi/annotations/PaparazziPreviewData { - public fun (Ljava/lang/String;Ljava/lang/String;)V + public fun (Ljava/lang/String;Lapp/cash/paparazzi/annotations/PreviewData;Ljava/lang/String;)V public final fun component1 ()Ljava/lang/String; - public final fun component2 ()Ljava/lang/String; - public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Error; - public static synthetic fun copy$default (Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Error;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Error; + public final fun component2 ()Lapp/cash/paparazzi/annotations/PreviewData; + public final fun component3 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Lapp/cash/paparazzi/annotations/PreviewData;Ljava/lang/String;)Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Error; + public static synthetic fun copy$default (Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Error;Ljava/lang/String;Lapp/cash/paparazzi/annotations/PreviewData;Ljava/lang/String;ILjava/lang/Object;)Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Error; public fun equals (Ljava/lang/Object;)Z public final fun getMessage ()Ljava/lang/String; + public final fun getPreview ()Lapp/cash/paparazzi/annotations/PreviewData; public final fun getSnapshotName ()Ljava/lang/String; public fun hashCode ()I public fun toString ()Ljava/lang/String; } +public final class app/cash/paparazzi/annotations/PaparazziPreviewData$Provider : app/cash/paparazzi/annotations/PaparazziPreviewData { + public static final field $stable I + public fun (Ljava/lang/String;Lapp/cash/paparazzi/annotations/PreviewData;Lkotlin/jvm/functions/Function3;Lapp/cash/paparazzi/annotations/PreviewParameterData;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Lapp/cash/paparazzi/annotations/PreviewData; + public final fun component3 ()Lkotlin/jvm/functions/Function3; + public final fun component4 ()Lapp/cash/paparazzi/annotations/PreviewParameterData; + public final fun copy (Ljava/lang/String;Lapp/cash/paparazzi/annotations/PreviewData;Lkotlin/jvm/functions/Function3;Lapp/cash/paparazzi/annotations/PreviewParameterData;)Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Provider; + public static synthetic fun copy$default (Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Provider;Ljava/lang/String;Lapp/cash/paparazzi/annotations/PreviewData;Lkotlin/jvm/functions/Function3;Lapp/cash/paparazzi/annotations/PreviewParameterData;ILjava/lang/Object;)Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Provider; + public fun equals (Ljava/lang/Object;)Z + public final fun getComposable ()Lkotlin/jvm/functions/Function3; + public final fun getPreview ()Lapp/cash/paparazzi/annotations/PreviewData; + public final fun getPreviewParameter ()Lapp/cash/paparazzi/annotations/PreviewParameterData; + public final fun getSnapshotName ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; + public final fun withPreviewParameterIndex (I)Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Provider; +} + +public final class app/cash/paparazzi/annotations/PaparazziPreviewDefaults { + public static final field $stable I + public static final field DEVICE_ID Ljava/lang/String; + public static final field INSTANCE Lapp/cash/paparazzi/annotations/PaparazziPreviewDefaults; +} + +public final class app/cash/paparazzi/annotations/PreviewData { + public static final field $stable I + public fun ()V + public fun (Ljava/lang/Float;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (Ljava/lang/Float;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/Float; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/Integer; + public final fun component4 ()Ljava/lang/Integer; + public final fun component5 ()Ljava/lang/Integer; + public final fun component6 ()Ljava/lang/String; + public final fun component7 ()Ljava/lang/String; + public final fun copy (Ljava/lang/Float;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;)Lapp/cash/paparazzi/annotations/PreviewData; + public static synthetic fun copy$default (Lapp/cash/paparazzi/annotations/PreviewData;Ljava/lang/Float;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lapp/cash/paparazzi/annotations/PreviewData; + public fun equals (Ljava/lang/Object;)Z + public final fun getBackgroundColor ()Ljava/lang/String; + public final fun getDevice ()Ljava/lang/String; + public final fun getFontScale ()Ljava/lang/Float; + public final fun getHeightDp ()Ljava/lang/Integer; + public final fun getLocale ()Ljava/lang/String; + public final fun getUiMode ()Ljava/lang/Integer; + public final fun getWidthDp ()Ljava/lang/Integer; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class app/cash/paparazzi/annotations/PreviewParameterData { + public static final field $stable I + public fun (Ljava/lang/String;Lkotlin/sequences/Sequence;I)V + public synthetic fun (Ljava/lang/String;Lkotlin/sequences/Sequence;IILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Lkotlin/sequences/Sequence; + public final fun component3 ()I + public final fun copy (Ljava/lang/String;Lkotlin/sequences/Sequence;I)Lapp/cash/paparazzi/annotations/PreviewParameterData; + public static synthetic fun copy$default (Lapp/cash/paparazzi/annotations/PreviewParameterData;Ljava/lang/String;Lkotlin/sequences/Sequence;IILjava/lang/Object;)Lapp/cash/paparazzi/annotations/PreviewParameterData; + public fun equals (Ljava/lang/Object;)Z + public final fun getIndex ()I + public final fun getName ()Ljava/lang/String; + public final fun getValues ()Lkotlin/sequences/Sequence; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + diff --git a/paparazzi-annotations/build.gradle b/paparazzi-annotations/build.gradle index f97b16750b..6a9c7e28e8 100644 --- a/paparazzi-annotations/build.gradle +++ b/paparazzi-annotations/build.gradle @@ -4,4 +4,5 @@ apply plugin: 'com.vanniktech.maven.publish' dependencies { compileOnly libs.compose.runtime + compileOnly libs.tools.layoutlib } diff --git a/paparazzi-annotations/src/main/java/app/cash/paparazzi/annotations/PaparazziPreviewData.kt b/paparazzi-annotations/src/main/java/app/cash/paparazzi/annotations/PaparazziPreviewData.kt index c52825bce1..283ad3f2ad 100644 --- a/paparazzi-annotations/src/main/java/app/cash/paparazzi/annotations/PaparazziPreviewData.kt +++ b/paparazzi-annotations/src/main/java/app/cash/paparazzi/annotations/PaparazziPreviewData.kt @@ -1,14 +1,38 @@ package app.cash.paparazzi.annotations +import android.content.res.Configuration import androidx.compose.runtime.Composable +public object PaparazziPreviewDefaults { + public const val DEVICE_ID: String = "id:pixel_5" +} + public sealed interface PaparazziPreviewData { public data class Default( val snapshotName: String, + val preview: PreviewData, val composable: @Composable () -> Unit ) : PaparazziPreviewData { - override fun toString(): String = snapshotName + override fun toString(): String = buildList { + add(snapshotName) + preview.toString().takeIf { it.isNotEmpty() }?.let(::add) + }.joinToString(",") + } + + public data class Provider( + val snapshotName: String, + val preview: PreviewData, + val composable: @Composable (T) -> Unit, + val previewParameter: PreviewParameterData + ) : PaparazziPreviewData { + override fun toString(): String = buildList { + add(snapshotName) + preview.toString().takeIf { it.isNotEmpty() }?.let(::add) + add(previewParameter.toString()) + }.joinToString(",") + + public fun withPreviewParameterIndex(index: Int): Provider = copy(previewParameter = previewParameter.copy(index = index)) } public data object Empty : PaparazziPreviewData { @@ -17,8 +41,84 @@ public sealed interface PaparazziPreviewData { public data class Error( val snapshotName: String, + val preview: PreviewData, val message: String ) : PaparazziPreviewData { - override fun toString(): String = snapshotName + override fun toString(): String = buildList { + add(snapshotName) + preview.toString().takeIf { it.isNotEmpty() }?.let(::add) + }.joinToString(",") + } +} + +public data class PreviewData( + val fontScale: Float? = null, + val device: String? = null, + val widthDp: Int? = null, + val heightDp: Int? = null, + val uiMode: Int? = null, + val locale: String? = null, + val backgroundColor: String? = null +) { + override fun toString(): String = buildList { + fontScale?.fontScale()?.displayName()?.let(::add) + uiMode?.lightDarkName()?.let(::add) + uiMode?.uiModeName()?.let(::add) + device?.let { + if (it != PaparazziPreviewDefaults.DEVICE_ID) { + add(it.substringAfterLast(":")) + } + } + widthDp?.let { add("w_$it") } + heightDp?.let { add("h_$it") } + locale?.let(::add) + backgroundColor?.let { add("bg_$it") } + }.takeIf { it.isNotEmpty() } + ?.joinToString(",") + ?: "" +} + +public data class PreviewParameterData( + val name: String, + val values: Sequence, + val index: Int = 0 +) { + override fun toString(): String = "$name$index" +} + +/** + * Maps [fontScale] to enum values similar to Preview + * see: +https://android.googlesource.com/platform/tools/adt/idea/+/refs/heads/mirror-goog-studio-main/compose-designer/src/com/android/tools/idea/compose/pickers/preview/enumsupport/PsiEnumValues.kt + */ +internal fun Float.fontScale() = + FontScale.values().find { this == it.value } ?: FontScale.CUSTOM.apply { value = this@fontScale } + +internal enum class FontScale(var value: Float?) { + DEFAULT(1f), + SMALL(0.85f), + LARGE(1.15f), + LARGEST(1.30f), + CUSTOM(null); + + fun displayName() = when (this) { + CUSTOM -> "fs_$value" + else -> name } } + +internal fun Int.lightDarkName() = when (this and Configuration.UI_MODE_NIGHT_MASK) { + Configuration.UI_MODE_NIGHT_NO -> "Light" + Configuration.UI_MODE_NIGHT_YES -> "Dark" + else -> null +} + +internal fun Int.uiModeName() = when (this and Configuration.UI_MODE_TYPE_MASK) { + Configuration.UI_MODE_TYPE_NORMAL -> "Normal" + Configuration.UI_MODE_TYPE_CAR -> "Car" + Configuration.UI_MODE_TYPE_DESK -> "Desk" + Configuration.UI_MODE_TYPE_APPLIANCE -> "Appliance" + Configuration.UI_MODE_TYPE_WATCH -> "Watch" + Configuration.UI_MODE_TYPE_VR_HEADSET -> "VR_Headset" + else -> null +} diff --git a/paparazzi-gradle-plugin/build.gradle b/paparazzi-gradle-plugin/build.gradle index e5ae3979b9..5ea547b5f3 100644 --- a/paparazzi-gradle-plugin/build.gradle +++ b/paparazzi-gradle-plugin/build.gradle @@ -66,6 +66,7 @@ tasks.withType(Test).configureEach { dependsOn(':paparazzi:publishMavenPublicationToProjectLocalMavenRepository') dependsOn(':paparazzi-annotations:publishMavenPublicationToProjectLocalMavenRepository') dependsOn(':paparazzi-preview-processor:publishMavenPublicationToProjectLocalMavenRepository') + dependsOn(':paparazzi-preview-test:publishMavenPublicationToProjectLocalMavenRepository') } // When cleaning this project, we also want to clean the test projects. diff --git a/paparazzi-gradle-plugin/src/main/java/app/cash/paparazzi/gradle/PaparazziPlugin.kt b/paparazzi-gradle-plugin/src/main/java/app/cash/paparazzi/gradle/PaparazziPlugin.kt index 4b4d93dd3d..63784fefc8 100644 --- a/paparazzi-gradle-plugin/src/main/java/app/cash/paparazzi/gradle/PaparazziPlugin.kt +++ b/paparazzi-gradle-plugin/src/main/java/app/cash/paparazzi/gradle/PaparazziPlugin.kt @@ -251,6 +251,7 @@ public class PaparazziPlugin @Inject constructor( project.addAnnotationsDependency() project.addProcessorDependency() + project.addPreviewTestDependency() project.registerGeneratePreviewTask(config, extension) project.afterEvaluate { @@ -343,6 +344,15 @@ public class PaparazziPlugin @Inject constructor( configurations.getByName("ksp").dependencies.add(dependency) } + private fun Project.addPreviewTestDependency() { + val dependency = if (isInternal()) { + dependencies.project(mapOf("path" to ":paparazzi-preview-test")) + } else { + dependencies.create("app.cash.paparazzi:paparazzi-preview-test:$VERSION") + } + configurations.getByName("testImplementation").dependencies.add(dependency) + } + private fun Project.isInternal(): Boolean = providers.gradleProperty("app.cash.paparazzi.internal").orNull == "true" diff --git a/paparazzi-gradle-plugin/src/main/java/app/cash/paparazzi/gradle/PreviewTests.kt b/paparazzi-gradle-plugin/src/main/java/app/cash/paparazzi/gradle/PreviewTests.kt index 8c34bfe1cc..0b353e49f5 100644 --- a/paparazzi-gradle-plugin/src/main/java/app/cash/paparazzi/gradle/PreviewTests.kt +++ b/paparazzi-gradle-plugin/src/main/java/app/cash/paparazzi/gradle/PreviewTests.kt @@ -1,9 +1,9 @@ package app.cash.paparazzi.gradle -private const val PREVIEW_TEST_SOURCE = """ +internal const val PREVIEW_TEST_SOURCE = """ import app.cash.paparazzi.Paparazzi +import app.cash.paparazzi.annotations.PaparazziPreviewData import app.cash.paparazzi.preview.DefaultLocaleRule -import app.cash.paparazzi.preview.PaparazziPreviewData import app.cash.paparazzi.preview.PaparazziValuesProvider import app.cash.paparazzi.preview.deviceConfig import app.cash.paparazzi.preview.locale diff --git a/paparazzi-gradle-plugin/src/main/java/app/cash/paparazzi/gradle/utils/PreviewUtils.kt b/paparazzi-gradle-plugin/src/main/java/app/cash/paparazzi/gradle/utils/PreviewUtils.kt index 881f9c48f5..4bce5c4cb1 100644 --- a/paparazzi-gradle-plugin/src/main/java/app/cash/paparazzi/gradle/utils/PreviewUtils.kt +++ b/paparazzi-gradle-plugin/src/main/java/app/cash/paparazzi/gradle/utils/PreviewUtils.kt @@ -1,5 +1,6 @@ package app.cash.paparazzi.gradle.utils +import app.cash.paparazzi.gradle.PREVIEW_TEST_SOURCE import app.cash.paparazzi.gradle.PaparazziExtension import com.android.build.api.variant.AndroidComponentsExtension import com.android.build.api.variant.HasUnitTest @@ -81,36 +82,3 @@ internal fun Project.registerGeneratePreviewTask( private fun String.capitalize() = replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.ROOT) else it.toString() } - -private const val PREVIEW_TEST_SOURCE = """ -import app.cash.paparazzi.Paparazzi -import app.cash.paparazzi.annotations.PaparazziPreviewData -import app.cash.paparazzi.preview.PaparazziValuesProvider -import app.cash.paparazzi.preview.snapshot -import com.android.ide.common.rendering.api.SessionParams.RenderingMode.SHRINK -import com.google.testing.junit.testparameterinjector.TestParameter -import com.google.testing.junit.testparameterinjector.TestParameterInjector -import org.junit.Assume.assumeTrue -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(TestParameterInjector::class) -class PreviewTests( - @TestParameter(valuesProvider = PreviewConfigValuesProvider::class) - private val preview: PaparazziPreviewData, -) { - private class PreviewConfigValuesProvider : PaparazziValuesProvider(paparazziPreviews) - - @get:Rule - val paparazzi = Paparazzi( - renderingMode = SHRINK, - ) - - @Test - fun preview() { - assumeTrue(preview !is PaparazziPreviewData.Empty) - paparazzi.snapshot(preview) - } -} -""" diff --git a/paparazzi-gradle-plugin/src/test/java/app/cash/paparazzi/gradle/PaparazziPluginTest.kt b/paparazzi-gradle-plugin/src/test/java/app/cash/paparazzi/gradle/PaparazziPluginTest.kt index d888559a41..a2ed72cca4 100644 --- a/paparazzi-gradle-plugin/src/test/java/app/cash/paparazzi/gradle/PaparazziPluginTest.kt +++ b/paparazzi-gradle-plugin/src/test/java/app/cash/paparazzi/gradle/PaparazziPluginTest.kt @@ -1470,7 +1470,7 @@ class PaparazziPluginTest { val result = gradleRunner .forwardOutput() .withArguments("verifyPaparazziDebug", "--stacktrace") - .runFixture(fixtureRoot) { buildAndFail() } // Currently fails because of preview parameter usage + .runFixture(fixtureRoot) { build() } // Currently fails because of preview parameter usage assertThat(result.task(":paparazziGeneratePreviewDebugUnitTestKotlin")).isNotNull() diff --git a/paparazzi-gradle-plugin/src/test/projects/preview-annotation-sample/src/test/snapshots/images/app.cash.paparazzi.plugin.test_PreviewTests_preview[HelloPaparazzi_HelloPaparazziParameterized,text0].png b/paparazzi-gradle-plugin/src/test/projects/preview-annotation-sample/src/test/snapshots/images/app.cash.paparazzi.plugin.test_PreviewTests_preview[HelloPaparazzi_HelloPaparazziParameterized,text0].png new file mode 100644 index 0000000000..9b577c683c Binary files /dev/null and b/paparazzi-gradle-plugin/src/test/projects/preview-annotation-sample/src/test/snapshots/images/app.cash.paparazzi.plugin.test_PreviewTests_preview[HelloPaparazzi_HelloPaparazziParameterized,text0].png differ diff --git a/paparazzi-gradle-plugin/src/test/projects/preview-annotation-sample/src/test/snapshots/images/app.cash.paparazzi.plugin.test_PreviewTests_preview[HelloPaparazzi_HelloPaparazziParameterized,text1].png b/paparazzi-gradle-plugin/src/test/projects/preview-annotation-sample/src/test/snapshots/images/app.cash.paparazzi.plugin.test_PreviewTests_preview[HelloPaparazzi_HelloPaparazziParameterized,text1].png new file mode 100644 index 0000000000..fa3f1a85cb Binary files /dev/null and b/paparazzi-gradle-plugin/src/test/projects/preview-annotation-sample/src/test/snapshots/images/app.cash.paparazzi.plugin.test_PreviewTests_preview[HelloPaparazzi_HelloPaparazziParameterized,text1].png differ diff --git a/paparazzi-gradle-plugin/src/test/projects/preview-annotation-sample/src/test/snapshots/images/app.cash.paparazzi.plugin.test_PreviewTests_preview[HelloPaparazzi_HelloPaparazziPreviewConfig,fs_1.5,Dark,Normal,Nexus_6].png b/paparazzi-gradle-plugin/src/test/projects/preview-annotation-sample/src/test/snapshots/images/app.cash.paparazzi.plugin.test_PreviewTests_preview[HelloPaparazzi_HelloPaparazziPreviewConfig,fs_1.5,Dark,Normal,Nexus_6].png new file mode 100644 index 0000000000..08449fa476 Binary files /dev/null and b/paparazzi-gradle-plugin/src/test/projects/preview-annotation-sample/src/test/snapshots/images/app.cash.paparazzi.plugin.test_PreviewTests_preview[HelloPaparazzi_HelloPaparazziPreviewConfig,fs_1.5,Dark,Normal,Nexus_6].png differ diff --git a/paparazzi-gradle-plugin/src/test/projects/preview-annotation-sample/src/test/snapshots/images/app.cash.paparazzi.plugin.test_PreviewTests_preview[HelloPaparazzi_HelloPaparazziPreviewConfig].png b/paparazzi-gradle-plugin/src/test/projects/preview-annotation-sample/src/test/snapshots/images/app.cash.paparazzi.plugin.test_PreviewTests_preview[HelloPaparazzi_HelloPaparazziPreviewConfig].png deleted file mode 100644 index 828d6d4c56..0000000000 Binary files a/paparazzi-gradle-plugin/src/test/projects/preview-annotation-sample/src/test/snapshots/images/app.cash.paparazzi.plugin.test_PreviewTests_preview[HelloPaparazzi_HelloPaparazziPreviewConfig].png and /dev/null differ diff --git a/paparazzi-gradle-plugin/src/test/projects/preview-annotation-sample/src/test/snapshots/images/app.cash.paparazzi.plugin.test_PreviewTests_preview[HelloPaparazzi_HelloPaparazzi].png b/paparazzi-gradle-plugin/src/test/projects/preview-annotation-sample/src/test/snapshots/images/app.cash.paparazzi.plugin.test_PreviewTests_preview[HelloPaparazzi_HelloPaparazzi].png index d838caf940..da18192acb 100644 Binary files a/paparazzi-gradle-plugin/src/test/projects/preview-annotation-sample/src/test/snapshots/images/app.cash.paparazzi.plugin.test_PreviewTests_preview[HelloPaparazzi_HelloPaparazzi].png and b/paparazzi-gradle-plugin/src/test/projects/preview-annotation-sample/src/test/snapshots/images/app.cash.paparazzi.plugin.test_PreviewTests_preview[HelloPaparazzi_HelloPaparazzi].png differ diff --git a/paparazzi-preview-processor/api/paparazzi-preview-processor.api b/paparazzi-preview-processor/api/paparazzi-preview-processor.api index 4c9c65ac6a..c448d927fc 100644 --- a/paparazzi-preview-processor/api/paparazzi-preview-processor.api +++ b/paparazzi-preview-processor/api/paparazzi-preview-processor.api @@ -9,3 +9,7 @@ public final class app/cash/paparazzi/preview/processor/PreviewProcessorProvider public synthetic fun create (Lcom/google/devtools/ksp/processing/SymbolProcessorEnvironment;)Lcom/google/devtools/ksp/processing/SymbolProcessor; } +public final class app/cash/paparazzi/preview/processor/UtilsKt { + public static final fun previewArg (Lcom/google/devtools/ksp/symbol/KSAnnotation;Ljava/lang/String;)Ljava/lang/Object; +} + diff --git a/paparazzi-preview-processor/src/main/java/app/cash/paparazzi/preview/processor/PaparazziPoet.kt b/paparazzi-preview-processor/src/main/java/app/cash/paparazzi/preview/processor/PaparazziPoet.kt index 47c3bb28c8..b218186cce 100644 --- a/paparazzi-preview-processor/src/main/java/app/cash/paparazzi/preview/processor/PaparazziPoet.kt +++ b/paparazzi-preview-processor/src/main/java/app/cash/paparazzi/preview/processor/PaparazziPoet.kt @@ -1,6 +1,8 @@ package app.cash.paparazzi.preview.processor +import com.google.devtools.ksp.closestClassDeclaration import com.google.devtools.ksp.getVisibility +import com.google.devtools.ksp.symbol.ClassKind import com.google.devtools.ksp.symbol.KSFunctionDeclaration import com.google.devtools.ksp.symbol.KSValueParameter import com.google.devtools.ksp.symbol.Visibility @@ -21,7 +23,7 @@ internal object PaparazziPoet { listOf( buildAnnotationsFile( fileName = "PaparazziPreviews", - propertyName = "PaparazziPreviews", "paparazziPreviews", + propertyName = "paparazziPreviews", "paparazziPreviews", functions = functions, env = env ) @@ -44,8 +46,8 @@ internal object PaparazziPoet { if (functions.count() == 0) { addEmpty() } else { - functions.process { func, previewParam -> - val visibilityCheck = checkVisibility(func) + functions.process { func, preview, previewParam -> + val visibilityCheck = checkVisibility(func, previewParam) val snapshotName = func.snapshotName(env) when { @@ -53,21 +55,19 @@ internal object PaparazziPoet { visibilityCheck = visibilityCheck, function = func, snapshotName = snapshotName, - buildErrorMessage = { - "$it is private. Make it internal or public to generate a snapshot." - } + preview = preview, + previewParam = previewParam ) - previewParam != null -> addError( - visibilityCheck = visibilityCheck, + previewParam != null -> addProvider( function = func, snapshotName = snapshotName, - buildErrorMessage = { - "$it preview uses PreviewParameters which aren't currently supported." - } + preview = preview, + previewParam = previewParam ) else -> addDefault( function = func, - snapshotName = snapshotName + snapshotName = snapshotName, + preview = preview ) } } @@ -84,32 +84,56 @@ internal object PaparazziPoet { } private fun Sequence.process( - block: (KSFunctionDeclaration, KSValueParameter?) -> Unit + block: (KSFunctionDeclaration, PreviewModel, KSValueParameter?) -> Unit ) = flatMap { func -> val previewParam = func.previewParam() func.findDistinctPreviews() - .map { Pair(func, previewParam) } - }.forEach { (func, previewParam) -> - block(func, previewParam) + .map { AcceptableAnnotationsProcessData(func, it, previewParam) } + }.forEach { (func, preview, previewParam) -> + block(func, preview, previewParam) } + private data class AcceptableAnnotationsProcessData( + val func: KSFunctionDeclaration, + val model: PreviewModel, + val previewParam: KSValueParameter? + ) + private fun CodeBlock.Builder.addError( visibilityCheck: VisibilityCheck, function: KSFunctionDeclaration, snapshotName: String, - buildErrorMessage: (String?) -> String + preview: PreviewModel, + previewParam: KSValueParameter? ) { val qualifiedName = if (visibilityCheck.isFunctionPrivate) { function.qualifiedName?.asString() } else { - null + previewParam?.previewParamProvider()?.qualifiedName?.asString() } addStatement("%L.PaparazziPreviewData.Error(", PACKAGE_NAME) indent() addStatement("snapshotName = %S,", snapshotName) - addStatement("message = %S,", buildErrorMessage(qualifiedName)) + addStatement("message = %S,", "$qualifiedName is private. Make it internal or public to generate a snapshot.") + addPreviewData(preview) + unindent() + addStatement("),") + } + + private fun CodeBlock.Builder.addProvider( + function: KSFunctionDeclaration, + snapshotName: String, + preview: PreviewModel, + previewParam: KSValueParameter + ) { + addStatement("%L.PaparazziPreviewData.Provider(", PACKAGE_NAME) + indent() + addStatement("snapshotName = %S,", snapshotName) + addStatement("composable = { %L(it) },", function.qualifiedName?.asString()) + addPreviewParameterData(previewParam) + addPreviewData(preview) unindent() addStatement("),") } @@ -128,12 +152,55 @@ internal object PaparazziPoet { private fun CodeBlock.Builder.addDefault( function: KSFunctionDeclaration, - snapshotName: String + snapshotName: String, + preview: PreviewModel ) { addStatement("%L.PaparazziPreviewData.Default(", PACKAGE_NAME) indent() addStatement("snapshotName = %S,", snapshotName) addStatement("composable = { %L() },", function.qualifiedName?.asString()) + addPreviewData(preview) + unindent() + addStatement("),") + } + + private fun CodeBlock.Builder.addPreviewData(preview: PreviewModel) { + addStatement("preview = %L.PreviewData(", PACKAGE_NAME) + indent() + + preview.fontScale.takeIf { it != 1f } + ?.let { addStatement("fontScale = %Lf,", it) } + + preview.device.takeIf { it.isNotEmpty() } + ?.let { addStatement("device = %S,", it) } + + preview.widthDp.takeIf { it > -1 } + ?.let { addStatement("widthDp = %L,", it) } + + preview.heightDp.takeIf { it > -1 } + ?.let { addStatement("heightDp = %L,", it) } + + preview.uiMode.takeIf { it != 0 } + ?.let { addStatement("uiMode = %L,", it) } + + preview.locale.takeIf { it.isNotEmpty() } + ?.let { addStatement("locale = %S,", it) } + + preview.backgroundColor.takeIf { it != 0L && preview.showBackground } + ?.let { addStatement("backgroundColor = %S", it.toString(16)) } + + unindent() + addStatement("),") + } + + private fun CodeBlock.Builder.addPreviewParameterData(previewParam: KSValueParameter) { + addStatement("previewParameter = %L.PreviewParameterData(", PACKAGE_NAME) + indent() + addStatement("name = %S,", previewParam.name?.asString()) + val previewParamProvider = previewParam.previewParamProvider() + val isClassObject = previewParamProvider.closestClassDeclaration()?.classKind == ClassKind.OBJECT + val previewParamProviderInstantiation = "${previewParamProvider.qualifiedName?.asString()}${if (isClassObject) "" else "()"}" + addStatement("values = %L.values,", previewParamProviderInstantiation) unindent() addStatement("),") } @@ -149,14 +216,17 @@ internal object PaparazziPoet { }.joinToString("_") private fun checkVisibility( - function: KSFunctionDeclaration + function: KSFunctionDeclaration, + previewParam: KSValueParameter? ) = VisibilityCheck( - isFunctionPrivate = function.getVisibility() == Visibility.PRIVATE + isFunctionPrivate = function.getVisibility() == Visibility.PRIVATE, + isPreviewParamProviderPrivate = previewParam?.previewParamProvider()?.getVisibility() == Visibility.PRIVATE ) } internal data class VisibilityCheck( - val isFunctionPrivate: Boolean + val isFunctionPrivate: Boolean, + val isPreviewParamProviderPrivate: Boolean ) { val isPrivate = isFunctionPrivate } diff --git a/paparazzi-preview-processor/src/main/java/app/cash/paparazzi/preview/processor/Utils.kt b/paparazzi-preview-processor/src/main/java/app/cash/paparazzi/preview/processor/Utils.kt index 0deb57adcd..e6f0559da9 100644 --- a/paparazzi-preview-processor/src/main/java/app/cash/paparazzi/preview/processor/Utils.kt +++ b/paparazzi-preview-processor/src/main/java/app/cash/paparazzi/preview/processor/Utils.kt @@ -4,6 +4,8 @@ import com.google.devtools.ksp.symbol.FunctionKind.TOP_LEVEL import com.google.devtools.ksp.symbol.KSAnnotated import com.google.devtools.ksp.symbol.KSAnnotation import com.google.devtools.ksp.symbol.KSFunctionDeclaration +import com.google.devtools.ksp.symbol.KSType +import com.google.devtools.ksp.symbol.KSValueParameter internal const val PACKAGE_NAME = "app.cash.paparazzi.annotations" @@ -14,6 +16,11 @@ internal fun KSAnnotation.isPreviewParameter() = qualifiedName() == "androidx.co internal fun KSAnnotation.qualifiedName() = declaration().qualifiedName?.asString() ?: "" internal fun KSAnnotation.declaration() = annotationType.resolve().declaration +@Suppress("UNCHECKED_CAST") +public fun KSAnnotation.previewArg(name: String): T = arguments + .first { it.name?.asString() == name } + .let { it.value as T } + internal fun Sequence.findPaparazzi() = filterIsInstance() .filter { @@ -35,12 +42,43 @@ internal fun Sequence.findPreviews(stack: Set = setO return direct.plus(indirect) } -internal fun KSFunctionDeclaration.findDistinctPreviews() = annotations.findPreviews().distinct() +internal fun KSFunctionDeclaration.findDistinctPreviews() = annotations.findPreviews().toList() + .map { preview -> + PreviewModel( + fontScale = preview.previewArg("fontScale"), + device = preview.previewArg("device"), + widthDp = preview.previewArg("widthDp"), + heightDp = preview.previewArg("heightDp"), + uiMode = preview.previewArg("uiMode"), + locale = preview.previewArg("locale"), + backgroundColor = preview.previewArg("backgroundColor"), + showBackground = preview.previewArg("showBackground") + ) + } + .distinct() internal fun KSFunctionDeclaration.previewParam() = parameters.firstOrNull { param -> param.annotations.any { it.isPreviewParameter() } } +internal fun KSValueParameter.previewParamProvider() = annotations + .first { it.isPreviewParameter() } + .arguments + .first { arg -> arg.name?.asString() == "provider" } + .let { it.value as KSType } + .declaration + +internal data class PreviewModel( + val fontScale: Float, + val device: String, + val widthDp: Int, + val heightDp: Int, + val uiMode: Int, + val locale: String, + val backgroundColor: Long, + val showBackground: Boolean +) + internal data class EnvironmentOptions( val namespace: String ) diff --git a/paparazzi-preview-test/api/paparazzi-annotations.api b/paparazzi-preview-test/api/paparazzi-annotations.api new file mode 100644 index 0000000000..913e18bbf8 --- /dev/null +++ b/paparazzi-preview-test/api/paparazzi-annotations.api @@ -0,0 +1,39 @@ +public abstract interface annotation class app/cash/paparazzi/annotations/Paparazzi : java/lang/annotation/Annotation { +} + +public abstract interface class app/cash/paparazzi/annotations/PaparazziPreviewData { +} + +public final class app/cash/paparazzi/annotations/PaparazziPreviewData$Default : app/cash/paparazzi/annotations/PaparazziPreviewData { + public fun (Ljava/lang/String;Lkotlin/jvm/functions/Function0;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Lkotlin/jvm/functions/Function0; + public final fun copy (Ljava/lang/String;Lkotlin/jvm/functions/Function0;)Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Default; + public static synthetic fun copy$default (Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Default;Ljava/lang/String;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Default; + public fun equals (Ljava/lang/Object;)Z + public final fun getComposable ()Lkotlin/jvm/functions/Function0; + public final fun getSnapshotName ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class app/cash/paparazzi/annotations/PaparazziPreviewData$Empty : app/cash/paparazzi/annotations/PaparazziPreviewData { + public static final field INSTANCE Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Empty; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class app/cash/paparazzi/annotations/PaparazziPreviewData$Error : app/cash/paparazzi/annotations/PaparazziPreviewData { + public fun (Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Error; + public static synthetic fun copy$default (Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Error;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lapp/cash/paparazzi/annotations/PaparazziPreviewData$Error; + public fun equals (Ljava/lang/Object;)Z + public final fun getMessage ()Ljava/lang/String; + public final fun getSnapshotName ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + diff --git a/paparazzi-preview-test/api/paparazzi-preview-test.api b/paparazzi-preview-test/api/paparazzi-preview-test.api new file mode 100644 index 0000000000..59a26fe6ad --- /dev/null +++ b/paparazzi-preview-test/api/paparazzi-preview-test.api @@ -0,0 +1,43 @@ +public final class app/cash/paparazzi/preview/ComposableSingletons$SnapshotKt { + public static final field INSTANCE Lapp/cash/paparazzi/preview/ComposableSingletons$SnapshotKt; + public static field lambda-1 Lkotlin/jvm/functions/Function3; + public fun ()V + public final fun getLambda-1$paparazzi_preview_test ()Lkotlin/jvm/functions/Function3; +} + +public final class app/cash/paparazzi/preview/ComposableSingletons$UtilsKt { + public static final field INSTANCE Lapp/cash/paparazzi/preview/ComposableSingletons$UtilsKt; + public static field lambda-1 Lkotlin/jvm/functions/Function3; + public static field lambda-2 Lkotlin/jvm/functions/Function3; + public static field lambda-3 Lkotlin/jvm/functions/Function3; + public fun ()V + public final fun getLambda-1$paparazzi_preview_test ()Lkotlin/jvm/functions/Function3; + public final fun getLambda-2$paparazzi_preview_test ()Lkotlin/jvm/functions/Function3; + public final fun getLambda-3$paparazzi_preview_test ()Lkotlin/jvm/functions/Function3; +} + +public final class app/cash/paparazzi/preview/DefaultLocaleRule : org/junit/rules/TestRule { + public static final field $stable I + public fun (Ljava/lang/String;)V + public fun apply (Lorg/junit/runners/model/Statement;Lorg/junit/runner/Description;)Lorg/junit/runners/model/Statement; + public final fun getLocale ()Ljava/lang/String; +} + +public class app/cash/paparazzi/preview/PaparazziValuesProvider : com/google/testing/junit/testparameterinjector/TestParameter$TestParameterValuesProvider { + public static final field $stable I + public fun (Ljava/util/List;)V + public fun provideValues ()Ljava/util/List; +} + +public final class app/cash/paparazzi/preview/SnapshotKt { + public static final fun deviceConfig (Lapp/cash/paparazzi/annotations/PaparazziPreviewData;Lapp/cash/paparazzi/DeviceConfig;)Lapp/cash/paparazzi/DeviceConfig; + public static synthetic fun deviceConfig$default (Lapp/cash/paparazzi/annotations/PaparazziPreviewData;Lapp/cash/paparazzi/DeviceConfig;ILjava/lang/Object;)Lapp/cash/paparazzi/DeviceConfig; + public static final fun locale (Lapp/cash/paparazzi/annotations/PaparazziPreviewData;)Ljava/lang/String; + public static final fun snapshot (Lapp/cash/paparazzi/Paparazzi;Lapp/cash/paparazzi/annotations/PaparazziPreviewData;Ljava/lang/String;ZLkotlin/jvm/functions/Function3;)V + public static synthetic fun snapshot$default (Lapp/cash/paparazzi/Paparazzi;Lapp/cash/paparazzi/annotations/PaparazziPreviewData;Ljava/lang/String;ZLkotlin/jvm/functions/Function3;ILjava/lang/Object;)V +} + +public final class app/cash/paparazzi/preview/UtilsKt { + public static final fun deviceConfig (Lapp/cash/paparazzi/annotations/PreviewData;Lapp/cash/paparazzi/DeviceConfig;)Lapp/cash/paparazzi/DeviceConfig; +} + diff --git a/paparazzi-preview-test/build.gradle b/paparazzi-preview-test/build.gradle new file mode 100644 index 0000000000..0bdd9b278b --- /dev/null +++ b/paparazzi-preview-test/build.gradle @@ -0,0 +1,10 @@ +apply plugin: 'org.jetbrains.kotlin.jvm' +apply plugin: 'org.jetbrains.kotlin.plugin.compose' +apply plugin: 'com.vanniktech.maven.publish' + +dependencies { + compileOnly libs.compose.runtime + compileOnly libs.composeUi.foundation + compileOnly projects.paparazzi + implementation projects.paparazziAnnotations +} diff --git a/paparazzi-preview-test/gradle.properties b/paparazzi-preview-test/gradle.properties new file mode 100644 index 0000000000..2c41f7932f --- /dev/null +++ b/paparazzi-preview-test/gradle.properties @@ -0,0 +1,4 @@ +POM_ARTIFACT_ID=paparazzi-preview-test +POM_NAME=Paparazzi Preview Test +POM_DESCRIPTION=Helper classes for running JUnit tests for Paparazzi with generated Composable @Previews +POM_PACKAGING=jar diff --git a/paparazzi-preview-test/src/main/java/app/cash/paparazzi/preview/Snapshot.kt b/paparazzi-preview-test/src/main/java/app/cash/paparazzi/preview/Snapshot.kt new file mode 100644 index 0000000000..ef48dd3e20 --- /dev/null +++ b/paparazzi-preview-test/src/main/java/app/cash/paparazzi/preview/Snapshot.kt @@ -0,0 +1,104 @@ +// Copyright Square, Inc. +package app.cash.paparazzi.preview + +import androidx.compose.runtime.Composable +import app.cash.paparazzi.DeviceConfig +import app.cash.paparazzi.Paparazzi +import app.cash.paparazzi.annotations.PaparazziPreviewData +import com.google.testing.junit.testparameterinjector.TestParameter.TestParameterValuesProvider +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement +import java.util.Locale + +/** + * Take a snapshot of the given [previewData]. + */ +public fun Paparazzi.snapshot( + previewData: PaparazziPreviewData, + name: String? = null, + localInspectionMode: Boolean = true, + wrapper: @Composable (@Composable () -> Unit) -> Unit = { it() } +) { + when (previewData) { + is PaparazziPreviewData.Default -> snapshotDefault(previewData, name, localInspectionMode, wrapper) + is PaparazziPreviewData.Provider<*> -> snapshotProvider(previewData, name, localInspectionMode, wrapper) + is PaparazziPreviewData.Empty -> Unit + is PaparazziPreviewData.Error -> throw Exception(previewData.message) + } +} + +/** + * Generate a Paparazzi DeviceConfig for the given preview + * using the given [default] DeviceConfig. + * + * default: The IDE renders a preview with a higher resolution than + * the default device set by Paparazzi (which is currently Nexus 5). Defaulting to + * a larger device brings the previews and snapshots closer in parity. + */ +public fun PaparazziPreviewData.deviceConfig( + default: DeviceConfig = DeviceConfig.PIXEL_5 +): DeviceConfig = when (this) { + is PaparazziPreviewData.Default -> preview.deviceConfig(default) + is PaparazziPreviewData.Provider<*> -> preview.deviceConfig(default) + else -> default +} + +/** + * Returns a locale for the given preview, or null if error or empty. + */ +public fun PaparazziPreviewData.locale(): String? = when (this) { + is PaparazziPreviewData.Default -> preview.locale + is PaparazziPreviewData.Provider<*> -> preview.locale + else -> null +} + +/** + * Convert a list of generated [PaparazziPreviewData] + * to a flat list of [PaparazziPreviewData]s. + */ +internal fun List.flatten() = flatMap { + when (it) { + is PaparazziPreviewData.Provider<*> -> List(it.previewParameter.values.count()) { i -> + it.withPreviewParameterIndex(i) + } + else -> listOf(it) + } +} + +/** + * A `@TestParameter` values provider for the given [annotations]. + * + * Example usage: + * ``` + * private class ValuesProvider : PaparazziValuesProvider(paparazziAnnotations) + * ``` + */ +public open class PaparazziValuesProvider( + private val annotations: List +) : TestParameterValuesProvider { + override fun provideValues(): List = annotations.flatten() +} + +/** + * Enforce a particular default locale for a test. Resets back to default on completion. + */ +public class DefaultLocaleRule(public val locale: String?) : TestRule { + override fun apply( + base: Statement, + description: Description + ): Statement { + return object : Statement() { + override fun evaluate() { + val default = Locale.getDefault() + + try { + locale?.let { Locale.setDefault(Locale.forLanguageTag(it)) } + base.evaluate() + } finally { + Locale.setDefault(default) + } + } + } + } +} diff --git a/paparazzi-preview-test/src/main/java/app/cash/paparazzi/preview/Utils.kt b/paparazzi-preview-test/src/main/java/app/cash/paparazzi/preview/Utils.kt new file mode 100644 index 0000000000..c3404418f4 --- /dev/null +++ b/paparazzi-preview-test/src/main/java/app/cash/paparazzi/preview/Utils.kt @@ -0,0 +1,151 @@ +// Copyright Square, Inc. +package app.cash.paparazzi.preview + +import android.content.res.Configuration +import android.util.DisplayMetrics +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalInspectionMode +import app.cash.paparazzi.DeviceConfig +import app.cash.paparazzi.Paparazzi +import app.cash.paparazzi.annotations.PaparazziPreviewData +import app.cash.paparazzi.annotations.PreviewData +import com.android.resources.NightMode +import com.android.resources.UiMode +import java.util.Locale +import kotlin.math.roundToInt + +internal fun String.deviceConfig() = when (this) { + "id:Nexus 7" -> DeviceConfig.NEXUS_7 + "id:Nexus 7 2013" -> DeviceConfig.NEXUS_7_2012 + "id:Nexus 5" -> DeviceConfig.NEXUS_5 + "id:Nexus 6" -> DeviceConfig.NEXUS_7 + "id:Nexus 9" -> DeviceConfig.NEXUS_10 + "name:Nexus 10" -> DeviceConfig.NEXUS_10 + "id:Nexus 5X" -> DeviceConfig.NEXUS_5 + "id:Nexus 6P" -> DeviceConfig.NEXUS_7 + "id:pixel_c" -> DeviceConfig.PIXEL_C + "id:pixel" -> DeviceConfig.PIXEL + "id:pixel_xl" -> DeviceConfig.PIXEL_XL + "id:pixel_2" -> DeviceConfig.PIXEL_2 + "id:pixel_2_xl" -> DeviceConfig.PIXEL_2_XL + "id:pixel_3" -> DeviceConfig.PIXEL_3 + "id:pixel_3_xl" -> DeviceConfig.PIXEL_3_XL + "id:pixel_3a" -> DeviceConfig.PIXEL_3A + "id:pixel_3a_xl" -> DeviceConfig.PIXEL_3A_XL + "id:pixel_4" -> DeviceConfig.PIXEL_4 + "id:pixel_4_xl" -> DeviceConfig.PIXEL_4_XL + "id:pixel_5" -> DeviceConfig.PIXEL_5 + "id:pixel_6" -> DeviceConfig.PIXEL_6 + "id:pixel_6_pro" -> DeviceConfig.PIXEL_6_PRO + "id:wearos_small_round" -> DeviceConfig.WEAR_OS_SMALL_ROUND + "id:wearos_square" -> DeviceConfig.WEAR_OS_SQUARE + else -> null +} + +internal fun Int.uiMode() = when (this and Configuration.UI_MODE_TYPE_MASK) { + Configuration.UI_MODE_TYPE_NORMAL -> UiMode.NORMAL + Configuration.UI_MODE_TYPE_CAR -> UiMode.CAR + Configuration.UI_MODE_TYPE_DESK -> UiMode.DESK + Configuration.UI_MODE_TYPE_APPLIANCE -> UiMode.APPLIANCE + Configuration.UI_MODE_TYPE_WATCH -> UiMode.WATCH + Configuration.UI_MODE_TYPE_VR_HEADSET -> UiMode.VR_HEADSET + else -> null +} + +internal fun Int.nightMode() = when (this and Configuration.UI_MODE_NIGHT_MASK) { + Configuration.UI_MODE_NIGHT_NO -> NightMode.NOTNIGHT + Configuration.UI_MODE_NIGHT_YES -> NightMode.NIGHT + else -> null +} + +internal fun String.localeQualifierString() = + Locale.forLanguageTag(this).run { + "$language-r$country" + } + +public fun PreviewData?.deviceConfig(defaultDeviceConfig: DeviceConfig): DeviceConfig = + (this?.device?.deviceConfig() ?: defaultDeviceConfig).let { config -> + config.copy( + screenWidth = this?.widthDp?.toPx(config.density.dpiValue) ?: config.screenWidth, + screenHeight = this?.heightDp?.toPx(config.density.dpiValue) ?: config.screenHeight, + fontScale = this?.fontScale ?: config.fontScale, + uiMode = this?.uiMode?.uiMode() ?: config.uiMode, + nightMode = this?.uiMode?.nightMode() ?: config.nightMode, + locale = this?.locale?.localeQualifierString() ?: config.locale + ) + } + +private fun Int.toPx(dpi: Int) = + (this * (dpi.toFloat() / DisplayMetrics.DENSITY_DEFAULT)).roundToInt() + +internal fun Paparazzi.snapshotDefault( + previewData: PaparazziPreviewData.Default, + name: String?, + localInspectionMode: Boolean, + wrapper: @Composable (@Composable () -> Unit) -> Unit = { it() } +) { + snapshot( + name = name, + backgroundColor = previewData.preview.backgroundColor, + composable = { previewData.composable() }, + localInspectionMode = localInspectionMode, + wrapper = wrapper + ) +} + +internal fun Paparazzi.snapshotProvider( + previewData: PaparazziPreviewData.Provider, + name: String?, + localInspectionMode: Boolean, + wrapper: @Composable (@Composable () -> Unit) -> Unit = { it() } +) { + val paramValue = previewData.previewParameter.values + .elementAt(previewData.previewParameter.index) + + snapshot( + name = name, + backgroundColor = previewData.preview.backgroundColor, + composable = { previewData.composable(paramValue) }, + localInspectionMode = localInspectionMode, + wrapper = wrapper + ) +} + +private fun Paparazzi.snapshot( + name: String?, + backgroundColor: String?, + composable: @Composable () -> Unit, + localInspectionMode: Boolean, + wrapper: @Composable (@Composable () -> Unit) -> Unit = { it() } +) { + snapshot(name) { + PreviewWrapper(backgroundColor, localInspectionMode) { + wrapper { composable() } + } + } +} + +@Composable +private fun PreviewWrapper( + backgroundColor: String?, + localInspectionMode: Boolean, + content: @Composable BoxScope.() -> Unit +) { + CompositionLocalProvider(LocalInspectionMode provides localInspectionMode) { + Box( + modifier = Modifier + .then( + backgroundColor?.toLong(16) + ?.let { Modifier.background(Color(it)) } + ?: Modifier + ), + content = content + ) + } +} diff --git a/paparazzi/api/paparazzi.api b/paparazzi/api/paparazzi.api index b7ab491035..01a6bf3947 100644 --- a/paparazzi/api/paparazzi.api +++ b/paparazzi/api/paparazzi.api @@ -235,28 +235,3 @@ public final class app/cash/paparazzi/accessibility/AccessibilityRenderExtension public fun renderView (Landroid/view/View;)Landroid/view/View; } -public final class app/cash/paparazzi/preview/ComposableSingletons$SnapshotKt { - public static final field INSTANCE Lapp/cash/paparazzi/preview/ComposableSingletons$SnapshotKt; - public static field lambda-1 Lkotlin/jvm/functions/Function3; - public fun ()V - public final fun getLambda-1$paparazzi ()Lkotlin/jvm/functions/Function3; -} - -public final class app/cash/paparazzi/preview/ComposableSingletons$UtilsKt { - public static final field INSTANCE Lapp/cash/paparazzi/preview/ComposableSingletons$UtilsKt; - public static field lambda-1 Lkotlin/jvm/functions/Function3; - public fun ()V - public final fun getLambda-1$paparazzi ()Lkotlin/jvm/functions/Function3; -} - -public class app/cash/paparazzi/preview/PaparazziValuesProvider : com/google/testing/junit/testparameterinjector/TestParameter$TestParameterValuesProvider { - public static final field $stable I - public fun (Ljava/util/List;)V - public fun provideValues ()Ljava/util/List; -} - -public final class app/cash/paparazzi/preview/SnapshotKt { - public static final fun snapshot (Lapp/cash/paparazzi/Paparazzi;Lapp/cash/paparazzi/annotations/PaparazziPreviewData;Ljava/lang/String;Lkotlin/jvm/functions/Function3;)V - public static synthetic fun snapshot$default (Lapp/cash/paparazzi/Paparazzi;Lapp/cash/paparazzi/annotations/PaparazziPreviewData;Ljava/lang/String;Lkotlin/jvm/functions/Function3;ILjava/lang/Object;)V -} - diff --git a/paparazzi/src/main/java/app/cash/paparazzi/preview/Snapshot.kt b/paparazzi/src/main/java/app/cash/paparazzi/preview/Snapshot.kt deleted file mode 100644 index 253d2c1773..0000000000 --- a/paparazzi/src/main/java/app/cash/paparazzi/preview/Snapshot.kt +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright Square, Inc. -package app.cash.paparazzi.preview - -import androidx.compose.runtime.Composable -import app.cash.paparazzi.Paparazzi -import app.cash.paparazzi.annotations.PaparazziPreviewData -import com.google.testing.junit.testparameterinjector.TestParameter.TestParameterValuesProvider - -/** - * Take a snapshot of the given [previewData]. - */ -public fun Paparazzi.snapshot( - previewData: PaparazziPreviewData, - name: String? = null, - wrapper: @Composable (@Composable () -> Unit) -> Unit = { it() } -) { - when (previewData) { - is PaparazziPreviewData.Default -> snapshotDefault(previewData, name, wrapper) - is PaparazziPreviewData.Empty -> Unit - is PaparazziPreviewData.Error -> throw Exception(previewData.message) - } -} - -/** - * A `@TestParameter` values provider for the given [annotations]. - * - * Example usage: - * ``` - * private class ValuesProvider : PaparazziValuesProvider(paparazziAnnotations) - * ``` - */ -public open class PaparazziValuesProvider( - private val annotations: List -) : TestParameterValuesProvider { - override fun provideValues(): List = annotations -} diff --git a/paparazzi/src/main/java/app/cash/paparazzi/preview/Utils.kt b/paparazzi/src/main/java/app/cash/paparazzi/preview/Utils.kt deleted file mode 100644 index 97c0a4a51e..0000000000 --- a/paparazzi/src/main/java/app/cash/paparazzi/preview/Utils.kt +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright Square, Inc. -package app.cash.paparazzi.preview - -import androidx.compose.runtime.Composable -import app.cash.paparazzi.Paparazzi -import app.cash.paparazzi.annotations.PaparazziPreviewData - -internal fun Paparazzi.snapshotDefault( - previewData: PaparazziPreviewData.Default, - name: String?, - wrapper: @Composable (@Composable () -> Unit) -> Unit = { it() } -) { - snapshot(name) { - wrapper { previewData.composable() } - } -} diff --git a/sample/src/main/java/app/cash/paparazzi/sample/HelloPaparazzi.kt b/sample/src/main/java/app/cash/paparazzi/sample/HelloPaparazzi.kt index c140df2349..8187d3974a 100644 --- a/sample/src/main/java/app/cash/paparazzi/sample/HelloPaparazzi.kt +++ b/sample/src/main/java/app/cash/paparazzi/sample/HelloPaparazzi.kt @@ -1,5 +1,6 @@ package app.cash.paparazzi.sample +import android.content.res.Configuration import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -13,6 +14,8 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider import app.cash.paparazzi.annotations.Paparazzi @Paparazzi @@ -50,3 +53,45 @@ fun HelloPaparazzi() { ) } } + +@Paparazzi +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview +@Composable +fun HelloPaparazzi( + @PreviewParameter(TextProvider::class) text: String +) { + Column( + Modifier + .background(Color.White) + .wrapContentSize() + ) { + Text(text) + Text(text, style = TextStyle(fontFamily = FontFamily.Cursive)) + Text( + text = text, + style = TextStyle(textDecoration = TextDecoration.LineThrough) + ) + Text( + text = text, + style = TextStyle(textDecoration = TextDecoration.Underline) + ) + Text( + text = text, + style = TextStyle( + textDecoration = TextDecoration.combine( + listOf( + TextDecoration.Underline, + TextDecoration.LineThrough + ) + ), + fontWeight = FontWeight.Bold + ) + ) + } +} + +object TextProvider : PreviewParameterProvider { + override val values: Sequence = + sequenceOf("Hello Paparazzi", "Hello World", "Hello Compose") +} diff --git a/settings.gradle b/settings.gradle index 47b4876ac9..2676f3e16b 100644 --- a/settings.gradle +++ b/settings.gradle @@ -19,6 +19,7 @@ rootProject.name = 'paparazzi-root' include ':paparazzi' include ':paparazzi-annotations' include ':paparazzi-preview-processor' +include ':paparazzi-preview-test' include ':paparazzi-gradle-plugin' include ':sample'