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
13 changes: 13 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
*.iml
.gradle/
.kotlin/
.idea/
.worktrees/
build/
Expand All @@ -14,3 +15,15 @@ local.properties
website/node_modules/
website/dist/
output/playwright/

# Local coding-agent instructions and state
.codex/
.claude/
.superpowers/
AGENTS.md
CLAUDE.md
GEMINI.md
/docs/superpowers/

# Locally generated verification and performance reports
/docs/testing/
33 changes: 27 additions & 6 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-alpha03` (pre-release)
Version: `1.3.0-alpha04` (pre-release)

Artifacts:
- `io.github.oleginvoke:composium:1.3.0-alpha03`
- `io.github.oleginvoke:composium-processor:1.3.0-alpha03`
- `io.github.oleginvoke:composium:1.3.0-alpha04`
- `io.github.oleginvoke:composium-processor:1.3.0-alpha04`

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-alpha03")
ksp("io.github.oleginvoke:composium-processor:1.3.0-alpha03")
implementation("io.github.oleginvoke:composium:1.3.0-alpha04")
ksp("io.github.oleginvoke:composium-processor:1.3.0-alpha04")
}
```

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-alpha03")
implementation("io.github.oleginvoke:composium:1.3.0-alpha04")
}
```

Expand Down Expand Up @@ -712,6 +712,25 @@ For referential values such as `Painter`, names act as the stable identity for e

If explicit option names collide inside the same parameter, Composium will append numeric suffixes automatically until every name becomes unique.

### Updating named options

Use stable, unique option names when values depend on a theme or other changing scene state:

```kotlin
val background: Color by param(
listOf(
MaterialTheme.colorScheme.background named "colorScheme.background",
MaterialTheme.colorScheme.surface named "colorScheme.surface",
),
)
```

Selecting `colorScheme.surface` preserves that choice even when both colors are equal. When the scene recomposes with a new color scheme and supplies updated options, the parameter follows the current value of `colorScheme.surface` without another click. The initial default can be implicit or unnamed; a default matching an option by value uses that option's name.

Compute theme-dependent options inside the themed composition so they receive current values. A list cached without the relevant theme keys, or mutated in place without notifying Compose, cannot provide this update. Generated names that change with the values do not establish a stable choice across themes.

Programmatic assignments should use values from the declared options. A changed assignment identifies the option to follow; assigning an equal value remains a no-op. Assignments outside the options do not throw, but their retention across option updates is not guaranteed.

### Nullable parameters

Nullable parameters get an extra checkbox that controls whether the value is currently `null`.
Expand Down Expand Up @@ -748,6 +767,8 @@ What happens in the UI:
- unchecked means the parameter is currently `null`;
- checked restores the value and shows its regular control.

For mutable nullable parameters, assigning `null` from scene code also unchecks the checkbox; assigning a non-null value checks it. `false` and an empty string are non-null values. Disabling preserves the last non-null value or named choice, including when code assigns a value and then `null` before the next composition. Re-enabling a named choice uses its current option value, so a disabled color parameter can follow the latest theme when restored. Empty choices without a restoration value remain null and unchecked.

For nullable parameters with explicit options, do not include `null` in the list yourself. Pass only non-null values and let Composium manage the null-state toggle.

If you declare a nullable parameter with `param(options = ...)` and do not provide an explicit `default`, Composium uses the first option from the list as the initial value (including `null` if it is first).
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-alpha03
VERSION_NAME=1.3.0-alpha04

# Maven Central release credentials must not be committed.
# Provide them via ~/.gradle/gradle.properties or ORG_GRADLE_PROJECT_* env vars:
Expand Down
8 changes: 8 additions & 0 deletions macrobenchmark/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

buildTypes {
create("benchmark") {
isDebuggable = true
signingConfig = signingConfigs.getByName("debug")
matchingFallbacks += listOf("release")
}
}

targetProjectPath = ":sample"
experimentalProperties["android.experimental.self-instrumenting"] = true

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,101 @@ class ComposiumMacrobenchmark {
device.waitForIdle()
}

@Test
fun reactiveParameterThemeSwitching() = benchmarkRule.measureRepeated(
packageName = TARGET_PACKAGE,
metrics = listOf(FrameTimingMetric()),
iterations = 5,
startupMode = StartupMode.WARM,
setupBlock = {
pressHome()
startActivityAndWait()
openReactiveParametersScene()
},
) {
var status = reactiveParameterStatus()
repeat(8) {
clickObject(By.desc("Switch product theme"), "product theme button")
status = status.copy(
isProductB = !status.isProductB,
themeSwitches = status.themeSwitches + 1,
)
waitForReactiveParameterStatus(status)
}
}

@Test
fun reactiveParameterUnrelatedRecomposition() = benchmarkRule.measureRepeated(
packageName = TARGET_PACKAGE,
metrics = listOf(FrameTimingMetric()),
iterations = 5,
startupMode = StartupMode.WARM,
setupBlock = {
pressHome()
startActivityAndWait()
openReactiveParametersScene()
},
) {
var status = reactiveParameterStatus()
repeat(10) {
clickObject(By.desc("Recompose scene"), "unrelated recomposition button")
status = status.copy(unrelatedRecompositions = status.unrelatedRecompositions + 1)
waitForReactiveParameterStatus(status)
}
}

private fun openReactiveParametersScene() {
// A warm activity can retain the previous iteration's scene. Reopen it to reset counters.
device.findObject(By.desc("Return to catalog"))?.click()
waitForMainScreen()
focusSearchField()
device.findObject(By.desc("Clear search"))?.click()
device.executeShellCommand("input text Reactive")
waitForObject(By.text(REACTIVE_PARAMETERS_SCENE_NAME), "reactive parameters scene result")
device.pressBack() // Dismiss the search keyboard before entering the scene.
clickObject(By.text(REACTIVE_PARAMETERS_SCENE_NAME), "reactive parameters scene")
val status = reactiveParameterStatus()
check(status.themeSwitches == 0 && status.unrelatedRecompositions == 0) {
"Reactive parameter scene counters did not reset: $status"
}
device.waitForIdle()
}

private fun reactiveParameterStatus(): ReactiveParameterStatus {
val text = checkNotNull(
waitForObject(
By.desc("Reactive parameter status"),
"reactive parameter status",
).text,
) { "Reactive parameter status has no text" }
val match = checkNotNull(REACTIVE_PARAMETER_STATUS_PATTERN.matchEntire(text)) {
"Unexpected reactive parameter status: $text"
}
return ReactiveParameterStatus(
isProductB = match.groupValues[1] == "B",
themeSwitches = match.groupValues[2].toInt(),
unrelatedRecompositions = match.groupValues[3].toInt(),
)
}

private fun waitForReactiveParameterStatus(status: ReactiveParameterStatus) {
// Wait for the product theme and event counters, never for reactive color updates.
// This permits measurements against the baseline runtime where selected colors stay stale.
waitForObject(By.text(status.text), "reactive parameter status ${status.text}")
device.waitForIdle()
}

private data class ReactiveParameterStatus(
val isProductB: Boolean,
val themeSwitches: Int,
val unrelatedRecompositions: Int,
) {
val text: String
get() = "Theme: product ${if (isProductB) "B" else "A"}; " +
"theme switches: $themeSwitches; " +
"unrelated recompositions: $unrelatedRecompositions; params: 100"
}

private fun waitForMainScreen() {
waitForObject(By.text("Composium"), "main screen title")
waitForObject(By.textContains("Search scenes"), "search field")
Expand Down Expand Up @@ -205,6 +300,10 @@ class ComposiumMacrobenchmark {
companion object {
private const val TARGET_PACKAGE = "com.example.ComposiumSample"
private const val BENCHMARK_SCENE_NAME = "Benchmark 1"
private const val REACTIVE_PARAMETERS_SCENE_NAME = "Reactive parameters"
private const val WAIT_TIMEOUT_MS = 5_000L
private val REACTIVE_PARAMETER_STATUS_PATTERN = Regex(
"Theme: product (A|B); theme switches: (\\d+); unrelated recompositions: (\\d+); params: 100",
)
}
}
56 changes: 56 additions & 0 deletions runtime/src/main/kotlin/composium/ParamOptionUpdates.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package oleginvoke.com.composium

/** Resolve a valid assignment while retaining the existing preference between equal values. */
internal fun assignedParamOptionName(
options: List<ParamOption<Any>>,
preferredName: String?,
value: Any?,
): String? {
if (value == null) return null
var firstMatch: String? = null
var index = 0
while (index < options.size) {
val option = options[index++]
if (option.value == value) {
if (option.name == preferredName) return option.name
if (firstMatch == null) firstMatch = option.name
}
}
return firstMatch
}

/** A single scan on changed options; ordinary delegate reads never call this function. */
internal fun updatedParamOption(
options: List<ParamOption<Any>>,
intendedName: String?,
preferredValueName: String?,
value: Any,
preferAssignedValue: Boolean,
defaultName: String?,
): ParamOption<Any>? {
var namedOption: ParamOption<Any>? = null
var firstValueMatch: ParamOption<Any>? = null
var preferredValueMatch: ParamOption<Any>? = null
var defaultOption: ParamOption<Any>? = null
var index = 0
while (index < options.size) {
val option = options[index++]
if (option.name == intendedName) {
if (!preferAssignedValue) return option
namedOption = option
}
if (option.value == value) {
if (firstValueMatch == null) firstValueMatch = option
if (option.name == preferredValueName) {
if (preferAssignedValue) return option
preferredValueMatch = option
}
}
if (option.name == defaultName) defaultOption = option
}
return if (preferAssignedValue) {
preferredValueMatch ?: firstValueMatch ?: namedOption ?: defaultOption ?: options.firstOrNull()
} else {
namedOption ?: preferredValueMatch ?: firstValueMatch ?: defaultOption ?: options.firstOrNull()
}
}
29 changes: 29 additions & 0 deletions runtime/src/main/kotlin/composium/ParamProperty.kt
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
package oleginvoke.com.composium

import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KClass
import kotlin.reflect.KProperty
import kotlin.reflect.KType

/** Identity distinguishes consecutive writes, including writes back to an earlier value. */
internal class ParamValueAssignment(
val optionName: String?,
val lastNonNullValue: Any?,
val hasNonNullAssignment: Boolean,
)

/**
* Property delegate returned by [SceneScope.param].
* Obtain instances through [SceneScope.param] so controls registration is configured.
Expand All @@ -29,6 +37,20 @@ class ParamProperty<T> internal constructor(
internal var bindingSignature: Any? = null
internal var lastRegisteredName: String? = null

internal var objectOptions: List<ParamOption<Any>>? = null
internal var unregisteredOptions: List<ParamOption<Any>>? = null
internal var intendedOptionName: String? = null
internal var activationState: MutableState<Boolean>? = null
internal var onValueAssigned: ((Any?) -> Unit)? = null
internal val valueAssignmentState = mutableStateOf<ParamValueAssignment?>(null)
internal var acknowledgedValueAssignment: ParamValueAssignment? = null
internal val hasPendingValueAssignment: Boolean
get() = valueAssignmentState.value !== acknowledgedValueAssignment

internal fun acknowledgeValueAssignment() {
acknowledgedValueAssignment = valueAssignmentState.value
}

internal val resolvedName: String?
get() = explicitName ?: delegatedName

Expand Down Expand Up @@ -62,12 +84,19 @@ class ParamProperty<T> internal constructor(

/**
* Updates parameter value.
* Nullable assignments synchronize activity and preserve the last non-null selection.
*
* @param thisRef Owner object of delegated property.
* @param property Delegated property metadata.
* @param value New value.
*/
override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
// Preserve Compose's no-op policy for equal writes, including SideEffect writes.
val requestedActivity = value != null
val activityChanged = activationState?.value?.let { it != requestedActivity } == true
if (state.value == value && !activityChanged) return
state.value = value
if (activityChanged) activationState?.value = requestedActivity
onValueAssigned?.invoke(value)
}
}
Loading
Loading