Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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")
}
```

Expand All @@ -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")
}
```

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion runtime/src/main/kotlin/composium/SceneToolStates.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<ComposeView?>(null) }
var captureSurfaceState by remember(captureRequest.key) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Boolean, Color>()
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<SceneThumbnailCaptureRequest?>(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()
}
}
}
Loading