diff --git a/gradle-plugins/build.gradle.kts b/gradle-plugins/build.gradle.kts index 9e9474f823..1c9c5500b2 100644 --- a/gradle-plugins/build.gradle.kts +++ b/gradle-plugins/build.gradle.kts @@ -115,3 +115,9 @@ fun Project.configureGradlePlugin( } } } + +tasks.register("publishToMavenLocal") { + for (subproject in subprojects) { + dependsOn(subproject.tasks.named("publishToMavenLocal")) + } +} diff --git a/gradle-plugins/buildSrc/src/main/kotlin/gradleUtils.kt b/gradle-plugins/buildSrc/src/main/kotlin/gradleUtils.kt index 1f482ebbf1..eca0997c01 100644 --- a/gradle-plugins/buildSrc/src/main/kotlin/gradleUtils.kt +++ b/gradle-plugins/buildSrc/src/main/kotlin/gradleUtils.kt @@ -3,8 +3,51 @@ * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. */ +import org.gradle.api.JavaVersion import org.gradle.api.Project +import org.gradle.api.artifacts.dsl.DependencyHandler +import org.gradle.api.plugins.JavaPlugin +import org.gradle.api.tasks.testing.Test +import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.withType +import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform +import java.io.File inline fun Project.configureIfExists(fn: T.() -> Unit) { extensions.findByType(T::class.java)?.fn() +} + +val javaHomeForTests: String? = when { + // __COMPOSE_NATIVE_DISTRIBUTIONS_MIN_JAVA_VERSION__ + JavaVersion.current() >= JavaVersion.VERSION_15 -> System.getProperty("java.home") + else -> System.getenv("JDK_15") + ?: System.getenv("JDK_FOR_GRADLE_TESTS") +} + +val isWindows = DefaultNativePlatform.getCurrentOperatingSystem().isWindows + +fun Test.configureJavaForComposeTest() { + if (javaHomeForTests != null) { + val executableFileName = if (isWindows) "java.exe" else "java" + executable = File(javaHomeForTests).resolve("bin/$executableFileName").absolutePath + } else { + doFirst { error("Use JDK 15+ to run tests or set up JDK_15/JDK_FOR_GRADLE_TESTS env. var") } + } +} + +fun Project.configureJUnit() { + fun DependencyHandler.testImplementation(notation: Any) = + add(JavaPlugin.TEST_IMPLEMENTATION_CONFIGURATION_NAME, notation) + + dependencies { + testImplementation(platform("org.junit:junit-bom:5.7.0")) + testImplementation("org.junit.jupiter:junit-jupiter") + } + + tasks.withType().configureEach { + useJUnitPlatform() + testLogging { + events("passed", "skipped", "failed") + } + } } \ No newline at end of file diff --git a/gradle-plugins/compose-preview-runtime/build.gradle.kts b/gradle-plugins/compose-preview-runtime/build.gradle.kts deleted file mode 100644 index cc9b75cee3..0000000000 --- a/gradle-plugins/compose-preview-runtime/build.gradle.kts +++ /dev/null @@ -1,14 +0,0 @@ -plugins { - kotlin("jvm") - id("maven-publish") -} - -mavenPublicationConfig { - displayName = "Compose Desktop Preview Runtime" - description = "Runtime helpers for Compose Desktop Preview" - artifactId = "compose-preview-runtime-desktop" -} - -dependencies { - compileOnly("org.jetbrains.kotlin:kotlin-stdlib") -} \ No newline at end of file diff --git a/gradle-plugins/compose-preview-runtime/src/main/kotlin/org/jetbrains/compose/desktop/preview/runtime/ComposePreviewRunner.kt b/gradle-plugins/compose-preview-runtime/src/main/kotlin/org/jetbrains/compose/desktop/preview/runtime/ComposePreviewRunner.kt deleted file mode 100644 index a2760c6e02..0000000000 --- a/gradle-plugins/compose-preview-runtime/src/main/kotlin/org/jetbrains/compose/desktop/preview/runtime/ComposePreviewRunner.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. - * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. - */ - -package org.jetbrains.compose.desktop.preview.runtime - -import kotlin.reflect.KProperty1 - -class ComposePreviewRunner { - companion object { - private const val PREVIEW_ANNOTATION_FQ_NAME = "androidx.compose.ui.tooling.desktop.preview.Preview" - - @JvmStatic - fun main(args: Array) { - val classLoader = ComposePreviewRunner::class.java.classLoader - - val previewFqName = args[0] - val previewClassFqName = previewFqName.substringBeforeLast(".") - val previewMethodName = previewFqName.substringAfterLast(".") - val previewClass = classLoader.loadClass(previewClassFqName) - val previewMethod = previewClass.methods.find { it.name == previewMethodName } - ?: error("Could not find method '$previewMethodName' in class '${previewClass.canonicalName}'") - - val content = previewMethod.invoke(previewClass) - val previewAnnotation = previewMethod.annotations.find { it.annotationClass.qualifiedName == PREVIEW_ANNOTATION_FQ_NAME } - ?: error("Could not find '$PREVIEW_ANNOTATION_FQ_NAME' annotation on '$previewClassFqName#$previewMethodName'") - val environmentKClassProperty = previewAnnotation.annotationClass.members.find { it is KProperty1<*, *> && it.name == "environment" } - as KProperty1> - val environmentClass = environmentKClassProperty.get(previewAnnotation) - val previewEnvironment = environmentClass - .getDeclaredConstructor() - .newInstance() - val showMethod = previewEnvironment.javaClass - .methods.find { it.name == "show" } - ?: error("Could not find 'show' in class '${environmentClass.canonicalName}'") - showMethod.invoke(previewEnvironment, content) - } - } -} \ No newline at end of file diff --git a/gradle-plugins/compose/build.gradle.kts b/gradle-plugins/compose/build.gradle.kts index 93475c9290..fbc0223ad2 100644 --- a/gradle-plugins/compose/build.gradle.kts +++ b/gradle-plugins/compose/build.gradle.kts @@ -35,6 +35,8 @@ dependencies { compileOnly(localGroovy()) compileOnly(kotlin("gradle-plugin-api")) compileOnly(kotlin("gradle-plugin")) + implementation(project(":preview-rpc")) + testImplementation(gradleTestKit()) testImplementation(platform("org.junit:junit-bom:5.7.0")) testImplementation("org.junit.jupiter:junit-jupiter") @@ -97,22 +99,13 @@ fun testGradleVersion(gradleVersion: String) { } } +configureJUnit() + tasks.withType().configureEach { - useJUnitPlatform() - testLogging { - events("passed", "skipped", "failed") - } + configureJavaForComposeTest() dependsOn("publishToMavenLocal") systemProperty("compose.plugin.version", BuildProperties.deployVersion(project)) - - if (javaHomeForTests != null) { - val executableFileName = if (isWindows) "java.exe" else "java" - executable = File(javaHomeForTests).resolve("bin/$executableFileName").absolutePath - } else { - doFirst { error("Use JDK 15+ to run tests or set up JDK_15/JDK_FOR_GRADLE_TESTS env. var") } - } - } task("printAllAndroidxReplacements") { diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/ComposePlugin.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/ComposePlugin.kt index 94b4378f27..cfd6a25d30 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/ComposePlugin.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/ComposePlugin.kt @@ -115,6 +115,7 @@ class ComposePlugin : Plugin { val material get() = composeDependency("org.jetbrains.compose.material:material") val runtime get() = composeDependency("org.jetbrains.compose.runtime:runtime") val ui get() = composeDependency("org.jetbrains.compose.ui:ui") + val uiTooling get() = composeDependency("org.jetbrains.compose.ui:ui-tooling") val materialIconsExtended get() = composeDependency("org.jetbrains.compose.material:material-icons-extended") val web: WebDependencies get() = if (ComposeBuildConfig.isComposeWithWeb) WebDependencies diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/configureApplication.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/configureApplication.kt index 55c6da6f39..27befc0e70 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/configureApplication.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/application/internal/configureApplication.kt @@ -17,8 +17,8 @@ import org.jetbrains.compose.desktop.application.dsl.Application import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.jetbrains.compose.desktop.application.internal.validation.validatePackageVersions import org.jetbrains.compose.desktop.application.tasks.* -import org.jetbrains.compose.desktop.preview.internal.configureRunPreviewTask -import org.jetbrains.compose.desktop.preview.tasks.AbstractRunComposePreviewTask +import org.jetbrains.compose.desktop.preview.internal.configureConfigureDesktopPreviewTask +import org.jetbrains.compose.desktop.preview.tasks.AbstractConfigureDesktopPreviewTask import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType import java.io.File @@ -158,8 +158,8 @@ internal fun Project.configurePackagingTasks(apps: Collection) { configureRunTask(app) } - val runPreview = project.tasks.composeTask("runComposeDesktopPreview") { - configureRunPreviewTask(app) + val configureDesktopPreviewTask = project.tasks.composeTask("configureDesktopPreview") { + configureConfigureDesktopPreviewTask(app) } } } diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/preview/internal/configurePreview.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/preview/internal/configurePreview.kt index 583871478e..bed2520cbb 100644 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/preview/internal/configurePreview.kt +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/preview/internal/configurePreview.kt @@ -1,25 +1,18 @@ package org.jetbrains.compose.desktop.preview.internal import org.gradle.api.Project -import org.jetbrains.compose.composeVersion import org.jetbrains.compose.desktop.application.dsl.Application import org.jetbrains.compose.desktop.application.internal.javaHomeOrDefault import org.jetbrains.compose.desktop.application.internal.provider -import org.jetbrains.compose.desktop.preview.tasks.AbstractRunComposePreviewTask - -internal const val PREVIEW_RUNTIME_CLASSPATH_CONFIGURATION = "composeDesktopPreviewRuntimeClasspath" -private val COMPOSE_PREVIEW_RUNTIME_DEPENDENCY = "org.jetbrains.compose:compose-preview-runtime-desktop:$composeVersion" +import org.jetbrains.compose.desktop.preview.tasks.AbstractConfigureDesktopPreviewTask fun Project.initializePreview() { - configurations.create(PREVIEW_RUNTIME_CLASSPATH_CONFIGURATION).defaultDependencies { deps -> - deps.add(dependencies.create(COMPOSE_PREVIEW_RUNTIME_DEPENDENCY)) - } } -internal fun AbstractRunComposePreviewTask.configureRunPreviewTask(app: Application) { +internal fun AbstractConfigureDesktopPreviewTask.configureConfigureDesktopPreviewTask(app: Application) { app._configurationSource?.let { configSource -> dependsOn(configSource.jarTaskName) - classpath = configSource.runtimeClasspath(project) + previewClasspath = configSource.runtimeClasspath(project) javaHome.set(provider { app.javaHomeOrDefault() }) jvmArgs.set(provider { app.jvmArgs }) } diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/preview/tasks/AbstractConfigureDesktopPreviewTask.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/preview/tasks/AbstractConfigureDesktopPreviewTask.kt new file mode 100644 index 0000000000..9348bcfa73 --- /dev/null +++ b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/preview/tasks/AbstractConfigureDesktopPreviewTask.kt @@ -0,0 +1,82 @@ +package org.jetbrains.compose.desktop.preview.tasks + +import org.gradle.api.file.FileCollection +import org.gradle.api.logging.Logger as GradleLogger +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.* +import org.jetbrains.compose.ComposeBuildConfig +import org.jetbrains.compose.desktop.application.internal.javaExecutable +import org.jetbrains.compose.desktop.application.internal.notNullProperty +import org.jetbrains.compose.desktop.tasks.AbstractComposeDesktopTask +import org.jetbrains.compose.desktop.ui.tooling.preview.rpc.* +import java.io.File + +abstract class AbstractConfigureDesktopPreviewTask : AbstractComposeDesktopTask() { + @get:InputFiles + internal lateinit var previewClasspath: FileCollection + + @get:Internal + internal val javaHome: Property = objects.notNullProperty().apply { + set(providers.systemProperty("java.home")) + } + + // todo + @get:Input + @get:Optional + internal val jvmArgs: ListProperty = objects.listProperty(String::class.java) + + @get:Input + internal val previewTarget: Provider = + project.providers.gradleProperty("compose.desktop.preview.target") + + @get:Input + internal val idePort: Provider = + project.providers.gradleProperty("compose.desktop.preview.ide.port") + + @get:InputFiles + internal val hostClasspath = project.configurations.detachedConfiguration( + project.dependencies.create("org.jetbrains.compose:preview-rpc:${ComposeBuildConfig.VERSION}") + ) + + @TaskAction + fun run() { + val hostConfig = PreviewHostConfig( + javaExecutable = javaExecutable(javaHome.get()), + hostClasspath = hostClasspath.files.pathString() + ) + val previewClasspathString = previewClasspath.files.pathString() + + val gradleLogger = logger + val previewLogger = GradlePreviewLoggerAdapter(gradleLogger) + + val connection = getLocalConnectionOrNull(idePort.get().toInt(), previewLogger, onClose = {}) + if (connection != null) { + connection.use { + connection.sendConfigFromGradle( + hostConfig, + previewClasspath = previewClasspathString, + previewFqName = previewTarget.get() + ) + } + } else { + gradleLogger.error("Could not connect to IDE") + } + } + + private fun Collection.pathString(): String = + joinToString(File.pathSeparator) { it.absolutePath } + + private class GradlePreviewLoggerAdapter( + private val logger: GradleLogger + ) : PreviewLogger() { + // todo: support compose.verbose + override val isEnabled: Boolean + get() = logger.isDebugEnabled + + override fun log(s: String) { + logger.info("Compose Preview: $s") + } + } +} \ No newline at end of file diff --git a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/preview/tasks/AbstractRunComposePreviewTask.kt b/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/preview/tasks/AbstractRunComposePreviewTask.kt deleted file mode 100644 index 723d76d698..0000000000 --- a/gradle-plugins/compose/src/main/kotlin/org/jetbrains/compose/desktop/preview/tasks/AbstractRunComposePreviewTask.kt +++ /dev/null @@ -1,39 +0,0 @@ -package org.jetbrains.compose.desktop.preview.tasks - -import org.gradle.api.file.FileCollection -import org.gradle.api.provider.ListProperty -import org.gradle.api.provider.Property -import org.gradle.api.tasks.* -import org.jetbrains.compose.desktop.application.internal.javaExecutable -import org.jetbrains.compose.desktop.application.internal.notNullProperty -import org.jetbrains.compose.desktop.preview.internal.PREVIEW_RUNTIME_CLASSPATH_CONFIGURATION -import org.jetbrains.compose.desktop.tasks.AbstractComposeDesktopTask - -abstract class AbstractRunComposePreviewTask : AbstractComposeDesktopTask() { - @get:InputFiles - internal lateinit var classpath: FileCollection - - @get:InputFiles - internal val previewRuntimeClasspath: FileCollection - get() = project.configurations.getByName(PREVIEW_RUNTIME_CLASSPATH_CONFIGURATION) - - @get:Internal - internal val javaHome: Property = objects.notNullProperty().apply { - set(providers.systemProperty("java.home")) - } - - @get:Input - @get:Optional - internal val jvmArgs: ListProperty = objects.listProperty(String::class.java) - - @TaskAction - fun run() { - val target = project.findProperty("compose.desktop.preview.target") as String - execOperations.javaexec { javaExec -> - javaExec.executable = javaExecutable(javaHome.get()) - javaExec.main = "org.jetbrains.compose.desktop.preview.runtime.ComposePreviewRunner" - javaExec.classpath = previewRuntimeClasspath + classpath - javaExec.args = listOf(target) - } - } -} \ No newline at end of file diff --git a/gradle-plugins/preview-rpc/build.gradle.kts b/gradle-plugins/preview-rpc/build.gradle.kts new file mode 100644 index 0000000000..b4945c5ab4 --- /dev/null +++ b/gradle-plugins/preview-rpc/build.gradle.kts @@ -0,0 +1,32 @@ +plugins { + kotlin("jvm") + id("maven-publish") +} + +mavenPublicationConfig { + displayName = "Compose Preview RPC" + description = "Compose Preview RPC" + artifactId = "preview-rpc" +} + +dependencies { + implementation(kotlin("stdlib")) +} + +configureJUnit() + +tasks.test.configure { + configureJavaForComposeTest() + + val runtimeClasspath = configurations.runtimeClasspath + dependsOn(runtimeClasspath) + val jar = tasks.jar + dependsOn(jar) + doFirst { + val rpcClasspath = LinkedHashSet() + rpcClasspath.add(jar.get().archiveFile.get().asFile) + rpcClasspath.addAll(runtimeClasspath.get().files) + val classpathString = rpcClasspath.joinToString(File.pathSeparator) { it.absolutePath } + systemProperty("org.jetbrains.compose.test.rpc.classpath", classpathString) + } +} \ No newline at end of file diff --git a/gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/Command.kt b/gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/Command.kt new file mode 100644 index 0000000000..93bf2f916f --- /dev/null +++ b/gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/Command.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.desktop.ui.tooling.preview.rpc + +data class Command(val type: Type, val args: List) { + enum class Type { + ATTACH, + FRAME, + PREVIEW_CONFIG, + PREVIEW_CLASSPATH, + PREVIEW_FQ_NAME, + FRAME_REQUEST + } + + constructor(type: Type, vararg args: String) : this(type, args.toList()) + + fun asString() = + (sequenceOf(type.name) + args.asSequence()).joinToString(" ") + + companion object { + private val typeByName: Map = + Type.values().associateBy { it.name } + + fun fromString(s: String): Command? { + val wordsIt = s.splitToSequence(" ").iterator() + val cmdName = wordsIt.nextOrNull() ?: return null + val type = typeByName[cmdName] ?: return null + val args = arrayListOf() + wordsIt.forEachRemaining { + args.add(it) + } + return Command(type, args) + } + } +} diff --git a/gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/ExitCodes.kt b/gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/ExitCodes.kt new file mode 100644 index 0000000000..14366c228f --- /dev/null +++ b/gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/ExitCodes.kt @@ -0,0 +1,11 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.desktop.ui.tooling.preview.rpc + +internal object ExitCodes { + const val OK = 0 + const val COULD_NOT_CONNECT_TO_PREVIEW_MANAGER = 1 +} \ No newline at end of file diff --git a/gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/PreviewLogger.kt b/gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/PreviewLogger.kt new file mode 100644 index 0000000000..e56ce0ace8 --- /dev/null +++ b/gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/PreviewLogger.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.desktop.ui.tooling.preview.rpc + +import java.io.PrintStream + +abstract class PreviewLogger { + inline operator fun invoke(s: () -> String) { + if (isEnabled) { + log(s()) + } + } + + inline fun error(msg: () -> String) { + invoke { "error: ${msg()}" } + } + + abstract val isEnabled: Boolean + abstract fun log(s: String) +} + +internal class PrintStreamLogger( + private val prefix: String, + private val printStream: PrintStream = System.out +) : PreviewLogger() { + override val isEnabled: Boolean = true + + override fun log(s: String) { + printStream.print(prefix) + printStream.print(":") + printStream.println(s) + } +} \ No newline at end of file diff --git a/gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/PreviewManager.kt b/gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/PreviewManager.kt new file mode 100644 index 0000000000..7ceeae5aeb --- /dev/null +++ b/gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/PreviewManager.kt @@ -0,0 +1,253 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.desktop.ui.tooling.preview.rpc + +import java.io.IOException +import java.net.ServerSocket +import java.net.SocketTimeoutException +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.atomic.AtomicReference +import kotlin.concurrent.thread +import kotlin.system.measureTimeMillis + +data class PreviewHostConfig( + val javaExecutable: String, + val hostClasspath: String +) + +data class FrameConfig(val width: Int, val height: Int, val scale: Double?) { + val scaledWidth: Int get() = scaledValue(width) + val scaledHeight: Int get() = scaledValue(height) + + private fun scaledValue(value: Int): Int = + if (scale != null) (value.toDouble() * scale).toInt() else value +} + +data class FrameRequest( + val composableFqName: String, + val frameConfig: FrameConfig +) + +interface PreviewManager { + val gradleCallbackPort: Int + fun updateFrameConfig(frameConfig: FrameConfig) + fun close() +} + +private data class RunningPreview( + val connection: RemoteConnection, + val process: Process +) { + val isAlive: Boolean + get() = connection.isAlive && process.isAlive +} + +class PreviewManagerImpl(private val onNewFrame: (RenderedFrame) -> Unit) : PreviewManager { + private val log = PrintStreamLogger("SERVER") + private val previewSocket = newServerSocket() + private val gradleCallbackSocket = newServerSocket() + private val connectionNumber = AtomicLong(0) + private val isAlive = AtomicBoolean(true) + + // todo: restart when configuration changes + private val previewHostConfig = AtomicReference(null) + private val previewClasspath = AtomicReference(null) + private val previewFqName = AtomicReference(null) + private val previewFrameConfig = AtomicReference(null) + private val frameRequest = AtomicReference(null) + private val shouldRequestFrame = AtomicBoolean(false) + private val runningPreview = AtomicReference(null) + private val threads = arrayListOf() + + private val runPreviewThread = repeatWhileAliveThread("runPreview") { + fun startPreviewProcess(config: PreviewHostConfig): Process = + ProcessBuilder( + config.javaExecutable, + "-Djava.awt.headless=true", + "-classpath", + config.hostClasspath, + PREVIEW_HOST_CLASS_NAME, + previewSocket.localPort.toString() + ).apply { + // todo: non verbose mode + redirectOutput(ProcessBuilder.Redirect.INHERIT) + redirectError(ProcessBuilder.Redirect.INHERIT) + }.start() + + val runningPreview = runningPreview.get() + val previewConfig = previewHostConfig.get() + if (previewConfig != null && runningPreview?.isAlive != true) { + val process = startPreviewProcess(previewConfig) + val connection = tryAcceptConnection(previewSocket, "PREVIEW") + connection?.receiveAttach { + this.runningPreview.set(RunningPreview(connection, process)) + } + } + } + + private val sendPreviewRequestThread = repeatWhileAliveThread("sendPreviewRequest") { + withLivePreviewConnection { + val classpath = previewClasspath.get() + val fqName = previewFqName.get() + val frameConfig = previewFrameConfig.get() + + if (classpath != null && frameConfig != null && fqName != null) { + val request = FrameRequest(fqName, frameConfig) + if (shouldRequestFrame.get() && frameRequest.get() == null) { + if (shouldRequestFrame.compareAndSet(true, false)) { + if (frameRequest.compareAndSet(null, request)) { + sendPreviewRequest(classpath, request) + } else { + shouldRequestFrame.compareAndSet(false, true) + } + } + } + } + } + } + + private val receivePreviewResponseThread = repeatWhileAliveThread("receivePreviewResponse") { + withLivePreviewConnection { + receiveFrame { renderedFrame -> + frameRequest.get()?.let { request -> + frameRequest.compareAndSet(request, null) + } + onNewFrame(renderedFrame) + } + } + } + + private val gradleCallbackThread = repeatWhileAliveThread("gradleCallback") { + tryAcceptConnection(gradleCallbackSocket, "GRADLE_CALLBACK")?.let { connection -> + while (isAlive.get() && connection.isAlive) { + connection.receiveConfigFromGradle( + onPreviewClasspath = { previewClasspath.set(it) }, + onPreviewHostConfig = { previewHostConfig.set(it) }, + onPreviewFqName = { previewFqName.set(it) } + ) + shouldRequestFrame.set(true) + sendPreviewRequestThread.interrupt() + } + } + } + + override fun close() { + if (!isAlive.compareAndSet(true, false)) return + + closeService("PREVIEW MANAGER") { + val runningPreview = runningPreview.getAndSet(null) + val previewConnection = runningPreview?.connection + val previewProcess = runningPreview?.process + threads.forEach { it.interrupt() } + + closeService("PREVIEW HOST CONNECTION") { previewConnection?.close() } + closeService("PREVIEW SOCKET") { previewSocket.close() } + closeService("GRADLE SOCKET") { gradleCallbackSocket.close() } + closeService("THREADS") { + for (i in 0..3) { + var aliveThreads = 0 + for (t in threads) { + if (t.isAlive) { + aliveThreads++ + t.interrupt() + } + } + if (aliveThreads == 0) break + else Thread.sleep(300) + } + val aliveThreads = threads.filter { it.isAlive } + if (aliveThreads.isNotEmpty()) { + error("Could not stop threads: ${aliveThreads.joinToString(", ") { it.name }}") + } + } + closeService("PREVIEW HOST PROCESS") { + previewProcess?.let { process -> + if (!process.waitFor(5, TimeUnit.SECONDS)) { + log { "FORCIBLY DESTROYING PREVIEW HOST PROCESS" } + // todo: check exit code + process.destroyForcibly() + } + } + } + } + } + + private inline fun closeService(name: String, doClose: () -> Unit) { + try { + log { "CLOSING $name" } + val ms = measureTimeMillis { + doClose() + } + log { "CLOSED $name in $ms ms" } + } catch (e: Exception) { + log.error { "ERROR CLOSING $name: ${e.stackTraceString}" } + } + } + + override fun updateFrameConfig(frameConfig: FrameConfig) { + previewFrameConfig.set(frameConfig) + shouldRequestFrame.set(true) + sendPreviewRequestThread.interrupt() + } + + override val gradleCallbackPort: Int + get() = gradleCallbackSocket.localPort + + private fun tryAcceptConnection( + serverSocket: ServerSocket, socketType: String + ): RemoteConnection? { + while (isAlive.get()) { + try { + val socket = serverSocket.accept() + return RemoteConnectionImpl( + socket = socket, + log = PrintStreamLogger("CONNECTION ($socketType) #${connectionNumber.incrementAndGet()}"), + onClose = { + // todo + } + ) + } catch (e: IOException) { + if (e !is SocketTimeoutException) { + if (isAlive.get()) { + log.error { e.stackTraceToString() } + } + } + } + } + + return null + } + + private inline fun withLivePreviewConnection(fn: RemoteConnection.() -> Unit) { + val runningPreview = runningPreview.get() ?: return + if (runningPreview.isAlive) { + runningPreview.connection.fn() + } + } + + private inline fun repeatWhileAliveThread( + name: String, + sleepDelayMs: Long = DEFAULT_SLEEP_DELAY_MS, + crossinline fn: () -> Unit + ) = thread(name = name, start = false) { + while (isAlive.get()) { + try { + fn() + Thread.sleep(sleepDelayMs) + } catch (e: InterruptedException) { + continue + } catch (e: Throwable) { + e.printStackTrace(System.err) + break + } + } + }.also { + threads.add(it) + it.start() + } +} diff --git a/gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/RemoteConnection.kt b/gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/RemoteConnection.kt new file mode 100644 index 0000000000..249510f5da --- /dev/null +++ b/gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/RemoteConnection.kt @@ -0,0 +1,158 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.desktop.ui.tooling.preview.rpc + +import java.io.* +import java.net.Socket +import java.net.SocketTimeoutException +import java.util.concurrent.atomic.AtomicBoolean + +fun getLocalConnectionOrNull( + port: Int, logger: PreviewLogger, onClose: () -> Unit +): RemoteConnection? = + getLocalSocketOrNull(port, trials = 3, trialDelay = 500)?.let { socket -> + RemoteConnectionImpl(socket, logger, onClose) + } + +abstract class RemoteConnection : AutoCloseable { + abstract val isAlive: Boolean + abstract fun receiveCommand(onResult: (Command) -> Unit) + abstract fun receiveData(onResult: (ByteArray) -> Unit) + abstract fun sendCommand(command: Command) + abstract fun sendData(data: ByteArray) + + fun receiveUtf8StringData(onResult: (String) -> Unit) { + receiveData { bytes -> + val string = bytes.toString(Charsets.UTF_8) + onResult(string) + } + } + fun sendUtf8StringData(string: String) { + sendData(string.toByteArray(Charsets.UTF_8)) + } + + fun sendCommand(type: Command.Type, vararg args: String) { + sendCommand(Command(type, *args)) + } +} + +internal class RemoteConnectionImpl( + private val socket: Socket, + private val log: PreviewLogger, + private val onClose: () -> Unit +): RemoteConnection() { + init { + socket.soTimeout = SOCKET_TIMEOUT_MS + } + + private val input = DataInputStream(socket.getInputStream()) + private val output = DataOutputStream(socket.getOutputStream()) + private var isConnectionAlive = AtomicBoolean(true) + + override val isAlive: Boolean + get() = !socket.isClosed && isConnectionAlive.get() + + private inline fun ifAlive(fn: () -> Unit) { + if (isAlive) { + fn() + } + } + + override fun close() { + if (isConnectionAlive.compareAndSet(true, false)) { + log { "CLOSING" } + socket.close() + onClose() + log { "CLOSED" } + } + } + + override fun sendCommand(command: Command) = ifAlive { + val commandStr = command.asString() + val data = commandStr.toByteArray() + writeData(output, data, maxDataSize = MAX_CMD_SIZE) + log { "SENT COMMAND '$commandStr'" } + } + + override fun sendData(data: ByteArray) = ifAlive { + writeData(output, data, maxDataSize = MAX_BINARY_SIZE) + log { "SENT DATA [${data.size}]" } + } + + override fun receiveCommand(onResult: (Command) -> Unit) = ifAlive { + val line = readData(input, MAX_CMD_SIZE)?.toString(Charsets.UTF_8) + if (line != null) { + val cmd = Command.fromString(line) + if (cmd == null) { + log { "GOT UNKNOWN COMMAND '$line'" } + } else { + log { "GOT COMMAND '$line'" } + onResult(cmd) + } + } else { + close() + } + } + + override fun receiveData(onResult: (ByteArray) -> Unit) = ifAlive { + val data = readData(input, MAX_BINARY_SIZE) + if (data != null) { + log { "GOT [${data.size}]" } + onResult(data) + } else { + close() + } + } + + private fun writeData(output: DataOutputStream, data: ByteArray, maxDataSize: Int): Boolean { + if (!isAlive) return false + + return try { + val size = data.size + assert(size < maxDataSize) { "Data is too big: $size >= $maxDataSize" } + output.writeInt(size) + var index = 0 + val bufSize = minOf(MAX_BUF_SIZE, size) + while (index < size) { + val len = minOf(bufSize, size - index) + output.write(data, index, len) + index += len + } + output.flush() + true + } catch (e: IOException) { + false + } + } + + private fun readData(input: DataInputStream, maxDataSize: Int): ByteArray? { + while (isAlive) { + try { + val size = input.readInt() + if (size == -1) { + break + } else { + assert(size < maxDataSize) { "Data is too big: $size >= $maxDataSize" } + val bytes = ByteArray(size) + val bufSize = minOf(size, MAX_BUF_SIZE) + var index = 0 + while (index < size) { + val len = minOf(bufSize, size - index) + val bytesRead = input.read(bytes, index, len) + index += bytesRead + } + return bytes + } + } catch (e: IOException) { + if (e !is SocketTimeoutException) break + } + } + + return null + } +} + + diff --git a/gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/RemotePreviewHost.kt b/gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/RemotePreviewHost.kt new file mode 100644 index 0000000000..f11f144fd0 --- /dev/null +++ b/gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/RemotePreviewHost.kt @@ -0,0 +1,159 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.desktop.ui.tooling.preview.rpc + +import java.io.File +import java.lang.RuntimeException +import java.lang.reflect.Method +import java.net.SocketTimeoutException +import java.net.URLClassLoader +import java.util.concurrent.atomic.AtomicReference +import kotlin.concurrent.thread +import kotlin.system.exitProcess +import kotlin.system.measureTimeMillis +import kotlin.time.DurationUnit +import kotlin.time.ExperimentalTime +import kotlin.time.measureTimedValue + +val PREVIEW_HOST_CLASS_NAME: String + get() = PreviewHost::class.java.canonicalName + +private class PreviewClassloaderProvider { + private var currentClasspath = arrayOf() + private var currentSnapshots = hashSetOf() + private var currentClassloader = URLClassLoader(emptyArray()) + + // todo: read in memory on Windows + fun getClassloader(classpathString: String): ClassLoader { + val newClasspath = classpathString.split(File.pathSeparator) + .map { File(it) } + .toTypedArray() + val newSnapshots = newClasspath.mapTo(HashSet()) { Snapshot(it) } + if (!currentClasspath.contentEquals(newClasspath) || newSnapshots != currentSnapshots) { + currentClasspath = newClasspath + currentSnapshots = newSnapshots + + currentClassloader.close() + currentClassloader = URLClassLoader(Array(newClasspath.size) { newClasspath[it].toURI().toURL() }) + } + + return currentClassloader + } + + private data class Snapshot(val file: File, val lastModified: Long, val size: Long) { + constructor(file: File) : this(file, file.lastModified(), file.length()) + } +} + +internal class PreviewHost(private val log: PreviewLogger, connection: RemoteConnection) { + private val previewClasspath = AtomicReference(null) + private val previewRequest = AtomicReference(null) + private val classloaderProvider = PreviewClassloaderProvider() + + init { + connection.sendAttach() + } + + private val senderThread = thread { + while (connection.isAlive) { + try { + val classpath = previewClasspath.get() + val request = previewRequest.get() + if (classpath != null && request != null) { + if (previewRequest.compareAndSet(request, null)) { + val bytes = renderFrame(classpath, request) + val config = request.frameConfig + val frame = RenderedFrame(bytes, width = config.width, height = config.height) + connection.sendFrame(frame) + } + } + Thread.sleep(DEFAULT_SLEEP_DELAY_MS) + } catch (e: InterruptedException) { + continue + } + } + } + + val receiverThread = thread { + try { + while (connection.isAlive) { + try { + connection.receivePreviewRequest( + onPreviewClasspath = { + previewClasspath.set(it) + senderThread.interrupt() + }, + onFrameRequest = { + previewRequest.set(it) + senderThread.interrupt() + } + ) + } catch (e: SocketTimeoutException) { + continue + } + } + } catch (e: Throwable) { + e.printStackTrace(System.err) + exitProcess(1) + } + } + + fun join() { + senderThread.join() + receiverThread.join() + } + + private fun renderFrame( + classpath: String, + request: FrameRequest + ): ByteArray { + val classloader = classloaderProvider.getClassloader(classpath) + val previewFacade = classloader.loadClass(PREVIEW_FACADE_CLASS_NAME) + val renderArgsClasses = arrayOf( + String::class.java, + Int::class.java, + Int::class.java, + java.lang.Double::class.java + ) + val render = try { + previewFacade.getMethod("render", *renderArgsClasses) + } catch (e: NoSuchMethodException) { + val signature = + "${previewFacade.canonicalName}#render(${renderArgsClasses.joinToString(", ") { it.simpleName }})" + val possibleCandidates = previewFacade.methods.filter { it.name == "render" } + throw RuntimeException("Could not find method '$signature'. Possible candidates: \n${possibleCandidates.joinToString("\n") { "* ${it}" }}", e) + } + val (fqName, frameConfig) = request + val scaledWidth = frameConfig.scaledWidth + val scaledHeight = frameConfig.scaledHeight + val scale = frameConfig.scale + log { "RENDERING '$fqName' ${scaledWidth}x$scaledHeight@${scale ?: 1f}" } + var bytes: ByteArray + val ms = measureTimeMillis { + bytes = render.invoke(previewFacade, fqName, scaledWidth, scaledHeight, scale) as ByteArray + } + log { "RENDERED [${bytes.size}] in $ms ms" } + return bytes + } + + companion object { + private const val PREVIEW_FACADE_CLASS_NAME = + "androidx.compose.desktop.ui.tooling.preview.runtime.NonInteractivePreviewFacade" + + @JvmStatic + fun main(args: Array) { + val port = args[0].toInt() + val logger = PrintStreamLogger("PREVIEW_HOST") + val onClose = { exitProcess(ExitCodes.OK) } + val connection = getLocalConnectionOrNull(port, logger, onClose = onClose) + if (connection != null) { + PreviewHost(logger, connection).join() + } else { + exitProcess(ExitCodes.COULD_NOT_CONNECT_TO_PREVIEW_MANAGER) + } + } + } +} diff --git a/gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/RenderedFrame.kt b/gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/RenderedFrame.kt new file mode 100644 index 0000000000..838c7f9cfe --- /dev/null +++ b/gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/RenderedFrame.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.desktop.ui.tooling.preview.rpc + +data class RenderedFrame( + val bytes: ByteArray, + val width: Int, + val height: Int +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as RenderedFrame + + if (!bytes.contentEquals(other.bytes)) return false + if (width != other.width) return false + if (height != other.height) return false + + return true + } + + override fun hashCode(): Int { + var result = bytes.contentHashCode() + result = 31 * result + width + result = 31 * result + height + return result + } +} \ No newline at end of file diff --git a/gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/commands.kt b/gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/commands.kt new file mode 100644 index 0000000000..deba6ab06b --- /dev/null +++ b/gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/commands.kt @@ -0,0 +1,121 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.desktop.ui.tooling.preview.rpc + +import java.net.URLDecoder +import java.net.URLEncoder + +internal fun RemoteConnection.sendAttach() { + sendCommand(Command.Type.ATTACH) +} + +internal fun RemoteConnection.receiveAttach(fn: () -> Unit) { + receiveCommand { (type, _) -> + if (type == Command.Type.ATTACH) { + fn() + } + } +} + +internal fun RemoteConnection.sendFrame(frame: RenderedFrame) { + sendCommand(Command.Type.FRAME, frame.width.toString(), frame.height.toString()) + sendData(frame.bytes) +} + +internal fun RemoteConnection.receiveFrame(fn: (RenderedFrame) -> Unit) { + receiveCommand { (type, args) -> + if (type == Command.Type.FRAME) { + receiveData { bytes -> + val (w, h) = args + val frame = RenderedFrame(bytes, width = w.toInt(), height = h.toInt()) + fn(frame) + } + } + } +} + +fun RemoteConnection.sendConfigFromGradle( + config: PreviewHostConfig, + previewClasspath: String, + previewFqName: String +) { + sendCommand(Command.Type.PREVIEW_CONFIG, URLEncoder.encode(config.javaExecutable, Charsets.UTF_8)) + sendUtf8StringData(config.hostClasspath) + sendCommand(Command.Type.PREVIEW_CLASSPATH) + sendUtf8StringData(previewClasspath) + sendCommand(Command.Type.PREVIEW_FQ_NAME) + sendUtf8StringData(previewFqName) +} + +internal fun RemoteConnection.receiveConfigFromGradle( + onPreviewClasspath: (String) -> Unit, + onPreviewFqName: (String) -> Unit, + onPreviewHostConfig: (PreviewHostConfig) -> Unit +) { + receiveCommand { (type, args) -> + when (type) { + Command.Type.PREVIEW_CLASSPATH -> + receiveUtf8StringData { onPreviewClasspath(it) } + Command.Type.PREVIEW_FQ_NAME -> + receiveUtf8StringData { onPreviewFqName(it) } + Command.Type.PREVIEW_CONFIG -> { + val javaExecutable = URLDecoder.decode(args[0], Charsets.UTF_8) + receiveUtf8StringData { hostClasspath -> + val config = PreviewHostConfig(javaExecutable = javaExecutable, hostClasspath = hostClasspath) + onPreviewHostConfig(config) + } + } + else -> { + // todo + } + } + } +} + +internal fun RemoteConnection.sendPreviewRequest( + previewClasspath: String, + request: FrameRequest +) { + sendCommand(Command.Type.PREVIEW_CLASSPATH) + sendData(previewClasspath.toByteArray(Charsets.UTF_8)) + val (fqName, frameConfig) = request + val (w, h, scale) = frameConfig + val args = arrayListOf(fqName, w.toString(), h.toString()) + if (scale != null) { + val scaleLong = java.lang.Double.doubleToRawLongBits(scale) + args.add(scaleLong.toString()) + } + sendCommand(Command.Type.FRAME_REQUEST, *args.toTypedArray()) +} + +internal fun RemoteConnection.receivePreviewRequest( + onPreviewClasspath: (String) -> Unit, + onFrameRequest: (FrameRequest) -> Unit +) { + receiveCommand { (type, args) -> + when (type) { + Command.Type.PREVIEW_CLASSPATH -> { + receiveUtf8StringData { onPreviewClasspath(it) } + } + Command.Type.FRAME_REQUEST -> { + val fqName = args.getOrNull(0) + val w = args.getOrNull(1)?.toIntOrNull() + val h = args.getOrNull(2)?.toIntOrNull() + val scale = args.getOrNull(3)?.toLongOrNull()?.let { java.lang.Double.longBitsToDouble(it) } + if ( + fqName != null && fqName.isNotEmpty() + && w != null && w > 0 + && h != null && h > 0 + ) { + onFrameRequest(FrameRequest(fqName, FrameConfig(width = w, height = h, scale = scale))) + } + } + else -> { + // todo + } + } + } +} \ No newline at end of file diff --git a/gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/constants.kt b/gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/constants.kt new file mode 100644 index 0000000000..f6a98d384f --- /dev/null +++ b/gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/constants.kt @@ -0,0 +1,13 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.desktop.ui.tooling.preview.rpc + +internal const val SOCKET_TIMEOUT_MS = 1000 +internal const val DEFAULT_SLEEP_DELAY_MS = 1000L +internal const val MAX_CMD_SIZE = 8 * 1024 +// 100 Mb should be enough even for 8K screenshots +internal const val MAX_BINARY_SIZE = 100 * 1024 * 1024 +internal const val MAX_BUF_SIZE = 8 * 1024 diff --git a/gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/utils.kt b/gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/utils.kt new file mode 100644 index 0000000000..2d250a8019 --- /dev/null +++ b/gradle-plugins/preview-rpc/src/main/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/utils.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.desktop.ui.tooling.preview.rpc + +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.PrintStream +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket + +internal fun getLocalSocketOrNull( + port: Int, + trials: Int, + trialDelay: Long, +): Socket? { + for (i in 0..trials) { + try { + return Socket(localhost, port) + } catch (e: IOException) { + Thread.sleep(trialDelay) + } + } + + return null +} + +internal val localhost: InetAddress + get() = InetAddress.getLoopbackAddress() + +internal fun newServerSocket() = + ServerSocket(0, 0, localhost).apply { + reuseAddress = true + soTimeout = SOCKET_TIMEOUT_MS + } + +internal fun Iterator.nextOrNull(): T? = + if (hasNext()) next() else null + +internal val Throwable.stackTraceString: String + get() { + val output = ByteArrayOutputStream() + PrintStream(output).use { + printStackTrace(it) + } + return output.toString(Charsets.UTF_8) + } diff --git a/gradle-plugins/preview-rpc/src/test/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/PreviewHostTest.kt b/gradle-plugins/preview-rpc/src/test/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/PreviewHostTest.kt new file mode 100644 index 0000000000..6608e18bcf --- /dev/null +++ b/gradle-plugins/preview-rpc/src/test/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/PreviewHostTest.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.desktop.ui.tooling.preview.rpc + +import org.jetbrains.compose.desktop.ui.tooling.preview.rpc.utils.* +import org.junit.jupiter.api.* +import java.net.ServerSocket +import java.util.concurrent.atomic.* +import kotlin.concurrent.thread + +internal class PreviewHostTest { + private lateinit var serverSocket: ServerSocket + + @BeforeEach + internal fun setUp() { + serverSocket = ServerSocket(0, 0, localhost) + serverSocket.soTimeout = 10.secondsAsMillis + } + + @AfterEach + internal fun tearDown() { + serverSocket.close() + } + + @Test + fun connectNormallyAndStop() { + withPreviewHostConnection { connection -> + connection.receiveCommand { command -> + check(command.type == Command.Type.ATTACH) { + "First received command is not ${Command.Type.ATTACH}: ${command.asString()}" + } + } + } + } + + private fun withPreviewHostConnection(doWithConnection: (RemoteConnection) -> Unit) { + val isServerConnectionClosed = AtomicBoolean(false) + val serverThread = thread { + val socket = serverSocket.accept() + val logger = TestLogger() + val connection = RemoteConnectionImpl(socket, logger, onClose = { + isServerConnectionClosed.set(true) + }) + doWithConnection(connection) + connection.close() + } + val serverThreadFailure = AtomicReference(null) + serverThread.setUncaughtExceptionHandler { t, e -> + serverThreadFailure.set(e) + } + + val previewHostProcess = TestPreviewProcess(serverSocket.localPort) + previewHostProcess.start() + + serverThread.join(10L.secondsAsMillis) + val serverFailure = serverThreadFailure.get() + check(serverFailure == null) { "Unexpected server failure: $serverFailure" } + check(!serverThread.isAlive) { "Server thread should not be alive at this point" } + check(isServerConnectionClosed.get()) { "Server connection was not closed" } + + previewHostProcess.finish() + } +} diff --git a/gradle-plugins/preview-rpc/src/test/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/utils/TestLogger.kt b/gradle-plugins/preview-rpc/src/test/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/utils/TestLogger.kt new file mode 100644 index 0000000000..cda8ae4573 --- /dev/null +++ b/gradle-plugins/preview-rpc/src/test/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/utils/TestLogger.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.desktop.ui.tooling.preview.rpc.utils + +import org.jetbrains.compose.desktop.ui.tooling.preview.rpc.PreviewLogger + +internal class TestLogger : PreviewLogger() { + private val myMessages = arrayListOf() + val messages: List + get() = myMessages + + override val isEnabled: Boolean + get() = true + + override fun log(s: String) { + myMessages.add(s) + } +} \ No newline at end of file diff --git a/gradle-plugins/preview-rpc/src/test/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/utils/TestPreviewProcess.kt b/gradle-plugins/preview-rpc/src/test/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/utils/TestPreviewProcess.kt new file mode 100644 index 0000000000..5955d4f3fe --- /dev/null +++ b/gradle-plugins/preview-rpc/src/test/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/utils/TestPreviewProcess.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.desktop.ui.tooling.preview.rpc.utils + +import org.jetbrains.compose.desktop.ui.tooling.preview.rpc.PREVIEW_HOST_CLASS_NAME +import java.util.concurrent.TimeUnit + +class TestPreviewProcess(private val port: Int) { + private var myProcess: Process? = null + + fun start() { + if (myProcess != null) error("Process was started already") + + myProcess = runJava( + headless = true, + classpath = previewTestClaspath, + args = listOf(PREVIEW_HOST_CLASS_NAME, port.toString()) + ).start() + } + + fun finish() { + val process = myProcess + check(process != null) { "Process was not started" } + + process.waitFor(10, TimeUnit.SECONDS) + if (process.isAlive) { + val jstackOutput = runJStackAndGetOutput(process.pid()) + val message = buildString { + appendLine("Preview host process did not stop:") + jstackOutput.splitToSequence("\n").forEach { + appendLine("> $it") + } + } + process.destroyForcibly() + error(message) + } + val exitCode = process.exitValue() + check(exitCode == 0) { "Non-zero exit code: $exitCode" } + } +} \ No newline at end of file diff --git a/gradle-plugins/preview-rpc/src/test/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/utils/javaTools.kt b/gradle-plugins/preview-rpc/src/test/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/utils/javaTools.kt new file mode 100644 index 0000000000..b51424b5d3 --- /dev/null +++ b/gradle-plugins/preview-rpc/src/test/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/utils/javaTools.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.desktop.ui.tooling.preview.rpc.utils + +import java.io.File +import java.util.concurrent.TimeUnit + +internal fun runJava( + headless: Boolean = true, + debugPort: Int? = null, + classpath: String = "", + args: List = emptyList() +): ProcessBuilder { + val javaExec = javaToolPath("java") + val cmd = arrayListOf( + javaExec, + "-classpath", + classpath + ) + if (headless) { + cmd.add("-Djava.awt.headless=true") + } + if (debugPort != null) { + cmd.add("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=*:$debugPort") + } + cmd.addAll(args) + println("Starting process: [${cmd.joinToString(",") { "\n $it" } }\n]") + return ProcessBuilder(cmd).apply { + redirectError(ProcessBuilder.Redirect.INHERIT) + redirectOutput(ProcessBuilder.Redirect.INHERIT) + } +} + +internal fun runJStackAndGetOutput( + pid: Long +): String { + val jstack = javaToolPath("jstack") + val stdoutFile = File.createTempFile("jstack-stdout", ".txt").apply { deleteOnExit() } + val stderrFile = File.createTempFile("jstack-stderr", ".txt").apply { deleteOnExit() } + + try { + val process = ProcessBuilder(jstack, pid.toString()).apply { + redirectOutput(stdoutFile) + redirectError(stderrFile) + }.start() + process.waitFor(10, TimeUnit.SECONDS) + if (process.isAlive) { + process.destroyForcibly() + error("jstack did not finish") + } + val exitCode = process.exitValue() + check(exitCode == 0) { + buildString { + appendLine("jstack finished with error: $exitCode") + appendLine(" output:") + stdoutFile.readLines().forEach { + appendLine(" >") + } + appendLine(" err:") + stderrFile.readLines().forEach { + appendLine(" >") + } + } + " $exitCode\n${stderrFile.readText()}" + } + return stdoutFile.readText() + } finally { + stdoutFile.delete() + stderrFile.delete() + } +} + +private fun javaToolPath(toolName: String): String { + val javaHome = File(systemProperty("java.home")) + val toolExecutableName = if (isWindows) "$toolName.exe" else toolName + val executable = javaHome.resolve("bin/$toolExecutableName") + check(executable.isFile) { "Could not find tool '$toolName' at specified path: $executable" } + return executable.absolutePath +} \ No newline at end of file diff --git a/gradle-plugins/preview-rpc/src/test/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/utils/utils.kt b/gradle-plugins/preview-rpc/src/test/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/utils/utils.kt new file mode 100644 index 0000000000..b99eded9d6 --- /dev/null +++ b/gradle-plugins/preview-rpc/src/test/kotlin/org/jetbrains/compose/desktop/ui/tooling/preview/rpc/utils/utils.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.desktop.ui.tooling.preview.rpc.utils + +internal fun systemProperty(name: String): String = + System.getProperty(name) ?: error("System property is not found: '$name'") + +internal val isWindows = + systemProperty("os.name").startsWith("windows", ignoreCase = true) + +internal val previewTestClaspath: String + get() = systemProperty("org.jetbrains.compose.test.rpc.classpath") + +internal val Int.secondsAsMillis: Int + get() = this * 1000 + +internal val Long.secondsAsMillis: Long + get() = this * 1000 diff --git a/gradle-plugins/settings.gradle.kts b/gradle-plugins/settings.gradle.kts index fac5b5924d..a8c23f4b17 100644 --- a/gradle-plugins/settings.gradle.kts +++ b/gradle-plugins/settings.gradle.kts @@ -5,4 +5,4 @@ pluginManagement { } include(":compose") -include(":compose-preview-runtime") \ No newline at end of file +include(":preview-rpc") diff --git a/idea-plugin/build.gradle.kts b/idea-plugin/build.gradle.kts index 58f1e3c321..48c8a4da20 100644 --- a/idea-plugin/build.gradle.kts +++ b/idea-plugin/build.gradle.kts @@ -16,6 +16,10 @@ repositories { mavenCentral() } +dependencies { + implementation("org.jetbrains.compose:preview-rpc") +} + intellij { pluginName = "Compose Multiplatform IDE Support" type = properties("platform.type") diff --git a/idea-plugin/examples/desktop-project/build.gradle.kts b/idea-plugin/examples/desktop-project/build.gradle.kts index 13523dbca8..dfdf8a83ac 100644 --- a/idea-plugin/examples/desktop-project/build.gradle.kts +++ b/idea-plugin/examples/desktop-project/build.gradle.kts @@ -3,7 +3,7 @@ import org.jetbrains.compose.compose plugins { // __KOTLIN_COMPOSE_VERSION__ kotlin("jvm") version "1.5.10" - id("org.jetbrains.compose") version "0.4.0-idea-preview-build57" + id("org.jetbrains.compose") version "0.0.0-non-interactive-preview-build89-4" } repositories { @@ -12,7 +12,7 @@ repositories { } dependencies { - implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.4") + implementation(compose.uiTooling) implementation(compose.desktop.currentOs) implementation("org.jetbrains.kotlin:kotlin-reflect") } diff --git a/idea-plugin/examples/desktop-project/gradle.properties b/idea-plugin/examples/desktop-project/gradle.properties index f1a9d3306a..87ec412e67 100644 --- a/idea-plugin/examples/desktop-project/gradle.properties +++ b/idea-plugin/examples/desktop-project/gradle.properties @@ -1,2 +1,4 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -kotlin.code.style=official \ No newline at end of file +kotlin.code.style=official +#org.gradle.unsafe.configuration-cache=true +#org.gradle.unsafe.configuration-cache-problems=warn diff --git a/idea-plugin/examples/desktop-project/gradle/wrapper/gradle-wrapper.properties b/idea-plugin/examples/desktop-project/gradle/wrapper/gradle-wrapper.properties index bca17f3656..69a9715077 100644 --- a/idea-plugin/examples/desktop-project/gradle/wrapper/gradle-wrapper.properties +++ b/idea-plugin/examples/desktop-project/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.6-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/idea-plugin/examples/desktop-project/src/main/kotlin/preview.kt b/idea-plugin/examples/desktop-project/src/main/kotlin/preview.kt index dbfce7dc1e..9c8d883b3e 100644 --- a/idea-plugin/examples/desktop-project/src/main/kotlin/preview.kt +++ b/idea-plugin/examples/desktop-project/src/main/kotlin/preview.kt @@ -1,10 +1,11 @@ import androidx.compose.material.Text import androidx.compose.material.Button import androidx.compose.runtime.* -import androidx.compose.ui.tooling.desktop.preview.Preview +import androidx.compose.desktop.ui.tooling.preview.Preview @Preview -fun examplePreview() = @Composable { +@Composable +fun ExamplePreview() { var text by remember { mutableStateOf("Hello, World!") } Button(onClick = { diff --git a/idea-plugin/settings.gradle.kts b/idea-plugin/settings.gradle.kts index e69de29bb2..be37644097 100644 --- a/idea-plugin/settings.gradle.kts +++ b/idea-plugin/settings.gradle.kts @@ -0,0 +1,3 @@ +includeBuild("../gradle-plugins") { + name = "compose-gradle-components" +} \ No newline at end of file diff --git a/idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/PreviewEntryPoint.kt b/idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/PreviewEntryPoint.kt index 3e50a589a0..1d6c274c8b 100644 --- a/idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/PreviewEntryPoint.kt +++ b/idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/PreviewEntryPoint.kt @@ -20,12 +20,9 @@ import com.intellij.codeInspection.reference.EntryPoint import com.intellij.codeInspection.reference.RefElement import com.intellij.configurationStore.deserializeInto import com.intellij.configurationStore.serializeObjectInto -import com.intellij.openapi.util.InvalidDataException -import com.intellij.openapi.util.WriteExternalException import com.intellij.psi.PsiElement import com.intellij.psi.PsiMethod import org.jdom.Element -import org.jetbrains.annotations.Nls /** * [EntryPoint] implementation to mark `@Preview` functions as entry points and avoid them being flagged as unused. diff --git a/idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/PreviewPanel.kt b/idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/PreviewPanel.kt new file mode 100644 index 0000000000..ce440cb78c --- /dev/null +++ b/idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/PreviewPanel.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.desktop.ide.preview + +import java.awt.Color +import java.awt.Dimension +import java.awt.Graphics +import java.awt.image.BufferedImage +import javax.swing.JPanel + +internal class PreviewPanel : JPanel() { + private var image: BufferedImage? = null + private var imageDimension: Dimension? = null + + override fun paintComponent(g: Graphics) { + super.paintComponent(g) + + synchronized(this) { + image?.let { image -> + val w = imageDimension!!.width + val h = imageDimension!!.height + g.color = Color.WHITE + g.fillRect(0, 0, w, h) + g.drawImage(image, 0, 0, w, h, null) + } + } + } + + fun previewImage(image: BufferedImage, imageDimension: Dimension) { + synchronized(this) { + this.image = image + this.imageDimension = imageDimension + } + + repaint() + } +} \ No newline at end of file diff --git a/idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/PreviewRunConfigurationProducer.kt b/idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/PreviewRunConfigurationProducer.kt index 7b8d3feea5..66730e14b5 100644 --- a/idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/PreviewRunConfigurationProducer.kt +++ b/idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/PreviewRunConfigurationProducer.kt @@ -17,8 +17,10 @@ package org.jetbrains.compose.desktop.ide.preview import com.intellij.execution.actions.ConfigurationContext +import com.intellij.execution.actions.ConfigurationFromContext import com.intellij.execution.actions.LazyRunConfigurationProducer import com.intellij.execution.configurations.ConfigurationFactory +import com.intellij.openapi.components.service import com.intellij.openapi.externalSystem.util.ExternalSystemApiUtil import com.intellij.openapi.util.Ref import com.intellij.psi.PsiElement @@ -47,13 +49,13 @@ class PreviewRunConfigurationProducer : LazyRunConfigurationProducer ): Boolean { val composeFunction = context.containingComposePreviewFunction() ?: return false - + // todo: temporary configuration? configuration.apply { - name = composeFunction.name!! - settings.taskNames.add("runComposeDesktopPreview") + name = runConfigurationNameFor(composeFunction) + settings.taskNames.add(configureDesktopPreviewTaskName) settings.externalProjectPath = ExternalSystemApiUtil.getExternalProjectPath(context.location?.module) - settings.scriptParameters = listOf( - previewTargetGradleArg(composeFunction.composePreviewFunctionFqn()) - ).joinToString(" ") + settings.scriptParameters = + runConfigurationScriptParameters(composeFunction.composePreviewFunctionFqn(), context.port) + .joinToString(" ") } + return true } } -private fun previewTargetGradleArg(target: String): String = - "-Pcompose.desktop.preview.target=$target" +private val configureDesktopPreviewTaskName = "configureDesktopPreview" + +private fun runConfigurationNameFor(function: KtNamedFunction): String = + "Compose Preview: ${function.name!!}" + +private fun runConfigurationScriptParameters(target: String, idePort: Int): List = + listOf( + "-Pcompose.desktop.preview.target=$target", + "-Pcompose.desktop.preview.ide.port=${idePort}" + ) + +private val ConfigurationContext.port: Int + get() = project.service().gradleCallbackPort private fun KtNamedFunction.composePreviewFunctionFqn() = "${getClassName()}.${name}" diff --git a/idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/PreviewRunLineMarkerContributor.kt b/idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/PreviewRunLineMarkerContributor.kt index 064315f661..508deb2c69 100644 --- a/idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/PreviewRunLineMarkerContributor.kt +++ b/idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/PreviewRunLineMarkerContributor.kt @@ -36,11 +36,10 @@ class PreviewRunLineMarkerContributor : RunLineMarkerContributor() { val parent = element.parent return when { - parent is KtNamedFunction && parent.isValidComposePreview() -> - Info( - PreviewIcons.COMPOSE, - arrayOf(ExecutorAction.getActions(0).first()) - ) { PreviewMessages.runPreview(parent.name!!) } + parent is KtNamedFunction && parent.isValidComposePreview() -> { + val actions = arrayOf(ExecutorAction.getActions(0).first()) + Info(PreviewIcons.COMPOSE, actions) { PreviewMessages.runPreview(parent.name!!) } + } else -> null } } diff --git a/idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/PreviewStateService.kt b/idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/PreviewStateService.kt new file mode 100644 index 0000000000..71b29ec985 --- /dev/null +++ b/idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/PreviewStateService.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.desktop.ide.preview + +import com.intellij.openapi.Disposable +import com.intellij.openapi.components.Service +import org.jetbrains.compose.desktop.ui.tooling.preview.rpc.FrameConfig +import org.jetbrains.compose.desktop.ui.tooling.preview.rpc.PreviewManager +import org.jetbrains.compose.desktop.ui.tooling.preview.rpc.PreviewManagerImpl +import java.awt.Dimension +import java.io.ByteArrayInputStream +import javax.imageio.ImageIO +import javax.swing.JComponent +import javax.swing.event.AncestorEvent +import javax.swing.event.AncestorListener + +@Service +class PreviewStateService : Disposable { + private var myPanel: PreviewPanel? = null + private val previewManager: PreviewManager = PreviewManagerImpl { frame -> + ByteArrayInputStream(frame.bytes).use { input -> + val image = ImageIO.read(input) + myPanel?.previewImage(image, Dimension(frame.width, frame.height)) + } + } + val gradleCallbackPort: Int + get() = previewManager.gradleCallbackPort + + private val myListener = object : AncestorListener { + private fun updateFrameSize(c: JComponent) { + val frameConfig = FrameConfig( + width = c.width, + height = c.height, + scale = null + ) + previewManager.updateFrameConfig(frameConfig) + } + + override fun ancestorAdded(event: AncestorEvent) { + updateFrameSize(event.component) + } + + override fun ancestorRemoved(event: AncestorEvent) { + } + + override fun ancestorMoved(event: AncestorEvent) { + updateFrameSize(event.component) + } + } + + override fun dispose() { + myPanel?.removeAncestorListener(myListener) + previewManager.close() + } + + internal fun registerPreviewPanel(panel: PreviewPanel) { + myPanel = panel + panel.addAncestorListener(myListener) + } +} diff --git a/idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/PreviewToolWindow.kt b/idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/PreviewToolWindow.kt new file mode 100644 index 0000000000..cdd53d6300 --- /dev/null +++ b/idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/PreviewToolWindow.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ +package org.jetbrains.compose.desktop.ide.preview + +import com.intellij.openapi.components.service +import com.intellij.openapi.project.DumbAware +import com.intellij.openapi.project.Project +import com.intellij.openapi.wm.ToolWindow +import com.intellij.openapi.wm.ToolWindowFactory + +class PreviewToolWindow : ToolWindowFactory, DumbAware { + override fun isApplicable(project: Project): Boolean { + // todo: filter only Compose projects + return true + } + + override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { + toolWindow.contentManager.let { content -> + val panel = PreviewPanel() + content.addContent(content.factory.createContent(panel, null, false)) + project.service().registerPreviewPanel(panel) + } + } +} \ No newline at end of file diff --git a/idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/locationUtils.kt b/idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/locationUtils.kt index 43804abddc..5906832b52 100644 --- a/idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/locationUtils.kt +++ b/idea-plugin/src/main/kotlin/org/jetbrains/compose/desktop/ide/preview/locationUtils.kt @@ -27,7 +27,7 @@ import org.jetbrains.kotlin.resolve.BindingContext import org.jetbrains.kotlin.resolve.descriptorUtil.fqNameSafe import org.jetbrains.kotlin.resolve.lazy.BodyResolveMode -internal const val DESKTOP_PREVIEW_ANNOTATION_FQN = "androidx.compose.ui.tooling.desktop.preview.Preview" +internal const val DESKTOP_PREVIEW_ANNOTATION_FQN = "androidx.compose.desktop.ui.tooling.preview.Preview" /** * Utils based on functions from AOSP, taken from @@ -49,6 +49,8 @@ internal fun KtNamedFunction.isValidComposePreview() = * */ internal fun KtNamedFunction.isValidPreviewLocation(): Boolean { + if (valueParameters.size > 0) return false + if (isTopLevel) return true if (parentOfType() == null) { diff --git a/idea-plugin/src/main/resources/META-INF/plugin.xml b/idea-plugin/src/main/resources/META-INF/plugin.xml index a3efae9c76..ca24c7598c 100644 --- a/idea-plugin/src/main/resources/META-INF/plugin.xml +++ b/idea-plugin/src/main/resources/META-INF/plugin.xml @@ -34,5 +34,9 @@ +