diff --git a/README.md b/README.md index f468a9e..b76b3fa 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,11 @@ Composium is an Android Jetpack Compose library for building an in-app UI catalo One of the core ideas of the library is that it gives you a ready-to-embed `ComposiumScreen()` composable. You can place this screen anywhere in your app: in a debug-only route, a separate activity, an internal tools section, or any custom navigation graph. `ComposiumScreen()` will render the scenes that you described in your project and turn them into a browsable interactive catalog. -Version: `1.3.0-alpha02` (pre-release) +Version: `1.3.0-alpha03` (pre-release) Artifacts: -- `io.github.oleginvoke:composium:1.3.0-alpha02` -- `io.github.oleginvoke:composium-processor:1.3.0-alpha02` +- `io.github.oleginvoke:composium:1.3.0-alpha03` +- `io.github.oleginvoke:composium-processor:1.3.0-alpha03` It is useful for: - design systems and component libraries; @@ -101,8 +101,8 @@ plugins { } dependencies { - implementation("io.github.oleginvoke:composium:1.3.0-alpha02") - ksp("io.github.oleginvoke:composium-processor:1.3.0-alpha02") + implementation("io.github.oleginvoke:composium:1.3.0-alpha03") + ksp("io.github.oleginvoke:composium-processor:1.3.0-alpha03") } ``` @@ -116,7 +116,7 @@ Automatic discovery collects scenes declared in that showcase module; it does no ```kotlin dependencies { - implementation("io.github.oleginvoke:composium:1.3.0-alpha02") + implementation("io.github.oleginvoke:composium:1.3.0-alpha03") } ``` @@ -409,8 +409,11 @@ Theme commands use `ComposiumScreen`'s existing `onThemeChange` handling. If you reflects the effective theme, not a pending request. Call commands from event handlers or effects, not directly during composition. Commands -do nothing in thumbnails, `RenderPreview()`, or after the scene leaves composition; -inactive state objects report `false`. `None` hides the toolbars, not the built-in +do nothing in thumbnails, `RenderPreview()`, or after the scene leaves composition. +In thumbnails, `host.theme.isDark` reports the theme used to capture the image; +controls and eyedropper visibility remain `false`. In `RenderPreview()` or after an +opened scene leaves composition, state objects report `false`. +`None` hides the toolbars, not the built-in Properties / Environment panel or eyedropper that these commands operate. ### Closing a scene from your own UI diff --git a/gradle.properties b/gradle.properties index d7eab52..78ccee0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -28,7 +28,7 @@ org.gradle.caching=true # Publishing (used by com.vanniktech.maven.publish) GROUP=io.github.oleginvoke -VERSION_NAME=1.3.0-alpha02 +VERSION_NAME=1.3.0-alpha03 # Maven Central release credentials must not be committed. # Provide them via ~/.gradle/gradle.properties or ORG_GRADLE_PROJECT_* env vars: diff --git a/runtime/src/main/kotlin/composium/SceneToolStates.kt b/runtime/src/main/kotlin/composium/SceneToolStates.kt index 79e6aa0..56276c0 100644 --- a/runtime/src/main/kotlin/composium/SceneToolStates.kt +++ b/runtime/src/main/kotlin/composium/SceneToolStates.kt @@ -54,7 +54,8 @@ class SceneEyedropperState internal constructor() { * Observable effective Composium theme and theme change requests, owned by [SceneHost]. * Commands are for event handlers or effects, not composition, and do nothing in * thumbnails, [Scene.RenderPreview], or after the owning scene leaves composition. - * An inactive state reports [isDark] as false. + * Thumbnails report their capture theme through [isDark], while commands remain inactive. + * In [Scene.RenderPreview] or after an opened scene leaves composition, [isDark] is false. */ @Stable class SceneThemeState internal constructor() { diff --git a/runtime/src/main/kotlin/composium/scene_thumbnail/SceneThumbnailCaptureHost.kt b/runtime/src/main/kotlin/composium/scene_thumbnail/SceneThumbnailCaptureHost.kt index 23cced2..d4e1e22 100644 --- a/runtime/src/main/kotlin/composium/scene_thumbnail/SceneThumbnailCaptureHost.kt +++ b/runtime/src/main/kotlin/composium/scene_thumbnail/SceneThumbnailCaptureHost.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.viewinterop.AndroidView import kotlinx.coroutines.CancellationException import oleginvoke.com.composium.SceneEntry import oleginvoke.com.composium.SceneScope +import oleginvoke.com.composium.SceneToolStateBinding import oleginvoke.com.composium.ui.theme.ComposiumTheme import oleginvoke.com.composium.ui.theme.LocalComposiumThemeController import androidx.core.graphics.createBitmap @@ -53,7 +54,16 @@ internal fun SceneThumbnailCaptureHost( val context = LocalContext.current val density = LocalDensity.current val themeController = LocalComposiumThemeController.current - val sceneScope = remember(captureRequest.key) { SceneScope() } + val sceneScope = remember(captureRequest.key) { + val isDarkTheme = captureRequest.key.isDarkTheme + SceneScope().apply { + // Read the capture's theme without letting thumbnail effects control the host. + host.theme.binding = SceneToolStateBinding( + read = { isDarkTheme }, + change = {}, + ) + } + } val installedContent = remember { SceneThumbnailInstalledContent() } var captureView by remember(captureRequest.key) { mutableStateOf(null) } var captureSurfaceState by remember(captureRequest.key) { diff --git a/runtime/src/test/kotlin/composium/scene_thumbnail/SceneThumbnailThemeTest.kt b/runtime/src/test/kotlin/composium/scene_thumbnail/SceneThumbnailThemeTest.kt new file mode 100644 index 0000000..a046ece --- /dev/null +++ b/runtime/src/test/kotlin/composium/scene_thumbnail/SceneThumbnailThemeTest.kt @@ -0,0 +1,138 @@ +package oleginvoke.com.composium.scene_thumbnail + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.dp +import oleginvoke.com.composium.Scene +import oleginvoke.com.composium.SceneEntry +import oleginvoke.com.composium.SceneScope +import oleginvoke.com.composium.ui.theme.ComposiumThemeController +import oleginvoke.com.composium.ui.theme.LocalComposiumThemeController +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class SceneThumbnailThemeTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun automaticThumbnailCapturesContentInLightDarkAndLightThemes() { + var dark by mutableStateOf(false) + val captures = mutableMapOf() + val entry = SceneEntry(Scene(null, "Theme-dependent content") { + Box(Modifier.size(24.dp).background(if (host.theme.isDark) Color.Blue else Color.Red)) + }) + var captureRequest by mutableStateOf(request(entry, dark = false)) + + composeRule.setContent { + CompositionLocalProvider( + LocalComposiumThemeController provides ComposiumThemeController(dark), + ) { + SceneThumbnailCaptureHost( + request = captureRequest, + onCaptured = { key, result -> + val pixels = result.image.toPixelMap() + captures[key.isDarkTheme] = pixels[pixels.width / 2, pixels.height / 2] + captureRequest = null + }, + onFailed = { _, failure -> throw AssertionError(failure.reason, failure.throwable) }, + ) + } + } + advanceCaptureFrames() + composeRule.runOnIdle { + assertEquals(Color.Red, captures[false]) + dark = true + captureRequest = request(entry, dark = true) + } + advanceCaptureFrames() + composeRule.runOnIdle { + assertEquals(Color.Blue, captures[true]) + captures.remove(false) + dark = false + captureRequest = request(entry, dark = false) + } + advanceCaptureFrames() + composeRule.runOnIdle { assertEquals(Color.Red, captures[false]) } + } + + @Test + fun customThumbnailReadsCaptureThemeAndCannotControlTheHost() { + var themeRequests = 0 + var captured = false + lateinit var scope: SceneScope + val entry = SceneEntry(Scene( + group = null, + name = "Read-only thumbnail host", + thumbnail = { + SideEffect { scope = this } + Box(Modifier.size(24.dp).background(if (host.theme.isDark) Color.Blue else Color.Red)) + }, + content = { error("Custom thumbnail must not execute main content") }, + )) + + composeRule.setContent { + // An in-flight capture must render its key's theme even if the controller differs. + CompositionLocalProvider( + LocalComposiumThemeController provides ComposiumThemeController(false) { themeRequests++ }, + ) { + SceneThumbnailCaptureHost( + request = request(entry, dark = true), + onCaptured = { _, _ -> captured = true }, + onFailed = { _, failure -> throw AssertionError(failure.reason, failure.throwable) }, + ) + } + } + composeRule.waitUntil(timeoutMillis = 5_000) { captured } + composeRule.runOnIdle { + assertTrue(scope.host.theme.isDark) + scope.host.theme.setDark(false) + scope.host.theme.toggle() + scope.host.controls.show() + scope.host.controls.toggle() + scope.host.eyedropper.show() + scope.host.eyedropper.toggle() + scope.host.onBack() + scope.host.closeScene() + assertEquals(0, themeRequests) + assertTrue(scope.host.theme.isDark) + assertFalse(scope.host.controls.isVisible) + assertFalse(scope.host.eyedropper.isVisible) + } + } + + private fun request(entry: SceneEntry, dark: Boolean) = SceneThumbnailCaptureRequest( + key = SceneThumbnailKey( + sceneId = entry.id, + isDarkTheme = dark, + ), + sceneEntry = entry, + ) + + private fun advanceCaptureFrames() { + repeat(12) { + composeRule.mainClock.advanceTimeBy(200) + composeRule.waitForIdle() + } + } +}