diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 30f64d2..2b1a971 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,7 +11,7 @@ jobs: uses: actions/setup-java@v2 with: distribution: 'adopt' - java-version: '17' + java-version: '21' - name: Checkout code uses: actions/checkout@v3 @@ -54,4 +54,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 \ No newline at end of file + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/smokebuild.yaml b/.github/workflows/smokebuild.yaml index f1499ff..430daa4 100644 --- a/.github/workflows/smokebuild.yaml +++ b/.github/workflows/smokebuild.yaml @@ -16,7 +16,7 @@ jobs: uses: actions/setup-java@v2 with: distribution: 'adopt' - java-version: '17' + java-version: '21' - name: Checkout code uses: actions/checkout@v3 diff --git a/.github/workflows/spotless.yaml b/.github/workflows/spotless.yaml index 83cd7ad..997bfe7 100644 --- a/.github/workflows/spotless.yaml +++ b/.github/workflows/spotless.yaml @@ -16,7 +16,7 @@ jobs: - name: Set up JDK uses: actions/setup-java@v3 with: - java-version: '17' + java-version: '21' distribution: 'temurin' cache: gradle diff --git a/build.gradle.kts b/build.gradle.kts index 507998a..c6e7631 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,6 +11,13 @@ plugins { alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.dokka) apply false alias(libs.plugins.spotless) apply false + alias(libs.plugins.ksp) apply false +} + +buildscript { + dependencies { + classpath(kotlin("gradle-plugin", version = libs.versions.kotlin.asProvider().get())) + } } subprojects { diff --git a/gallery-demo/build.gradle.kts b/gallery-demo/build.gradle.kts index 1036ff7..1dd3c2d 100644 --- a/gallery-demo/build.gradle.kts +++ b/gallery-demo/build.gradle.kts @@ -12,6 +12,7 @@ plugins { alias(libs.plugins.compose.compiler) alias(libs.plugins.serialization) id("org.jetbrains.compose.hot-reload") version "1.0.0-alpha03" + alias(libs.plugins.ksp) } class StorytaleCompilerPlugin : KotlinCompilerPluginSupportPlugin { @@ -34,13 +35,36 @@ class StorytaleCompilerPlugin : KotlinCompilerPluginSupportPlugin { ) } } +class MakePreviewPublicCompilerPlugin : KotlinCompilerPluginSupportPlugin { + override fun applyToCompilation(kotlinCompilation: KotlinCompilation<*>): Provider> { + return kotlinCompilation.project.provider { emptyList() } + } + + override fun getCompilerPluginId(): String { + return "org.jetbrains.compose.compiler.plugins.storytale.preview.public" + } + + override fun getPluginArtifact(): SubpluginArtifact { + return SubpluginArtifact("org.jetbrains.compose.storytale.preview.public", "local-compiler-plugin") + } + + override fun isApplicable(kotlinCompilation: KotlinCompilation<*>): Boolean { + return kotlinCompilation.target.platformType in setOf( + org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType.jvm, + org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType.wasm, + ) + } +} apply() +apply() configurations.all { resolutionStrategy.dependencySubstitution { substitute(module("org.jetbrains.compose.storytale:local-compiler-plugin")) .using(project(":modules:compiler-plugin")) + substitute(module("org.jetbrains.compose.storytale.preview.public:local-compiler-plugin")) + .using(project(":modules:preview-processor")) } } @@ -107,9 +131,17 @@ kotlin { } } +dependencies { + add("kspCommonMainMetadata", project(":modules:preview-processor")) + add("ksp", project(":modules:preview-processor")) +} + compose.desktop { application { mainClass = "storytale.gallery.demo.MainKt" + buildTypes.release.proguard { + isEnabled.set(false) + } } } diff --git a/gallery-demo/src/commonMain/kotlin/storytale/gallery/demo/PreviewCheckbox.kt b/gallery-demo/src/commonMain/kotlin/storytale/gallery/demo/PreviewCheckbox.kt new file mode 100644 index 0000000..541a0e7 --- /dev/null +++ b/gallery-demo/src/commonMain/kotlin/storytale/gallery/demo/PreviewCheckbox.kt @@ -0,0 +1,28 @@ +package storytale.gallery.demo + +import androidx.compose.material3.TriStateCheckbox +import androidx.compose.runtime.Composable +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.state.ToggleableState.Indeterminate +import androidx.compose.ui.state.ToggleableState.Off +import androidx.compose.ui.state.ToggleableState.On +import org.jetbrains.compose.storytale.previewParameter +import org.jetbrains.compose.ui.tooling.preview.Preview + +@Preview +@Composable +@Suppress("ktlint") +private fun PreviewCheckbox() { + var state by previewParameter(ToggleableState.entries) + + TriStateCheckbox( + state = state, + onClick = { + state = when (state) { + On -> Indeterminate + Off -> On + Indeterminate -> Off + } + }, + ) +} diff --git a/gallery-demo/src/desktopMain/kotlin/storytale/gallery/demo/PreviewButton.kt b/gallery-demo/src/desktopMain/kotlin/storytale/gallery/demo/PreviewButton.kt new file mode 100644 index 0000000..9c55710 --- /dev/null +++ b/gallery-demo/src/desktopMain/kotlin/storytale/gallery/demo/PreviewButton.kt @@ -0,0 +1,51 @@ +package storytale.gallery.demo + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AddCircle +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.storytale.previewParameter + +@org.jetbrains.compose.ui.tooling.preview.Preview +@Composable +@Suppress("ktlint") +private fun PreviewExtendedFAB() { + val bgColor by previewParameter(MaterialTheme.colorScheme.primary) + + ExtendedFloatingActionButton(onClick = {}, containerColor = bgColor) { + Icon(imageVector = Icons.Default.AddCircle, contentDescription = null) + Spacer(Modifier.padding(4.dp)) + Text("Extended") + } +} + +@androidx.compose.desktop.ui.tooling.preview.Preview +@Composable +@Suppress("ktlint") +private fun PreviewSegmentedButton() { + val selectedIndex = remember { mutableIntStateOf(0) } + + SingleChoiceSegmentedButtonRow { + repeat(3) { index -> + SegmentedButton( + selected = index == selectedIndex.value, + onClick = { selectedIndex.value = index }, + shape = SegmentedButtonDefaults.itemShape(index, 3), + ) { + Text("Button $index", modifier = Modifier.padding(4.dp)) + } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 17b54db..84dd986 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,10 +21,12 @@ dokka = "1.9.10" kotlinx-html = "0.7.3" junit = "5.10.1" jsoup = "1.16.1" +ksp = "2.1.20-2.0.1" [libraries] assertj-core = { module = "org.assertj:assertj-core", version.ref = "assertj" } kotlinCompileTesting-core = { module = "dev.zacsweers.kctfork:core", version.ref = "kotlinCompileTesting" } +kotlinCompileTesting-ksp = { module = "dev.zacsweers.kctfork:ksp", version.ref = "kotlinCompileTesting" } junit = { module = "junit:junit", version.ref = "junitVersion" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" } @@ -45,6 +47,7 @@ junit-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "jun jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } ktlint = "com.pinterest.ktlint:ktlint-cli:1.5.0" composeRules = "io.nlopez.compose.rules:ktlint:0.4.22" +ksp-api = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } @@ -57,6 +60,6 @@ buildTimeConfig = { id = "dev.limebeck.build-time-config", version.ref = "build- storytale = { id = "org.jetbrains.compose.storytale", version.ref = "storytale" } kotlinDsl = { id = "kotlin-dsl" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } - +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } diff --git a/modules/preview-processor-test/.gitignore b/modules/preview-processor-test/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/modules/preview-processor-test/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/modules/preview-processor-test/build.gradle.kts b/modules/preview-processor-test/build.gradle.kts new file mode 100644 index 0000000..1f7c62a --- /dev/null +++ b/modules/preview-processor-test/build.gradle.kts @@ -0,0 +1,70 @@ +import org.gradle.kotlin.dsl.compileOnly +import org.gradle.kotlin.dsl.kotlin +import org.gradle.kotlin.dsl.project +import org.gradle.kotlin.dsl.withType +import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidLibrary) + alias(libs.plugins.jetbrainsCompose) + alias(libs.plugins.compose.compiler) +} + +kotlin { + jvm() + androidTarget() + + sourceSets { + val commonMain by getting { + dependencies { + compileOnly(compose.runtime) + } + } + val jvmMain by getting { + dependencies { + implementation(compose.components.uiToolingPreview) + implementation(project(":modules:preview-processor")) + implementation(kotlin("test")) + + implementation(compose.runtime) + implementation(kotlin("compiler-embeddable")) + implementation(kotlin("compose-compiler-plugin-embeddable")) + implementation(kotlin("test")) + implementation(libs.assertj.core) + implementation(libs.junit) + implementation(libs.kotlinCompileTesting.ksp) + implementation(project(":modules:runtime-api")) + } + } + val androidUnitTest by getting { + dependsOn(jvmMain) + + dependencies { + implementation("androidx.compose.ui:ui-tooling-preview-android:1.7.0") + } + } + val jvmTest by getting { + dependencies { + implementation("androidx.compose.ui:ui-tooling-preview-desktop:1.7.0") + } + } + } +} + +android { + compileSdk = 35 + namespace = "org.jetbrains.compose.storytale.preview.processor.test" + sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") + defaultConfig { + minSdk = 24 + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } +} + +tasks.withType>().configureEach { + compilerOptions.optIn.add("org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi") +} diff --git a/modules/preview-processor-test/src/androidUnitTest/kotlin/MakePreviewPublicFirExtensionRegistrarAndroidTest.kt b/modules/preview-processor-test/src/androidUnitTest/kotlin/MakePreviewPublicFirExtensionRegistrarAndroidTest.kt new file mode 100644 index 0000000..dd9d7ab --- /dev/null +++ b/modules/preview-processor-test/src/androidUnitTest/kotlin/MakePreviewPublicFirExtensionRegistrarAndroidTest.kt @@ -0,0 +1,146 @@ +package com.storytale + +import com.tschuchort.compiletesting.KotlinCompilation +import java.lang.reflect.Modifier +import kotlin.test.Ignore +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import util.storytaleTest + +class MakePreviewPublicFirExtensionRegistrarAndroidTest { + + @Test + fun `ensure that private androidx Preview functions are made public`() { + val compilation = storytaleTest { + "PrivateAndroidPreview.kt" hasContent """ + package test + + import androidx.compose.runtime.Composable + + @Composable + private fun PrivateAndroidComponent() {} + + @androidx.compose.ui.tooling.preview.Preview + @Composable + private fun PrivateAndroidPreviewFunction() { + PrivateAndroidComponent() + } + """ + } + val result = compilation.compile() + + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.OK) + + val classLoader = result.classLoader + val previewClass = classLoader.loadClass("test.PrivateAndroidPreviewKt") + + val previewFunction = previewClass.declaredMethods.find { it.name == "PrivateAndroidPreviewFunction" } + assertThat(previewFunction).isNotNull + assertThat(Modifier.isPublic(previewFunction!!.modifiers)).isTrue() + + val privateComponent = previewClass.declaredMethods.find { it.name == "PrivateAndroidComponent" } + assertThat(privateComponent).isNotNull + assertThat(Modifier.isPrivate(privateComponent!!.modifiers)).isTrue() + } + + @Test + fun `ensure that internal androidx Preview functions are made public`() { + val compilation = storytaleTest { + "InternalAndroidPreview.kt" hasContent """ + package test + + import androidx.compose.runtime.Composable + + @Composable + internal fun InternalAndroidComponent() {} + + @androidx.compose.ui.tooling.preview.Preview + @Composable + internal fun InternalAndroidPreviewFunction() { + InternalAndroidComponent() + } + """ + } + val result = compilation.compile() + + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.OK) + + val classLoader = result.classLoader + val previewClass = classLoader.loadClass("test.InternalAndroidPreviewKt") + + val previewFunction = previewClass.declaredMethods.find { it.name == "InternalAndroidPreviewFunction" } + assertThat(previewFunction).isNotNull + assertThat(Modifier.isPublic(previewFunction!!.modifiers)).isTrue() + + val internalComponent = previewClass.declaredMethods.find { it.name == "InternalAndroidComponent" } + assertThat(internalComponent).isNotNull + assertThat(Modifier.isPublic(internalComponent!!.modifiers)).isTrue() + } + + @Ignore("PreviewProcessor hasn't support protected Preview functions in classes yet") + @Test + fun `ensure that protected androidx Preview functions are made public in classes`() { + val compilation = storytaleTest { + "ProtectedAndroidPreview.kt" hasContent """ + package test + + import androidx.compose.runtime.Composable + + open class PreviewAndroidContainer { + @Composable + protected fun ProtectedAndroidComponent() {} + + @androidx.compose.ui.tooling.preview.Preview + @Composable + protected fun ProtectedAndroidPreviewFunction() { + ProtectedAndroidComponent() + } + } + """ + } + val result = compilation.compile() + + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.OK) + + val classLoader = result.classLoader + val containerClass = classLoader.loadClass("test.PreviewAndroidContainer") + + val previewFunction = containerClass.declaredMethods.find { it.name == "ProtectedAndroidPreviewFunction" } + assertThat(previewFunction).isNotNull + assertThat(Modifier.isPublic(previewFunction!!.modifiers)).isTrue() + + val protectedComponent = containerClass.declaredMethods.find { it.name == "ProtectedAndroidComponent" } + assertThat(protectedComponent).isNotNull + assertThat(Modifier.isPublic(protectedComponent!!.modifiers)).isTrue() + } + + @Test + fun `ensure that public androidx Preview functions remain public`() { + val compilation = storytaleTest { + "PublicAndroidPreview.kt" hasContent """ + package test + + import androidx.compose.runtime.Composable + + @Composable + fun PublicAndroidComponent() {} + + @androidx.compose.ui.tooling.preview.Preview + @Composable + fun PublicAndroidPreviewFunction() { + PublicAndroidComponent() + } + """ + } + val result = compilation.compile() + + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.OK) + + val classLoader = result.classLoader + val previewClass = classLoader.loadClass("test.PublicAndroidPreviewKt") + + val previewFunction = previewClass.declaredMethods.find { it.name == "PublicAndroidPreviewFunction" } + assertThat(previewFunction).isNotNull + assertThat(Modifier.isPublic(previewFunction!!.modifiers)).isTrue() + } +} diff --git a/modules/preview-processor-test/src/androidUnitTest/kotlin/PreviewProcessorAndroidTest.kt b/modules/preview-processor-test/src/androidUnitTest/kotlin/PreviewProcessorAndroidTest.kt new file mode 100644 index 0000000..7577342 --- /dev/null +++ b/modules/preview-processor-test/src/androidUnitTest/kotlin/PreviewProcessorAndroidTest.kt @@ -0,0 +1,49 @@ +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.sourcesGeneratedBySymbolProcessor +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import util.assertableGeneratedKspSources +import util.hasContent +import util.storytaleTest + +class PreviewProcessorAndroidTest { + + @Test + fun `generates story for Androidx Android Preview function`() { + val compilation = storytaleTest { + "AndroidButton.kt" hasContent """ + package storytale.gallery.demo + + @androidx.compose.runtime.Composable + fun AndroidButton() { } + + @androidx.compose.ui.tooling.preview.Preview + @androidx.compose.runtime.Composable + fun PreviewAndroidButton() { + AndroidButton() + } + """ + } + val result = compilation + .compile() + + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.OK) + + assertThat(result.sourcesGeneratedBySymbolProcessor.toList()).hasSize(1) + + assertThat(result.assertableGeneratedKspSources(compilation)) + .containsExactlyInAnyOrder( + "kotlin/org/jetbrains/compose/storytale/generated/Previews.story.kt" hasContent """ + |package org.jetbrains.compose.storytale.generated + | + |import org.jetbrains.compose.storytale.Story + |import org.jetbrains.compose.storytale.story + |import storytale.gallery.demo.PreviewAndroidButton + | + |public val AndroidButton: Story by story { + | PreviewAndroidButton() + |} + """.trimMargin(), + ) + } +} diff --git a/modules/preview-processor-test/src/jvmMain/kotlin/util/AssertableFile.kt b/modules/preview-processor-test/src/jvmMain/kotlin/util/AssertableFile.kt new file mode 100644 index 0000000..9d80d81 --- /dev/null +++ b/modules/preview-processor-test/src/jvmMain/kotlin/util/AssertableFile.kt @@ -0,0 +1,30 @@ +package util + +import java.io.File +import org.assertj.core.util.Files +import org.intellij.lang.annotations.Language + +data class AssertableFile( + val path: String, + val content: String, +) { + override fun toString(): String { + return buildString { + append('\"') + append(path) + appendLine("\" has content \"\"\"") + appendLine(content) + appendLine("\"\"\"") + } + } +} + +fun File.assertable(): AssertableFile { + val it = this + return path hasContent Files.contentOf(it, Charsets.UTF_8).trim() +} + +@Suppress("NOTHING_TO_INLINE") +inline infix fun String.hasContent(@Language("kotlin") content: String): AssertableFile { + return AssertableFile(this, content) +} diff --git a/modules/preview-processor-test/src/jvmMain/kotlin/util/Compilation.kt b/modules/preview-processor-test/src/jvmMain/kotlin/util/Compilation.kt new file mode 100644 index 0000000..b5854c1 --- /dev/null +++ b/modules/preview-processor-test/src/jvmMain/kotlin/util/Compilation.kt @@ -0,0 +1,66 @@ +package util + +import PreviewComponentRegistrar +import PreviewProcessor +import androidx.compose.compiler.plugins.kotlin.ComposePluginRegistrar +import com.tschuchort.compiletesting.JvmCompilationResult +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.SourceFile +import com.tschuchort.compiletesting.kspSourcesDir +import com.tschuchort.compiletesting.sourcesGeneratedBySymbolProcessor +import com.tschuchort.compiletesting.symbolProcessorProviders +import com.tschuchort.compiletesting.useKsp2 +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract +import org.intellij.lang.annotations.Language +import org.jetbrains.kotlin.utils.fileUtils.descendantRelativeTo + +@OptIn(ExperimentalContracts::class) +fun storytaleTest( + compilationBuilder: KotlinCompilation.() -> Unit = {}, + testSourceBuilder: StorytaleTestSourceScope.() -> Unit, +): KotlinCompilation { + contract { + callsInPlace(compilationBuilder, InvocationKind.EXACTLY_ONCE) + } + + val testSourceScope = object : StorytaleTestSourceScope { + val sources = mutableListOf() + override fun String.hasContent(content: String) { + sources.add(SourceFile.Companion.kotlin(this, content)) + } + } + + return KotlinCompilation().apply { + compilerPluginRegistrars = listOf( + ComposePluginRegistrar(), + PreviewComponentRegistrar(), + ) + useKsp2() + symbolProcessorProviders.add(PreviewProcessor.Provider()) + + // magic + inheritClassPath = true + messageOutputStream = System.out // see diagnostics in real time + jvmTarget = "21" + verbose = false + + compilationBuilder() + testSourceScope.testSourceBuilder() + sources = testSourceScope.sources + } +} + +interface StorytaleTestSourceScope { + infix fun String.hasContent(@Language("kotlin") content: String) +} + +fun JvmCompilationResult.assertableGeneratedKspSources( + compilation: KotlinCompilation, +): List { + return sourcesGeneratedBySymbolProcessor.toList().map { + it.descendantRelativeTo(compilation.kspSourcesDir).path hasContent + org.assertj.core.util.Files.contentOf(it, Charsets.UTF_8).trim() + } +} diff --git a/modules/preview-processor-test/src/jvmTest/kotlin/MakePreviewPublicFirExtensionRegistrarTest.kt b/modules/preview-processor-test/src/jvmTest/kotlin/MakePreviewPublicFirExtensionRegistrarTest.kt new file mode 100644 index 0000000..580a800 --- /dev/null +++ b/modules/preview-processor-test/src/jvmTest/kotlin/MakePreviewPublicFirExtensionRegistrarTest.kt @@ -0,0 +1,280 @@ +import com.tschuchort.compiletesting.KotlinCompilation +import java.lang.reflect.Modifier +import kotlin.test.Ignore +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import util.storytaleTest + +class MakePreviewPublicFirExtensionRegistrarTest { + + @Test + fun `ensure that private Jetbrains Compose Preview functions are made public`() { + val compilation = storytaleTest { + "PrivatePreview.kt" hasContent """ + package test + + import androidx.compose.runtime.Composable + import org.jetbrains.compose.ui.tooling.preview.Preview + + @Composable + private fun PrivateComponent() {} + + @org.jetbrains.compose.ui.tooling.preview.Preview + @Composable + private fun PrivatePreviewFunction() { + PrivateComponent() + } + """ + } + val result = compilation.compile() + + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.OK) + + val classLoader = result.classLoader + val previewClass = classLoader.loadClass("test.PrivatePreviewKt") + + val previewFunction = previewClass.declaredMethods.find { it.name == "PrivatePreviewFunction" } + assertThat(previewFunction).isNotNull + assertThat(Modifier.isPublic(previewFunction!!.modifiers)).isTrue() + + val privateComponent = previewClass.declaredMethods.find { it.name == "PrivateComponent" } + assertThat(privateComponent).isNotNull + assertThat(Modifier.isPrivate(privateComponent!!.modifiers)).isTrue() + } + + @Test + fun `ensure that internal Jetbrains Compose Preview functions are made public`() { + val compilation = storytaleTest { + "InternalPreview.kt" hasContent """ + package test + + import androidx.compose.runtime.Composable + + @Composable + internal fun InternalComponent() {} + + @org.jetbrains.compose.ui.tooling.preview.Preview + @Composable + internal fun InternalPreviewFunction() { + InternalComponent() + } + """ + } + val result = compilation.compile() + + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.OK) + + val classLoader = result.classLoader + val previewClass = classLoader.loadClass("test.InternalPreviewKt") + + val previewFunction = previewClass.declaredMethods.find { it.name == "InternalPreviewFunction" } + assertThat(previewFunction).isNotNull + assertThat(Modifier.isPublic(previewFunction!!.modifiers)).isTrue() + + val internalComponent = previewClass.declaredMethods.find { it.name == "InternalComponent" } + assertThat(internalComponent).isNotNull + assertThat(Modifier.isPublic(internalComponent!!.modifiers)).isTrue() + } + + @Ignore("PreviewProcessor hasn't support protected Preview functions in classes yet") + @Test + fun `ensure that protected Jetbrains Compose Preview functions are made public in classes`() { + val compilation = storytaleTest { + "ProtectedPreview.kt" hasContent """ + package test + + import androidx.compose.runtime.Composable + + open class PreviewContainer { + @Composable + protected fun ProtectedComponent() {} + + @org.jetbrains.compose.ui.tooling.preview.Preview + @Composable + protected fun ProtectedPreviewFunction() { + ProtectedComponent() + } + } + """ + } + val result = compilation.compile() + + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.OK) + + val classLoader = result.classLoader + val containerClass = classLoader.loadClass("test.PreviewContainer") + + val previewFunction = containerClass.declaredMethods.find { it.name == "ProtectedPreviewFunction" } + assertThat(previewFunction).isNotNull + assertThat(Modifier.isPublic(previewFunction!!.modifiers)).isTrue() + + val protectedComponent = containerClass.declaredMethods.find { it.name == "ProtectedComponent" } + assertThat(protectedComponent).isNotNull + assertThat(Modifier.isPublic(protectedComponent!!.modifiers)).isTrue() + } + + @Test + fun `ensure that public Jetbrains Compose Preview functions remain public`() { + val compilation = storytaleTest { + "PublicPreview.kt" hasContent """ + package test + + import androidx.compose.runtime.Composable + + @Composable + fun PublicComponent() {} + + @org.jetbrains.compose.ui.tooling.preview.Preview + @Composable + fun PublicPreviewFunction() { + PublicComponent() + } + """ + } + val result = compilation.compile() + + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.OK) + + val classLoader = result.classLoader + val previewClass = classLoader.loadClass("test.PublicPreviewKt") + + val previewFunction = previewClass.declaredMethods.find { it.name == "PublicPreviewFunction" } + assertThat(previewFunction).isNotNull + assertThat(Modifier.isPublic(previewFunction!!.modifiers)).isTrue() + } + + @Test + fun `ensure that private androidx desktop Preview functions are made public`() { + val compilation = storytaleTest { + "PrivateDesktopPreview.kt" hasContent """ + package test + + import androidx.compose.runtime.Composable + + @Composable + private fun PrivateDesktopComponent() {} + + @androidx.compose.desktop.ui.tooling.preview.Preview + @Composable + private fun PrivateDesktopPreviewFunction() { + PrivateDesktopComponent() + } + """ + } + val result = compilation.compile() + + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.OK) + + val classLoader = result.classLoader + val previewClass = classLoader.loadClass("test.PrivateDesktopPreviewKt") + + val previewFunction = previewClass.declaredMethods.find { it.name == "PrivateDesktopPreviewFunction" } + assertThat(previewFunction).isNotNull + assertThat(Modifier.isPublic(previewFunction!!.modifiers)).isTrue() + + val privateComponent = previewClass.declaredMethods.find { it.name == "PrivateDesktopComponent" } + assertThat(privateComponent).isNotNull + assertThat(Modifier.isPrivate(privateComponent!!.modifiers)).isTrue() + } + + @Test + fun `ensure that internal androidx desktop Preview functions are made public`() { + val compilation = storytaleTest { + "InternalDesktopPreview.kt" hasContent """ + package test + + import androidx.compose.runtime.Composable + + @Composable + internal fun InternalDesktopComponent() {} + + @androidx.compose.desktop.ui.tooling.preview.Preview + @Composable + internal fun InternalDesktopPreviewFunction() { + InternalDesktopComponent() + } + """ + } + val result = compilation.compile() + + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.OK) + + val classLoader = result.classLoader + val previewClass = classLoader.loadClass("test.InternalDesktopPreviewKt") + + val previewFunction = previewClass.declaredMethods.find { it.name == "InternalDesktopPreviewFunction" } + assertThat(previewFunction).isNotNull + assertThat(Modifier.isPublic(previewFunction!!.modifiers)).isTrue() + + val internalComponent = previewClass.declaredMethods.find { it.name == "InternalDesktopComponent" } + assertThat(internalComponent).isNotNull + assertThat(Modifier.isPublic(internalComponent!!.modifiers)).isTrue() + } + + @Ignore("PreviewProcessor hasn't support protected Preview functions in classes yet") + @Test + fun `ensure that protected androidx desktop Preview functions are made public in classes`() { + val compilation = storytaleTest { + "ProtectedDesktopPreview.kt" hasContent """ + package test + + import androidx.compose.runtime.Composable + + open class PreviewDesktopContainer { + @Composable + protected fun ProtectedDesktopComponent() {} + + @androidx.compose.desktop.ui.tooling.preview.Preview + @Composable + protected fun ProtectedDesktopPreviewFunction() { + ProtectedDesktopComponent() + } + } + """ + } + val result = compilation.compile() + + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.OK) + + val classLoader = result.classLoader + val containerClass = classLoader.loadClass("test.PreviewDesktopContainer") + + val previewFunction = containerClass.declaredMethods.find { it.name == "ProtectedDesktopPreviewFunction" } + assertThat(previewFunction).isNotNull + assertThat(Modifier.isPublic(previewFunction!!.modifiers)).isTrue() + + val protectedComponent = containerClass.declaredMethods.find { it.name == "ProtectedDesktopComponent" } + assertThat(protectedComponent).isNotNull + assertThat(Modifier.isPublic(protectedComponent!!.modifiers)).isTrue() + } + + @Test + fun `ensure that public androidx desktop Preview functions remain public`() { + val compilation = storytaleTest { + "PublicDesktopPreview.kt" hasContent """ + package test + + import androidx.compose.runtime.Composable + + @Composable + fun PublicDesktopComponent() {} + + @androidx.compose.desktop.ui.tooling.preview.Preview + @Composable + fun PublicDesktopPreviewFunction() { + PublicDesktopComponent() + } + """ + } + val result = compilation.compile() + + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.OK) + + val classLoader = result.classLoader + val previewClass = classLoader.loadClass("test.PublicDesktopPreviewKt") + + val previewFunction = previewClass.declaredMethods.find { it.name == "PublicDesktopPreviewFunction" } + assertThat(previewFunction).isNotNull + assertThat(Modifier.isPublic(previewFunction!!.modifiers)).isTrue() + } +} diff --git a/modules/preview-processor-test/src/jvmTest/kotlin/PreviewProcessorTest.kt b/modules/preview-processor-test/src/jvmTest/kotlin/PreviewProcessorTest.kt new file mode 100644 index 0000000..c118742 --- /dev/null +++ b/modules/preview-processor-test/src/jvmTest/kotlin/PreviewProcessorTest.kt @@ -0,0 +1,282 @@ +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.sourcesGeneratedBySymbolProcessor +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import util.assertableGeneratedKspSources +import util.hasContent +import util.storytaleTest + +class PreviewProcessorTest { + @Test + fun `generates story for Jetbrains Compose Preview function`() { + val compilation = storytaleTest { + "KmpButton.kt" hasContent """ + package storytale.gallery.demo + + @androidx.compose.runtime.Composable + fun KmpButton() { } + + @org.jetbrains.compose.ui.tooling.preview.Preview + @androidx.compose.runtime.Composable + fun PreviewKmpButton() { + KmpButton() + } + """ + } + val result = compilation + .compile() + + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.OK) + + assertThat(result.sourcesGeneratedBySymbolProcessor.toList()).hasSize(1) + + assertThat(result.assertableGeneratedKspSources(compilation)) + .containsExactlyInAnyOrder( + "kotlin/org/jetbrains/compose/storytale/generated/Previews.story.kt" hasContent """ + |package org.jetbrains.compose.storytale.generated + | + |import org.jetbrains.compose.storytale.Story + |import org.jetbrains.compose.storytale.story + |import storytale.gallery.demo.PreviewKmpButton + | + |public val KmpButton: Story by story { + | PreviewKmpButton() + |} + """.trimMargin(), + ) + } + + @Test + fun `generates story for Androidx Desktop Preview function`() { + val compilation = storytaleTest { + "DesktopButton.kt" hasContent """ + package storytale.gallery.demo + + @androidx.compose.runtime.Composable + fun DesktopButton() { } + + @androidx.compose.desktop.ui.tooling.preview.Preview + @androidx.compose.runtime.Composable + fun PreviewDesktopButton() { + DesktopButton() + } + """ + } + val result = compilation + .compile() + + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.OK) + + assertThat(result.sourcesGeneratedBySymbolProcessor.toList()).hasSize(1) + + assertThat(result.assertableGeneratedKspSources(compilation)) + .containsExactlyInAnyOrder( + "kotlin/org/jetbrains/compose/storytale/generated/Previews.story.kt" hasContent """ + |package org.jetbrains.compose.storytale.generated + | + |import org.jetbrains.compose.storytale.Story + |import org.jetbrains.compose.storytale.story + |import storytale.gallery.demo.PreviewDesktopButton + | + |public val DesktopButton: Story by story { + | PreviewDesktopButton() + |} + """.trimMargin(), + ) + } + + @Test + fun `generates stories for multiple Preview functions in the same file`() { + val compilation = storytaleTest { + "MultiplePreviews.kt" hasContent """ + package storytale.gallery.demo + + import androidx.compose.runtime.Composable + import org.jetbrains.compose.ui.tooling.preview.Preview + + @Composable + fun Button() { } + + @Preview + @Composable + fun PreviewButton1() { + Button() + } + @Preview + @Composable + fun PreviewButton2() { + Button() + } + """.trimIndent() + } + val result = compilation.compile() + + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.OK) + + assertThat(result.sourcesGeneratedBySymbolProcessor.toList()).hasSize(1) + + assertThat(result.assertableGeneratedKspSources(compilation)) + .containsExactlyInAnyOrder( + "kotlin/org/jetbrains/compose/storytale/generated/Previews.story.kt" hasContent """ + |package org.jetbrains.compose.storytale.generated + | + |import org.jetbrains.compose.storytale.Story + |import org.jetbrains.compose.storytale.story + |import storytale.gallery.demo.PreviewButton1 + |import storytale.gallery.demo.PreviewButton2 + | + |public val Button1: Story by story { + | PreviewButton1() + |} + | + |public val Button2: Story by story { + | PreviewButton2() + |} + """.trimMargin(), + ) + } + + @Test + fun `generates stories for Preview functions in different files calling same component`() { + val compilation = storytaleTest { + "CommonButton.kt" hasContent """ + package storytale.gallery.demo + + import androidx.compose.runtime.Composable + + @Composable + fun Button() { } + """.trimIndent() + + "FirstPreview.kt" hasContent """ + package storytale.gallery.demo.first + + import androidx.compose.runtime.Composable + import org.jetbrains.compose.ui.tooling.preview.Preview + import storytale.gallery.demo.Button + + @Preview + @Composable + fun PreviewButtonPrimary() { + Button() + } + """.trimIndent() + + "SecondPreview.kt" hasContent """ + package storytale.gallery.demo.second + + import androidx.compose.runtime.Composable + import org.jetbrains.compose.ui.tooling.preview.Preview + import storytale.gallery.demo.Button + + @Preview + @Composable + fun PreviewButtonSecondary() { + Button() + } + """.trimIndent() + } + val result = compilation.compile() + + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.OK) + + assertThat(result.sourcesGeneratedBySymbolProcessor.toList()).hasSize(1) + + assertThat(result.assertableGeneratedKspSources(compilation)) + .containsExactlyInAnyOrder( + "kotlin/org/jetbrains/compose/storytale/generated/Previews.story.kt" hasContent """ + |package org.jetbrains.compose.storytale.generated + | + |import org.jetbrains.compose.storytale.Story + |import org.jetbrains.compose.storytale.story + |import storytale.gallery.demo.first.PreviewButtonPrimary + |import storytale.gallery.demo.second.PreviewButtonSecondary + | + |public val ButtonPrimary: Story by story { + | PreviewButtonPrimary() + |} + | + |public val ButtonSecondary: Story by story { + | PreviewButtonSecondary() + |} + """.trimMargin(), + ) + } + + @Test + fun `verifies stories are generated in alphabetical order`() { + val compilation = storytaleTest { + "ZAButton.kt" hasContent """ + package storytale.gallery.demo.z + + import androidx.compose.runtime.Composable + import org.jetbrains.compose.ui.tooling.preview.Preview + + @Composable + fun ZAButton() { } + + @Preview + @Composable + fun PreviewZAButton() { + ZAButton() + } + """.trimIndent() + + "BButton.kt" hasContent """ + package storytale.gallery.demo.b + + import androidx.compose.runtime.Composable + import org.jetbrains.compose.ui.tooling.preview.Preview + + @Composable + fun BButton() { } + + @Preview + @Composable + fun PreviewBButton() { + BButton() + } + """.trimIndent() + + "AButton.kt" hasContent """ + package storytale.gallery.demo.a + + import androidx.compose.runtime.Composable + import org.jetbrains.compose.ui.tooling.preview.Preview + + @Composable + fun AButton() { } + + @Preview + @Composable + fun PreviewAButton() { + AButton() + } + """.trimIndent() + } + val result = compilation.compile() + + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.OK) + + assertThat(result.sourcesGeneratedBySymbolProcessor.toList()).hasSize(1) + + // Verify the stories are generated in alphabetical order based on qualified name + // The package name affects the sorting order: a.PreviewAButton comes before b.PreviewBButton before z.PreviewZAButton + val generatedSource = result.assertableGeneratedKspSources(compilation).first() + val content = generatedSource.content + + val importLines = content.lines().filter { it.startsWith("import storytale.gallery.demo") } + assertThat(importLines).containsExactly( + "import storytale.gallery.demo.a.PreviewAButton", + "import storytale.gallery.demo.b.PreviewBButton", + "import storytale.gallery.demo.z.PreviewZAButton", + ) + + val storyLines = content.lines().filter { it.startsWith("public val") } + assertThat(storyLines).containsExactly( + "public val AButton: Story by story {", + "public val BButton: Story by story {", + "public val ZAButton: Story by story {", + ) + } +} diff --git a/modules/preview-processor/.gitignore b/modules/preview-processor/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/modules/preview-processor/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/modules/preview-processor/build.gradle.kts b/modules/preview-processor/build.gradle.kts new file mode 100644 index 0000000..2f9e98f --- /dev/null +++ b/modules/preview-processor/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + alias(libs.plugins.kotlin.jvm) +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21 + } +} + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +dependencies { + implementation(libs.kotlin.poet) + implementation(libs.ksp.api) + implementation(kotlin("compiler-embeddable")) + implementation(project(":modules:gradle-plugin")) +} diff --git a/modules/preview-processor/src/main/kotlin/MakePreviewPublicFirExtensionRegistrar.kt b/modules/preview-processor/src/main/kotlin/MakePreviewPublicFirExtensionRegistrar.kt new file mode 100644 index 0000000..2e6a505 --- /dev/null +++ b/modules/preview-processor/src/main/kotlin/MakePreviewPublicFirExtensionRegistrar.kt @@ -0,0 +1,46 @@ +import org.jetbrains.kotlin.descriptors.Visibilities +import org.jetbrains.kotlin.fir.FirSession +import org.jetbrains.kotlin.fir.declarations.FirDeclaration +import org.jetbrains.kotlin.fir.declarations.FirDeclarationStatus +import org.jetbrains.kotlin.fir.declarations.FirSimpleFunction +import org.jetbrains.kotlin.fir.declarations.hasAnnotation +import org.jetbrains.kotlin.fir.extensions.FirExtensionRegistrar +import org.jetbrains.kotlin.fir.extensions.FirStatusTransformerExtension +import org.jetbrains.kotlin.fir.extensions.transform +import org.jetbrains.kotlin.fir.symbols.impl.FirClassLikeSymbol +import org.jetbrains.kotlin.name.ClassId +import org.jetbrains.kotlin.name.FqName + +class MakePreviewPublicFirExtensionRegistrar : FirExtensionRegistrar() { + companion object { + private val PREVIEW_ANNOTATION_FQ_NAME = FqName("org.jetbrains.compose.ui.tooling.preview.Preview") + private val ANDROIDX_PREVIEW_ANNOTATION_FQ_NAME = FqName("androidx.compose.ui.tooling.preview.Preview") + private val DESKTOP_PREVIEW_ANNOTATION_FQ_NAME = FqName("androidx.compose.desktop.ui.tooling.preview.Preview") + + private val PREVIEW_CLASS_IDS = setOf( + ClassId.topLevel(PREVIEW_ANNOTATION_FQ_NAME), + ClassId.topLevel(ANDROIDX_PREVIEW_ANNOTATION_FQ_NAME), + ClassId.topLevel(DESKTOP_PREVIEW_ANNOTATION_FQ_NAME), + ) + } + + override fun ExtensionRegistrarContext.configurePlugin() { + +FirStatusTransformerExtension.Factory { session -> Extension(session) } + } + + class Extension(session: FirSession) : FirStatusTransformerExtension(session) { + override fun needTransformStatus(declaration: FirDeclaration): Boolean { + if (declaration !is FirSimpleFunction) return false + return PREVIEW_CLASS_IDS.any { declaration.hasAnnotation(it, session) } + } + + override fun transformStatus( + status: FirDeclarationStatus, + function: FirSimpleFunction, + containingClass: FirClassLikeSymbol<*>?, + isLocal: Boolean, + ): FirDeclarationStatus { + return status.transform(visibility = Visibilities.Public) + } + } +} diff --git a/modules/preview-processor/src/main/kotlin/PreviewComponentRegistrar.kt b/modules/preview-processor/src/main/kotlin/PreviewComponentRegistrar.kt new file mode 100644 index 0000000..71d1f33 --- /dev/null +++ b/modules/preview-processor/src/main/kotlin/PreviewComponentRegistrar.kt @@ -0,0 +1,13 @@ +import org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar +import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi +import org.jetbrains.kotlin.config.CompilerConfiguration +import org.jetbrains.kotlin.fir.extensions.FirExtensionRegistrarAdapter + +@OptIn(ExperimentalCompilerApi::class) +class PreviewComponentRegistrar : CompilerPluginRegistrar() { + override val supportsK2: Boolean get() = true + + override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) { + FirExtensionRegistrarAdapter.registerExtension(MakePreviewPublicFirExtensionRegistrar()) + } +} diff --git a/modules/preview-processor/src/main/kotlin/PreviewProcessor.kt b/modules/preview-processor/src/main/kotlin/PreviewProcessor.kt new file mode 100644 index 0000000..1dadd6b --- /dev/null +++ b/modules/preview-processor/src/main/kotlin/PreviewProcessor.kt @@ -0,0 +1,102 @@ +import com.google.devtools.ksp.processing.CodeGenerator +import com.google.devtools.ksp.processing.Dependencies +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSFunctionDeclaration +import com.google.devtools.ksp.validate +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.buildCodeBlock +import org.jetbrains.compose.storytale.plugin.StorytaleGradlePlugin + +class PreviewProcessor( + private val logger: KSPLogger, + private val codeGenerator: CodeGenerator, +) : SymbolProcessor { + override fun process(resolver: Resolver): List { + val (jetbrains, discarded1) = + resolver.getSymbolsWithAnnotation("org.jetbrains.compose.ui.tooling.preview.Preview") + .partition { it.validate() } + val (androidxDesktop, discarded2) = resolver.getSymbolsWithAnnotation("androidx.compose.desktop.ui.tooling.preview.Preview") + .partition { it.validate() } + + val (androidxAndroid, discarded3) = resolver.getSymbolsWithAnnotation("androidx.compose.ui.tooling.preview.Preview") + .partition { it.validate() } + + val validPreviewFunctions = (jetbrains + androidxDesktop + androidxAndroid) + .filter { it is KSFunctionDeclaration && it.validate() } + .map { it as KSFunctionDeclaration } + .sortedBy { (it.qualifiedName ?: it.simpleName).asString() } + + generatePreviewFile(validPreviewFunctions) + + return discarded1 + discarded2 + discarded3 + } + + private fun generatePreviewFile(previewFunctions: List) { + if (previewFunctions.isEmpty()) return + + val packageName = StorytaleGradlePlugin.STORYTALE_PACKAGE + val fileSpecBuilder = FileSpec.builder( + packageName, + "Previews.story", + ).apply { + indent(" ") + addImport("org.jetbrains.compose.storytale", "story") + + previewFunctions.forEach { function -> + addImport(function.packageName.asString(), function.simpleName.asString()) + + val functionName = function.simpleName.asString() + val storyName = functionName.removePrefix("Preview").removeSuffix("Preview") + + addProperty( + PropertySpec + .builder( + storyName.ifEmpty { functionName }, + ClassName("org.jetbrains.compose.storytale", "Story"), + ) + .delegate( + buildCodeBlock { + beginControlFlow("story") + addStatement("%N()", functionName) + endControlFlow() + }, + ) + .build(), + ) + } + } + + val containingFiles = previewFunctions + .mapNotNull { it.containingFile } + .distinct() + + val dependencies = if (containingFiles.isNotEmpty()) { + Dependencies(true, containingFiles.first()) + } else { + Dependencies.ALL_FILES + } + + val file = codeGenerator.createNewFile( + dependencies, + packageName, + "Previews.story", + ) + fileSpecBuilder.build().toJavaFileObject().openInputStream().copyTo(file) + } + + class Provider : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor { + return PreviewProcessor( + environment.logger, + environment.codeGenerator, + ) + } + } +} diff --git a/modules/preview-processor/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider b/modules/preview-processor/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider new file mode 100644 index 0000000..3c9e451 --- /dev/null +++ b/modules/preview-processor/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider @@ -0,0 +1 @@ +PreviewProcessor$Provider diff --git a/modules/preview-processor/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar b/modules/preview-processor/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar new file mode 100644 index 0000000..a67d468 --- /dev/null +++ b/modules/preview-processor/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar @@ -0,0 +1,17 @@ +# +# Copyright 2010-2023 JetBrains s.r.o. +# +# 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. +# + +PreviewComponentRegistrar diff --git a/modules/runtime-api/src/commonMain/kotlin/org/jetbrains/compose/storytale/PreviewParameter.kt b/modules/runtime-api/src/commonMain/kotlin/org/jetbrains/compose/storytale/PreviewParameter.kt new file mode 100644 index 0000000..859b14e --- /dev/null +++ b/modules/runtime-api/src/commonMain/kotlin/org/jetbrains/compose/storytale/PreviewParameter.kt @@ -0,0 +1,20 @@ +package org.jetbrains.compose.storytale + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf + +@Composable +inline fun previewParameter(defaultValue: T) = LocalStory.current.parameter(defaultValue) + +@Composable +inline fun previewParameter( + values: List, + defaultValueIndex: Int = 0, + label: String? = null, +) = LocalStory.current.parameter(values, defaultValueIndex, label) + +@Composable +inline fun > previewParameter(defaultValue: T, label: String? = null) = LocalStory.current.parameter(defaultValue, label) + +@PublishedApi +internal val LocalStory = staticCompositionLocalOf { Story(-1, "DefaultPreviewStory", "", "", {}) } diff --git a/modules/runtime-api/src/commonMain/kotlin/org/jetbrains/compose/storytale/StoryDelegate.kt b/modules/runtime-api/src/commonMain/kotlin/org/jetbrains/compose/storytale/StoryDelegate.kt index 477e575..638680a 100644 --- a/modules/runtime-api/src/commonMain/kotlin/org/jetbrains/compose/storytale/StoryDelegate.kt +++ b/modules/runtime-api/src/commonMain/kotlin/org/jetbrains/compose/storytale/StoryDelegate.kt @@ -1,6 +1,7 @@ package org.jetbrains.compose.storytale import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import kotlin.reflect.KProperty val storiesStorage = mutableListOf() @@ -23,7 +24,17 @@ class StoryDelegate( } operator fun provideDelegate(thisRef: Any?, property: KProperty<*>): StoryDelegate { - instance = Story(storiesStorage.size, property.name, group, code, content).also(storiesStorage::add) + val wrappedContent: @Composable Story.() -> Unit = { + val story = this + val delegate = this@StoryDelegate + + CompositionLocalProvider(LocalStory provides story) { + delegate.content(story) + } + } + + instance = Story(storiesStorage.size, property.name, group, code, wrappedContent) + .also(storiesStorage::add) return this } } diff --git a/settings.gradle.kts b/settings.gradle.kts index f160878..6e7dddc 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -59,5 +59,7 @@ include(":modules:gradle-plugin") include(":modules:compiler-plugin") include(":modules:dokka-plugin") include(":modules:runtime-api") +include(":modules:preview-processor") +include(":modules:preview-processor-test") include(":gallery-demo")