diff --git a/README.md b/README.md index b295d494..ab96bf7b 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,77 @@ fun App() { See demo: [shared](https://github.com/Moriafly/SaltUI/tree/main/shared). +## Static material backgrounds + +`MaterialSource` defaults to `MaterialSourceMode.Dynamic`. For a wallpaper or another visually static +source, opt into caching with a content version: + +```kotlin +@OptIn(UnstableSaltUiApi::class) +@Composable +fun WallpaperBackground(wallpaperVersion: Any) { + MaterialSource( + mode = MaterialSourceMode.Static(key = wallpaperVersion) + ) { + // Draw the wallpaper here. + } +} +``` + +Use an immutable key with stable equality. Keep it equal while the source content is unchanged; +change it when the wallpaper changes or finishes loading. Use `Unit` for content without external +changes. Sizes, density, layout direction, dark/light mode, and material styles are tracked internally. +Pixels are shared by consumers using the same style; source content and foreground UI compose normally. + +Prefer wallpaper images or fixed drawing colors for a static source. Do not use changing theme +colors such as `SaltTheme.colors` in static source content without including their resolved values +in the content key. Fixed Compose `Color(...)` values are safe. The cache does not observe theme +palette, custom `CompositionLocal`, text style, or shape reads inside source content. For example: + +```kotlin +val sourceColor = SaltTheme.colors.highlight +MaterialSource(mode = MaterialSourceMode.Static(key = sourceColor)) { + Box(Modifier.fillMaxSize().background(sourceColor)) +} +``` + +When a source depends on several inputs, combine their resolved values in an immutable content key. +Use `Dynamic` for continuously changing content. + +The cache stores ARGB pixels at half the source width and height, preserving alpha and color depth. +A 1080 × 2280 source uses approximately 2.35 MiB per style, or 4.70 MiB for two styles. This is bitmap +storage, excluding temporary rendering resources and driver copies. A source reserves at most +16 MiB of bitmap storage; additional styles or allocation failures use live rendering. After the last +consumer leaves, at most one unused image remains for reentry. Changing the source key, environment, +or size invalidates it; admitting a new style can evict the unused image. + +Initial frames use live rendering. Baking starts after the size stays unchanged for two frames plus +80 ms, so resizing does not repeatedly capture intermediate sizes. Baking covers the whole source, +even for a small consumer. Small glass or acrylic panels may save little GPU work; measure the actual +screen before opting in. Mica and large or shared surfaces can have substantially different costs. + +Caching is an optimization and may fall back to dynamic rendering. The current cache path supports +a single source per theme and consumers contained within it with the same scale and orientation. +Multiple sources, other transforms, inherited custom Haze styles, and unsupported readback paths use +the live pipeline. + +To measure the tradeoff on a connected Android 12+ device, run: + +```powershell +./scripts/benchmark-material-cache.ps1 -Device +``` + +The script builds a non-debuggable benchmark variant and measures moving small, large, and shared +material surfaces in fresh processes. It exports raw steady-state and baking frame metrics, process +PSS, bitmap allocations, cache readiness and readback latency, APK hashes, source revision, and +repeated Dynamic/Static comparisons under +`androidApp/build/reports/material-cache-performance`. Emulator graphics memory accounting can be +incomplete; bitmap allocation and process PSS are reported separately. + +Use `-TargetApk ` to measure a previous build with the same instrumentation. Then use +`-BaselineReport ` when measuring the current build to export a comparison +report. Comparisons require matching instrumentation APK hashes. + ## Text Description and Translation Standards See: [SaltUI Text Description and Translation Standards](https://moriafly.com/standards/tdts). diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index f2619a73..d65b81c4 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -27,6 +27,14 @@ android { getByName("release") { isMinifyEnabled = false } + if (providers.gradleProperty("materialBenchmark").orNull == "true") { + create("benchmark") { + initWith(getByName("release")) + signingConfig = signingConfigs.getByName("debug") + matchingFallbacks += "release" + } + testBuildType = "benchmark" + } } compileOptions { diff --git a/androidApp/src/androidTest/kotlin/com/moriafly/salt/sample/ui/MaterialHardwareRenderingTest.kt b/androidApp/src/androidTest/kotlin/com/moriafly/salt/sample/ui/MaterialHardwareRenderingTest.kt new file mode 100644 index 00000000..579d497e --- /dev/null +++ b/androidApp/src/androidTest/kotlin/com/moriafly/salt/sample/ui/MaterialHardwareRenderingTest.kt @@ -0,0 +1,549 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.moriafly.salt.sample.ui + +import android.graphics.Bitmap +import android.os.SystemClock +import android.view.Choreographer +import androidx.activity.compose.setContent +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.getValue +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.unit.dp +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.moriafly.salt.ui.SaltMaterial +import com.moriafly.salt.ui.SaltConfigs +import com.moriafly.salt.ui.SaltColors +import com.moriafly.salt.ui.SaltDynamicColors +import com.moriafly.salt.ui.SaltTheme +import com.moriafly.salt.ui.UnstableSaltUiApi +import com.moriafly.salt.ui.material.MaterialSource +import com.moriafly.salt.ui.material.MaterialSourceMode +import com.moriafly.salt.ui.material.MaterialType +import com.moriafly.salt.ui.material.material +import com.moriafly.salt.ui.material.subMaterial +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.atomic.AtomicReference +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * Exercises material readback and reuse through hardware-composited Android window pixels. + */ +@OptIn(UnstableSaltUiApi::class) +@RunWith(AndroidJUnit4::class) +class MaterialHardwareRenderingTest { + private val materialState = AtomicReference() + + @Test + fun resolvedThemeColorKeyUpdatesHardwareRenderedPixels() { + var highlight by mutableStateOf(Color.Red) + val bounds = AtomicReference(Rect.Zero) + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + activity.setContent { + SaltTheme( + configs = SaltConfigs.default(isDarkTheme = false), + dynamicColors = SaltDynamicColors.default( + light = SaltColors.defaultLight(highlight = highlight) + ), + material = SaltMaterial(MaterialType.BlurryGlass) + ) { + materialState.set(checkNotNull(MaterialStateLocalForTests.current)) + val sourceColor = SaltTheme.colors.highlight + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Box(modifier = Modifier.size(160.dp)) { + MaterialSource( + materialSelf = false, + mode = MaterialSourceMode.Static(sourceColor) + ) { + Box(modifier = Modifier.fillMaxSize().background(sourceColor)) + } + Box( + modifier = Modifier + .fillMaxSize() + .material() + .onGloballyPositioned { bounds.set(it.boundsInWindow()) } + ) + } + } + } + } + } + awaitCache() + awaitPixel(bounds) { it.red > it.blue + 0.1f } + val original = materialCacheImages(materialState.get()).single() + scenario.onActivity { highlight = Color.Blue } + awaitCache(previous = original) + awaitFrames() + awaitPixel(bounds) { it.blue > it.red + 0.1f } + } + } + + @Test + fun staticMaterialsMatchWholeHardwareRenderedPanels() { + var mode by mutableStateOf(MaterialSourceMode.Dynamic) + var type by mutableStateOf(MaterialType.BlurryGlass) + var dark by mutableStateOf(false) + var subBackground by mutableStateOf(false) + val bounds = AtomicReference(Rect.Zero) + val markerBounds = AtomicReference(Rect.Zero) + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + activity.setContent { + SaltTheme( + configs = SaltConfigs.default(isDarkTheme = dark), + material = SaltMaterial(type) + ) { + materialState.set(checkNotNull(MaterialStateLocalForTests.current)) + Box( + modifier = Modifier.fillMaxSize().background(Color.Black), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier.size(241.dp, 161.dp) + .onGloballyPositioned { bounds.set(it.boundsInWindow()) } + ) { + MaterialSource(materialSelf = false, mode = mode) { + Canvas(modifier = Modifier.fillMaxSize()) { + drawRect( + brush = Brush.linearGradient( + listOf(Color.Red, Color.Blue, Color.Green) + ) + ) + drawRect( + color = Color.White.copy(alpha = 0.5f), + topLeft = Offset(size.width * 0.3f, size.height * 0.2f), + size = Size(size.width * 0.2f, size.height * 0.5f) + ) + } + } + val effect = if (subBackground) { + Modifier.subMaterial() + } else { + Modifier.material() + } + Box(modifier = Modifier.fillMaxSize().then(effect)) + } + Box( + modifier = Modifier + .align(Alignment.TopStart) + .size(24.dp) + .background(caseMarker(type, dark, subBackground)) + .onGloballyPositioned { markerBounds.set(it.boundsInWindow()) } + ) + } + } + } + } + for (materialType in MaterialType.entries.filter { it != MaterialType.None }) { + for (isDark in listOf(false, true)) { + for (subLayer in listOf(false, true)) { + scenario.onActivity { + mode = MaterialSourceMode.Dynamic + type = materialType + dark = isDark + subBackground = subLayer + } + val marker = caseMarker(materialType, isDark, subLayer) + awaitPixel(markerBounds) { it.difference(marker) < 0.02f } + val live = panelImage(bounds.get()) + try { + scenario.onActivity { mode = MaterialSourceMode.Static(Unit) } + awaitCache() + awaitFrames() + val cached = panelImage(bounds.get()) + try { + val difference = imageDifference(live, cached) + if (difference >= 0.015f) { + val directory = checkNotNull( + InstrumentationRegistry.getInstrumentation() + .targetContext.getExternalFilesDir("material-tests") + ) + val images = listOf("live" to live, "cached" to cached) + for ((name, image) in images) { + java.io.File(directory, "$name.png").outputStream().use { + image.compress(Bitmap.CompressFormat.PNG, 100, it) + } + } + } + assertTrue( + "$materialType/dark=$isDark/sub=$subLayer: $difference", + difference < 0.015f + ) + } finally { + cached.recycle() + } + } finally { + live.recycle() + } + } + } + } + } + } + + private fun caseMarker( + type: MaterialType, + dark: Boolean, + subBackground: Boolean + ): Color = Color( + red = type.ordinal * 0.2f, + green = if (dark) { 0.9f } else { 0.1f }, + blue = if (subBackground) { 0.8f } else { 0.2f } + ) + + @Test + fun staticBackgroundReusesPixelsAndRefreshesForContentAndSize() { + var sourceColor by mutableStateOf(Color.Red) + var foreground by mutableStateOf(Color.Green) + var edge by mutableStateOf(160.dp) + var mode by mutableStateOf(MaterialSourceMode.Dynamic) + val sourceBounds = AtomicReference(Rect.Zero) + val panelBounds = AtomicReference(Rect.Zero) + val foregroundBounds = AtomicReference(Rect.Zero) + + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + assertTrue( + "This test requires a hardware-accelerated window", + activity.window.decorView.isHardwareAccelerated + ) + activity.setContent { + SaltTheme( + configs = SaltConfigs.default(isDarkTheme = false), + material = SaltMaterial(MaterialType.BlurryGlass) + ) { + materialState.set(checkNotNull(MaterialStateLocalForTests.current)) + Box( + modifier = Modifier.fillMaxSize().background(Color.Black), + contentAlignment = Alignment.Center + ) { + Box(modifier = Modifier.size(edge)) { + MaterialSource( + materialSelf = false, + mode = mode, + modifier = Modifier.onGloballyPositioned { + sourceBounds.set(it.boundsInWindow()) + } + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + val color = if (size.width > 180.dp.toPx()) { + Color.Green + } else { + sourceColor + } + drawRect(color) + } + } + Box( + modifier = Modifier + .align(Alignment.Center) + .size(120.dp) + .material() + .onGloballyPositioned { + panelBounds.set(it.boundsInWindow()) + } + ) { + Box( + modifier = Modifier + .size(24.dp) + .background(foreground) + .onGloballyPositioned { + foregroundBounds.set(it.boundsInWindow()) + } + ) + } + } + } + } + } + } + + val live = awaitPixel(panelBounds) { it.red > it.blue + 0.1f && it.blue > 0.2f } + scenario.onActivity { mode = MaterialSourceMode.Static(Unit) } + awaitCache() + val baked = awaitPixel(panelBounds) { it.difference(live) < 0.03f } + assertTrue( + "Baking must preserve the visible material: $live -> $baked", + live.difference(baked) < 0.03f + ) + + scenario.onActivity { + sourceColor = Color.Blue + foreground = Color.Yellow + } + awaitPixel(sourceBounds, horizontal = 0.03f) { it.blue > 0.9f && it.red < 0.1f } + awaitPixel(foregroundBounds) { it.red > 0.9f && it.green > 0.9f } + val reused = awaitPixel(panelBounds) { it.difference(baked) < 0.03f } + assertTrue( + "An unchanged content version must reuse the baked background", + baked.difference(reused) < 0.03f + ) + + scenario.onActivity { mode = MaterialSourceMode.Static(1) } + awaitCache() + awaitPixel(panelBounds) { it.blue > it.red + 0.1f } + + scenario.onActivity { edge = 200.dp } + awaitPixel(panelBounds) { it.green > it.blue + 0.1f && it.green > it.red + 0.1f } + } + } + + @Test + fun staticMicaMatchesLiveLayersAndUpdatesWithTheTheme() { + var mode by mutableStateOf(MaterialSourceMode.Dynamic) + var dark by mutableStateOf(false) + var subBackground by mutableStateOf(false) + val bounds = AtomicReference(Rect.Zero) + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + scenario.onActivity { activity -> + activity.setContent { + SaltTheme( + configs = SaltConfigs.default(isDarkTheme = dark), + material = SaltMaterial(MaterialType.Mica) + ) { + materialState.set(checkNotNull(MaterialStateLocalForTests.current)) + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Box(modifier = Modifier.size(160.dp)) { + MaterialSource(materialSelf = false, mode = mode) { + Box(modifier = Modifier.fillMaxSize().background(Color.Red)) + } + val material = if (subBackground) { + Modifier.subMaterial() + } else { + Modifier.material() + } + Box( + modifier = Modifier + .fillMaxSize() + .then(material) + .onGloballyPositioned { bounds.set(it.boundsInWindow()) } + ) + } + } + } + } + } + for (subLayer in listOf(false, true)) { + scenario.onActivity { + mode = MaterialSourceMode.Dynamic + dark = false + subBackground = subLayer + } + val liveLight = awaitPixel(bounds) { + it.red + it.green + it.blue > 2f && it.red > it.blue + 0.02f + } + scenario.onActivity { mode = MaterialSourceMode.Static(Unit) } + awaitCache() + val cachedLight = awaitPixel(bounds) { it.difference(liveLight) < 0.03f } + assertTrue( + "Light Mica/sub=$subLayer cache difference: $liveLight -> $cachedLight", + liveLight.difference(cachedLight) < 0.03f + ) + + scenario.onActivity { dark = true } + awaitCache() + awaitPixel(bounds) { it.red + it.green + it.blue < 1f && it.red > it.blue + 0.01f } + val cachedDark = pixel(bounds.get()) + scenario.onActivity { mode = MaterialSourceMode.Dynamic } + val liveDark = awaitPixel(bounds) { + it.red + it.green + it.blue < 1f && it.red > it.blue + 0.01f + } + assertTrue( + "Dark Mica must rebuild with the theme: $liveDark -> $cachedDark", + liveDark.difference(cachedDark) < 0.03f + ) + } + } + } + + private fun awaitFrames() { + val complete = CountDownLatch(1) + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.waitForIdleSync() + instrumentation.runOnMainSync { + val choreographer = Choreographer.getInstance() + var remaining = 3 + val callback = object : Choreographer.FrameCallback { + override fun doFrame(frameTimeNanos: Long) { + remaining-- + if (remaining == 0) { + complete.countDown() + } else { + choreographer.postFrameCallback(this) + } + } + } + choreographer.postFrameCallback(callback) + } + assertTrue("Android frames stalled", complete.await(5, TimeUnit.SECONDS)) + } + + private fun panelImage( + bounds: Rect + ): Bitmap { + val screenshot = checkNotNull( + InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot() + ) + return try { + Bitmap.createBitmap( + screenshot, + bounds.left.roundToInt(), + bounds.top.roundToInt(), + bounds.width.roundToInt(), + bounds.height.roundToInt() + ) + } finally { + screenshot.recycle() + } + } + + private fun imageDifference( + first: Bitmap, + second: Bitmap + ): Float { + val count = first.width * first.height + val firstPixels = IntArray(count) + val secondPixels = IntArray(count) + first.getPixels(firstPixels, 0, first.width, 0, 0, first.width, first.height) + second.getPixels(secondPixels, 0, second.width, 0, 0, second.width, second.height) + var difference = 0L + for (index in firstPixels.indices) { + for (shift in listOf(0, 8, 16)) { + val a = (firstPixels[index] ushr shift) and 0xff + val b = (secondPixels[index] ushr shift) and 0xff + difference += abs(a - b) + } + } + return difference.toFloat() / (count * 3 * 255) + } + + private fun pixel( + bounds: Rect, + horizontal: Float = 0.5f + ): Color { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.waitForIdleSync() + val screenshot = checkNotNull(instrumentation.uiAutomation.takeScreenshot()) + return try { + val x = (bounds.left + bounds.width * horizontal).roundToInt() + val y = bounds.center.y.roundToInt() + Color(screenshot.getPixel(x, y)) + } finally { + screenshot.recycle() + } + } + + private fun awaitPixel( + bounds: AtomicReference, + horizontal: Float = 0.5f, + predicate: (Color) -> Boolean + ): Color { + val deadline = SystemClock.uptimeMillis() + 5_000 + var latest = Color.Transparent + var previous = Color.Transparent + var stableFrames = 0 + while (SystemClock.uptimeMillis() < deadline) { + val currentBounds = bounds.get() + if (currentBounds.width > 0f && currentBounds.height > 0f) { + latest = pixel(currentBounds, horizontal) + stableFrames = if (predicate(latest) && latest.difference(previous) < 0.005f) { + stableFrames + 1 + } else { + 0 + } + if (stableFrames >= 2) { + return latest + } + previous = latest + } + SystemClock.sleep(32) + } + error("The expected hardware-rendered pixel was not displayed: $latest") + } + + private fun Color.difference(other: Color): Float = + abs(red - other.red) + abs(green - other.green) + abs(blue - other.blue) + + private fun awaitCache(previous: ImageBitmap? = null) { + val instrumentation = InstrumentationRegistry.getInstrumentation() + instrumentation.waitForIdleSync() + val deadline = SystemClock.uptimeMillis() + 10_000 + while (SystemClock.uptimeMillis() < deadline) { + if (materialCacheImages(materialState.get()).any { it !== previous }) { + return + } + SystemClock.sleep(5) + } + error("The expected material cache was not produced") + } +} + +@Suppress("UNCHECKED_CAST") +internal val MaterialStateLocalForTests: ProvidableCompositionLocal by lazy { + Class.forName("com.moriafly.salt.ui.material.MaterialKt") + .getMethod("getLocalMaterialState") + .invoke(null) as ProvidableCompositionLocal +} + +internal fun materialCacheEntries( + state: Any? +): List { + if (state == null) { + return emptyList() + } + val sources = state.javaClass.getMethod("getSources").invoke(state) as List<*> + val source = sources.singleOrNull() ?: return emptyList() + val entries = source.javaClass.getMethod("getEntries").invoke(source) as List<*> + return entries.toList().filterNotNull() +} + +internal fun materialCacheImages( + state: Any? +): List = materialCacheEntries(state).mapNotNull { + it.javaClass.getMethod("getImage").invoke(it) as ImageBitmap? +} diff --git a/androidApp/src/androidTest/kotlin/com/moriafly/salt/sample/ui/MaterialPerformanceBenchmark.kt b/androidApp/src/androidTest/kotlin/com/moriafly/salt/sample/ui/MaterialPerformanceBenchmark.kt new file mode 100644 index 00000000..a6182b15 --- /dev/null +++ b/androidApp/src/androidTest/kotlin/com/moriafly/salt/sample/ui/MaterialPerformanceBenchmark.kt @@ -0,0 +1,418 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.moriafly.salt.sample.ui + +import android.content.pm.ApplicationInfo +import android.os.Build +import android.os.Bundle +import android.os.Debug +import android.os.Handler +import android.os.HandlerThread +import android.os.Process +import android.os.SystemClock +import android.view.FrameMetrics +import android.view.Window +import android.view.WindowManager +import androidx.activity.compose.setContent +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.sp +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.moriafly.salt.ui.SaltConfigs +import com.moriafly.salt.ui.SaltMaterial +import com.moriafly.salt.ui.SaltTheme +import com.moriafly.salt.ui.UnstableSaltUiApi +import com.moriafly.salt.ui.material.MaterialSource +import com.moriafly.salt.ui.material.MaterialSourceMode +import com.moriafly.salt.ui.material.MaterialType +import com.moriafly.salt.ui.material.material +import com.moriafly.salt.ui.material.subMaterial +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import kotlin.math.ceil +import kotlin.math.roundToInt +import kotlin.math.sin + +/** + * Measures a real Android window, including rendering and process memory. Run through + * scripts/benchmark-material-cache.ps1; ordinary correctness runs skip this benchmark. + */ +@OptIn(UnstableSaltUiApi::class) +@RunWith(AndroidJUnit4::class) +class MaterialPerformanceBenchmark { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val materialState = AtomicReference() + private val sourceSize = AtomicReference(IntSize.Zero) + + @Test + fun measureMaterialRenderingAndMemory() { + val arguments = InstrumentationRegistry.getArguments() + assumeTrue(arguments.getString("materialBenchmark") == "true") + assumeTrue(Build.VERSION.SDK_INT >= 31) + val type = MaterialType.valueOf(arguments.getString("materialType", "BlurryGlass")) + val scene = arguments.getString("scene", "Large") + val cached = arguments.getString("mode", "Dynamic") == "Static" + val durationMs = arguments.getString("durationMs", "2000").toLong() + val frames = ArrayList() + val bakeFrames = ArrayList() + var recording = false + var recordingBake = false + var callbackDrops = 0 + var bakeCallbackDrops = 0 + val metricsThread = HandlerThread("material-frame-metrics").apply { start() } + val metricsHandler = Handler(metricsThread.looper) + val listener = Window.OnFrameMetricsAvailableListener { _, metrics, dropped -> + // All collection state is confined to this handler thread. + if ((recording || recordingBake) && + metrics.getMetric(FrameMetrics.FIRST_DRAW_FRAME) == 0L + ) { + val sample = longArrayOf( + metrics.getMetric(FrameMetrics.TOTAL_DURATION), + metrics.getMetric(FrameMetrics.GPU_DURATION), + metrics.getMetric(FrameMetrics.DRAW_DURATION), + metrics.getMetric(FrameMetrics.COMMAND_ISSUE_DURATION), + metrics.getMetric(FrameMetrics.DEADLINE) + ) + if (recording) { + frames.add(sample) + callbackDrops += dropped + } else { + bakeFrames.add(sample) + bakeCallbackDrops += dropped + } + } + } + + var mode by mutableStateOf(MaterialSourceMode.Dynamic) + var animate by mutableStateOf(false) + var phase by mutableFloatStateOf(0f) + var buildMs = 0.0 + var cacheBytes = 0L + var cacheCount = 0 + var readbackMs = emptyList() + try { + ActivityScenario.launch(MainActivity::class.java).use { activityScenario -> + activityScenario.onActivity { activity -> + val debuggable = activity.applicationInfo.flags and + ApplicationInfo.FLAG_DEBUGGABLE != 0 + assertTrue("Use the non-debuggable benchmark variant", !debuggable) + assertTrue(activity.window.decorView.isHardwareAccelerated) + activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + activity.window.addOnFrameMetricsAvailableListener(listener, metricsHandler) + activity.setContent { + SaltTheme( + configs = SaltConfigs.default(isDarkTheme = false), + material = SaltMaterial(type) + ) { + materialState.set(checkNotNull(MaterialStateLocalForTests.current)) + BenchmarkScene( + scene = scene, + mode = mode, + phase = { phase } + ) + LaunchedEffect(animate) { + if (animate) { + val start = withFrameNanos { it } + while (true) { + withFrameNanos { + phase = sin((it - start) / 1_000_000_000.0 * 3.5) + .toFloat() + } + } + } + } + } + } + } + instrumentation.waitForIdleSync() + SystemClock.sleep(500) + if (cached) { + onMetricsThread(metricsHandler) { recordingBake = true } + val started = SystemClock.elapsedRealtimeNanos() + instrumentation.runOnMainSync { mode = MaterialSourceMode.Static(Unit) } + val expectedCount = if (scene == "Shared") { + 2 + } else { + 1 + } + awaitBakedEntries(expectedCount) + buildMs = (SystemClock.elapsedRealtimeNanos() - started) / 1_000_000.0 + val images = materialCacheImages(materialState.get()) + cacheCount = images.size + cacheBytes = images.sumOf { it.asAndroidBitmap().allocationByteCount.toLong() } + readbackMs = materialCacheEntries(materialState.get()).mapNotNull { entry -> + // Older baseline APKs have no per-entry readback timing. + entry.javaClass.methods.firstOrNull { it.name == "getReadbackNanos" } + ?.invoke(entry)?.let { (it as Long) / 1_000_000.0 } + } + // Include the publication frame and allow its GPU metrics to arrive. + SystemClock.sleep(100) + onMetricsThread(metricsHandler) { recordingBake = false } + } + + // Warm both pipelines with the same moving panels before collecting frame data. + instrumentation.runOnMainSync { animate = true } + SystemClock.sleep(1_000) + instrumentation.runOnMainSync { animate = false } + val beforeMemory = memorySnapshot() + + instrumentation.runOnMainSync { animate = true } + onMetricsThread(metricsHandler) { recording = true } + val cpuStart = Process.getElapsedCpuTime() + val wallStart = SystemClock.elapsedRealtime() + SystemClock.sleep(durationMs) + onMetricsThread(metricsHandler) { recording = false } + val wallMs = SystemClock.elapsedRealtime() - wallStart + val cpuMs = Process.getElapsedCpuTime() - cpuStart + instrumentation.runOnMainSync { animate = false } + val afterMemory = memorySnapshot() + assertTrue("Too few rendered frames: ${frames.size}", frames.size >= 20) + + val modeName = if (cached) { + "Static" + } else { + "Dynamic" + } + val result = JSONObject() + .put("materialType", type.name) + .put("scene", scene) + .put("mode", modeName) + .put("iteration", arguments.getString("iteration", "1").toInt()) + .put("sdk", Build.VERSION.SDK_INT) + .put("device", Build.MODEL) + .put("debuggable", false) + .put("sourceWidth", sourceSize.get().width) + .put("sourceHeight", sourceSize.get().height) + .put("cacheCount", cacheCount) + .put("cacheBitmapBytes", cacheBytes) + .put("cacheReadyMs", buildMs) + .put("readbackMs", JSONArray(readbackMs)) + .put("bakeTotalMs", distribution(bakeFrames.map { it[0] })) + .put("bakeGpuMs", distribution(bakeFrames.map { it[1] })) + .put("bakeMetricsCallbackDrops", bakeCallbackDrops) + .put("bakeFramesNs", JSONArray(bakeFrames.map { JSONArray(it.toList()) })) + .put("wallMs", wallMs) + .put("cpuMs", cpuMs) + .put("cpuMsPerFrame", cpuMs.toDouble() / frames.size) + .put("frameCount", frames.size) + .put("metricsCallbackDrops", callbackDrops) + .put("totalMs", distribution(frames.map { it[0] })) + .put("gpuMs", distribution(frames.map { it[1] })) + .put("drawMs", distribution(frames.map { it[2] })) + .put("commandMs", distribution(frames.map { it[3] })) + .put("deadlineMisses", frames.count { it[4] > 0 && it[0] > it[4] }) + .put("memoryBefore", beforeMemory) + .put("memoryAfter", afterMemory) + .put("framesNs", JSONArray(frames.map { JSONArray(it.toList()) })) + instrumentation.sendStatus(2, Bundle().apply { + putString("materialBenchmark", result.toString()) + }) + activityScenario.onActivity { + it.window.removeOnFrameMetricsAvailableListener(listener) + } + } + } finally { + metricsThread.quitSafely() + metricsThread.join() + } + } + + @Composable + private fun BenchmarkScene( + scene: String, + mode: MaterialSourceMode, + phase: () -> Float + ) { + BoxWithConstraints( + modifier = Modifier.fillMaxSize() + ) { + MaterialSource( + modifier = Modifier.onSizeChanged(sourceSize::set), + materialSelf = false, + mode = mode + ) { + Canvas( + modifier = Modifier.fillMaxSize() + ) { + drawRect( + brush = Brush.linearGradient(listOf(Color.Red, Color.Blue, Color.Green)) + ) + val cellWidth = size.width / 16 + val cellHeight = size.height / 24 + for (row in 0 until 24) { + for (column in 0 until 16) { + if ((row + column) % 2 == 0) { + drawRect( + color = Color.White.copy(alpha = 0.35f), + topLeft = Offset(column * cellWidth, row * cellHeight), + size = Size(cellWidth, cellHeight) + ) + } + } + } + } + } + + val density = LocalDensity.current + val widthPx = with(density) { maxWidth.toPx() } + val heightPx = with(density) { maxHeight.toPx() } + val widthFraction = when (scene) { + "Small" -> 0.25f + "Shared" -> 0.4f + else -> 0.8f + } + val heightFraction = when (scene) { + "Small" -> 0.2f + "Shared" -> 0.35f + else -> 0.8f + } + val panelCount = if (scene == "Shared") { + 4 + } else { + 1 + } + for (index in 0 until panelCount) { + val baseX = if (panelCount == 1) { + (1 - widthFraction) / 2 + } else { + 0.05f + (index % 2) * 0.5f + } + val baseY = if (panelCount == 1) { + (1 - heightFraction) / 2 + } else { + 0.1f + (index / 2) * 0.45f + } + val effect = if (scene == "Shared" && index % 2 == 1) { + Modifier.subMaterial() + } else { + Modifier.material() + } + Box( + modifier = Modifier + .offset { + val motion = phase() * 0.025f + IntOffset( + x = ((baseX + motion) * widthPx).roundToInt(), + y = ((baseY + motion) * heightPx).roundToInt() + ) + } + .size(maxWidth * widthFraction, maxHeight * heightFraction) + .then(effect) + ) { + BasicText( + text = "Material ${index + 1}", + style = TextStyle(color = Color.White, fontSize = 18.sp) + ) + } + } + } + } + + private fun awaitBakedEntries( + count: Int + ) { + val deadline = SystemClock.uptimeMillis() + 10_000 + while (SystemClock.uptimeMillis() < deadline) { + if (materialCacheImages(materialState.get()).size == count) { + return + } + SystemClock.sleep(5) + } + error("Expected $count baked images; the scene may have fallen back to live rendering") + } + + private fun memorySnapshot(): JSONObject { + Runtime.getRuntime().gc() + SystemClock.sleep(150) + val samples = (0 until 3).map { + Debug.MemoryInfo().also { info -> + Debug.getMemoryInfo(info) + SystemClock.sleep(50) + } + } + val result = JSONObject() + for (name in listOf("total-pss", "java-heap", "native-heap", "graphics", "private-other")) { + val values = samples.map { it.getMemoryStat("summary.$name").toLong() }.sorted() + result.put("${name}Kb", values[values.size / 2]) + } + return result + } + + private fun distribution( + values: List + ): JSONObject { + val sorted = values.filter { it >= 0 }.sorted() + if (sorted.isEmpty()) { + return JSONObject().put("supported", false) + } + fun percentile(fraction: Double): Double { + val index = (ceil(sorted.size * fraction).toInt() - 1).coerceAtLeast(0) + return sorted[index] / 1_000_000.0 + } + return JSONObject() + .put("supported", true) + .put("median", percentile(0.5)) + .put("p95", percentile(0.95)) + .put("max", sorted.last() / 1_000_000.0) + .put("mean", sorted.average() / 1_000_000.0) + } + + private fun onMetricsThread( + handler: Handler, + block: () -> Unit + ) { + val complete = CountDownLatch(1) + handler.post { + block() + complete.countDown() + } + assertTrue("Frame metrics handler stalled", complete.await(5, TimeUnit.SECONDS)) + } +} diff --git a/androidApp/src/androidTest/kotlin/com/moriafly/salt/sample/ui/MaterialSampleInteractionTest.kt b/androidApp/src/androidTest/kotlin/com/moriafly/salt/sample/ui/MaterialSampleInteractionTest.kt new file mode 100644 index 00000000..b20f8d06 --- /dev/null +++ b/androidApp/src/androidTest/kotlin/com/moriafly/salt/sample/ui/MaterialSampleInteractionTest.kt @@ -0,0 +1,222 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.moriafly.salt.sample.ui + +import android.graphics.Color +import android.os.SystemClock +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.BySelector +import androidx.test.uiautomator.Configurator +import androidx.test.uiautomator.StaleObjectException +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObject2 +import androidx.test.uiautomator.UiScrollable +import androidx.test.uiautomator.UiSelector +import androidx.test.uiautomator.Until +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.math.abs + +/** + * Opens the sample through its normal navigation using real touchscreen and system back events. + */ +@RunWith(AndroidJUnit4::class) +class MaterialSampleInteractionTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val device = UiDevice.getInstance(instrumentation) + + @Test + fun materialTestSupportsCachingControlsAndRestoresItsNavigation() { + val configurator = Configurator.getInstance() + val idleTimeout = configurator.waitForIdleTimeout + configurator.waitForIdleTimeout = 0 + try { + device.wakeUp() + device.executeShellCommand("wm dismiss-keyguard") + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + assertTrue(device.wait(Until.hasObject(By.text("Main")), 10_000)) + click(By.text("Material")) + click(By.textContains("Material cache test")) + shown(By.text("Material test")) + click(By.textContains("Static source")) + shown(By.textContains("Static key 0")) + val first = previewColor() + click(By.text("Tap count: 0")) + shown(By.text("Tap count: 1")) + click(By.text("Change wallpaper")) + shown(By.textContains("Static key 1")) + awaitPreview { difference(it, first) > 0.08f } + click(By.text("Refresh cache")) + shown(By.textContains("Static key 2")) + + scenario.recreate() + shown(By.text("Material test")) + shown(By.textContains("Static key 2")) + shown(By.text("Tap count: 1")) + + click(By.text("Material type")) + click(By.text("Mica")) + click(By.text("Panel layout")) + click(By.text("Shared")) + shown(By.text("Sub-background")) + val light = previewColor() + click(By.text("Dark preview")) + awaitPreview { difference(it, light) > 0.1f } + click(By.text("Animate panels")) + val firstPosition = shown(By.text("Tap count: 1")).visibleBounds + val motionDeadline = SystemClock.uptimeMillis() + 5_000 + var moved = false + while (SystemClock.uptimeMillis() < motionDeadline && !moved) { + val current = shown(By.text("Tap count: 1")).visibleBounds + moved = current.left != firstPosition.left || current.top != firstPosition.top + SystemClock.sleep(32) + } + assertTrue("Animating panels must move the interactive foreground", moved) + click(By.text("Animate panels")) + click(By.text("Panel layout")) + click(By.text("Small")) + click(By.text("Panel layout")) + click(By.text("Large")) + shown(By.text("Tap count: 1")) + val heightBefore = shown(By.desc("Material preview")).visibleBounds.height() + val slider = shown(By.clazz("android.widget.SeekBar")).visibleBounds + device.swipe( + slider.left + slider.width() / 4, + slider.centerY(), + slider.right - 2, + slider.centerY(), + 30 + ) + val resizeDeadline = SystemClock.uptimeMillis() + 5_000 + var resized = false + while (SystemClock.uptimeMillis() < resizeDeadline && !resized) { + val height = shown(By.desc("Material preview")).visibleBounds.height() + resized = abs(height - heightBefore) > 10 + SystemClock.sleep(32) + } + assertTrue("Dragging the slider must resize the actual source", resized) + + device.pressBack() + shown(By.text("Title Bar Backdrop Type")) + device.pressBack() + shown(By.text("Main")) + } + } finally { + configurator.waitForIdleTimeout = idleTimeout + } + } + + private fun click( + selector: BySelector + ) { + shown(selector) + val deadline = SystemClock.uptimeMillis() + 5_000 + var previous = android.graphics.Rect() + var stable = 0 + while (SystemClock.uptimeMillis() < deadline) { + try { + val target = device.findObject(selector) + val bounds = target?.visibleBounds + if (bounds != null && !bounds.isEmpty) { + stable = if (bounds == previous) { stable + 1 } else { 0 } + if (stable >= 3) { + target.click() + return + } + previous = bounds + } + } catch (_: StaleObjectException) { + stable = 0 + } + SystemClock.sleep(80) + } + error("The touch target did not settle: $selector") + } + + private fun shown( + selector: BySelector + ): UiObject2 { + fun visible(): UiObject2? = try { + device.findObject(selector)?.takeIf { !it.visibleBounds.isEmpty } + } catch (_: StaleObjectException) { + null + } + val deadline = SystemClock.uptimeMillis() + 1_000 + while (SystemClock.uptimeMillis() < deadline) { + visible()?.let { return it } + SystemClock.sleep(32) + } + for (direction in listOf(-1, 1)) { + repeat(8) { + val scroll = UiScrollable(UiSelector().scrollable(true)).setAsVerticalList() + if (direction < 0) { + scroll.scrollForward(80) + } else { + scroll.scrollBackward(80) + } + device.wait(Until.hasObject(selector), 250) + visible()?.let { return it } + } + } + val directory = checkNotNull( + instrumentation.targetContext.getExternalFilesDir("material-sample-tests") + ) + device.takeScreenshot(java.io.File(directory, "failure.png")) + device.dumpWindowHierarchy(java.io.File(directory, "failure.xml")) + error("The sample did not show $selector") + } + + private fun previewColor(): FloatArray { + val bounds = shown(By.desc("Material preview")).visibleBounds + val screenshot = checkNotNull(instrumentation.uiAutomation.takeScreenshot()) + return try { + val x = bounds.left + (bounds.width() * 0.75f).toInt() + val y = bounds.top + (bounds.height() * 0.25f).toInt() + val color = screenshot.getPixel(x, y) + floatArrayOf( + Color.red(color) / 255f, + Color.green(color) / 255f, + Color.blue(color) / 255f + ) + } finally { + screenshot.recycle() + } + } + + private fun awaitPreview( + predicate: (FloatArray) -> Boolean + ) { + val deadline = SystemClock.uptimeMillis() + 8_000 + while (SystemClock.uptimeMillis() < deadline) { + if (predicate(previewColor())) { + return + } + SystemClock.sleep(32) + } + assertTrue("The material preview did not update", false) + } + + private fun difference( + first: FloatArray, + second: FloatArray + ): Float = first.indices.sumOf { abs(first[it] - second[it]).toDouble() }.toFloat() +} diff --git a/scripts/benchmark-material-cache.ps1 b/scripts/benchmark-material-cache.ps1 new file mode 100644 index 00000000..2d8a43b5 --- /dev/null +++ b/scripts/benchmark-material-cache.ps1 @@ -0,0 +1,304 @@ +param( + [Parameter(Mandatory = $true)][string]$Device, + [string]$Adb = "$env:LOCALAPPDATA\Android\Sdk\platform-tools\adb.exe", + [int]$AdbServerPort = 5037, + [ValidateRange(1, 10)][int]$Repetitions = 3, + [ValidateRange(1000, 30000)][int]$DurationMs = 2000, + [ValidateSet('BlurryGlass', 'Acrylic', 'Mica', 'Premium')] + [string[]]$Materials = @('BlurryGlass', 'Acrylic', 'Mica', 'Premium'), + [ValidateSet('Small', 'Large', 'Shared')] + [string[]]$Scenes = @('Small', 'Large', 'Shared'), + [switch]$SkipBuild, + [string]$TargetApk, + [string]$Label = 'current', + [string]$BaselineReport +) + +$ErrorActionPreference = 'Stop' +$projectRoot = Split-Path -Parent $PSScriptRoot +if (-not $TargetApk) { + $TargetApk = Join-Path $projectRoot 'androidApp/build/outputs/apk/benchmark/androidApp-benchmark.apk' +} +$testApk = Join-Path $projectRoot 'androidApp/build/outputs/apk/androidTest/benchmark/androidApp-benchmark-androidTest.apk' +$outputRoot = Join-Path $projectRoot 'androidApp/build/reports/material-cache-performance' +$runDirectory = Join-Path $outputRoot (Get-Date -Format 'yyyyMMdd-HHmmss') +New-Item -ItemType Directory -Path $runDirectory -Force | Out-Null + +function Invoke-AdbChecked { + param([string[]]$Arguments) + $output = & $Adb -P $AdbServerPort -s $Device @Arguments 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "adb failed: $Arguments`n$output" + } + return $output +} + +function Get-Median { + param([double[]]$Values) + $sorted = @($Values | Sort-Object) + $middle = [int][Math]::Floor($sorted.Count / 2) + if ($sorted.Count % 2 -eq 1) { + return $sorted[$middle] + } + return ($sorted[$middle - 1] + $sorted[$middle]) / 2 +} + +Push-Location $projectRoot +try { + if (-not $SkipBuild) { + & .\gradlew.bat -PmaterialBenchmark=true :androidApp:assembleBenchmark ` + :androidApp:assembleBenchmarkAndroidTest --console=plain + if ($LASTEXITCODE -ne 0) { + throw 'Benchmark build failed' + } + } + + $metadata = [ordered]@{ + device = $Device + model = (Invoke-AdbChecked @('shell', 'getprop', 'ro.product.model')) -join "`n" + android = (Invoke-AdbChecked @('shell', 'getprop', 'ro.build.version.release')) -join "`n" + size = (Invoke-AdbChecked @('shell', 'wm', 'size')) -join "`n" + density = (Invoke-AdbChecked @('shell', 'wm', 'density')) -join "`n" + graphics = (Invoke-AdbChecked @('shell', 'dumpsys', 'SurfaceFlinger') | + Select-String 'GLES:|refresh-rate') -join "`n" + repetitions = $Repetitions + durationMs = $DurationMs + build = 'benchmark: non-debuggable, release fallback, no minification' + startedAt = (Get-Date).ToString('o') + label = $Label + sourceRevision = (git rev-parse HEAD) + sourceDirty = @(git status --porcelain).Count -gt 0 + apkSha256 = (Get-FileHash -LiteralPath $TargetApk).Hash + testApkSha256 = (Get-FileHash -LiteralPath $testApk).Hash + summaryPssCheckpoint = 'memoryAfter: after measured animation and requested GC' + totalFrames = 0 + } + $metadata | ConvertTo-Json | Set-Content (Join-Path $runDirectory 'environment.json') + Invoke-AdbChecked @('install', '-r', $TargetApk) + Invoke-AdbChecked @('install', '-r', $testApk) + + $validation = Invoke-AdbChecked @( + 'shell', 'am', 'instrument', '-w', '-r', + '-e', 'class', 'com.moriafly.salt.sample.ui.MaterialHardwareRenderingTest', + 'com.moriafly.salt.ui.test/androidx.test.runner.AndroidJUnitRunner' + ) + $validation | Set-Content (Join-Path $runDirectory 'correctness.log') + if (-not ($validation -match '^OK \(\d+ tests?\)\s*$')) { + throw "Material rendering validation failed. See $runDirectory/correctness.log" + } + + $samples = [System.Collections.Generic.List[object]]::new() + $sampleCount = $Materials.Count * $Scenes.Count * $Repetitions * 2 + foreach ($material in $Materials) { + foreach ($scene in $Scenes) { + for ($iteration = 1; $iteration -le $Repetitions; $iteration++) { + $modes = if ($iteration % 2 -eq 1) { + @('Dynamic', 'Static') + } else { + @('Static', 'Dynamic') + } + foreach ($mode in $modes) { + Invoke-AdbChecked @('shell', 'am', 'force-stop', 'com.moriafly.salt.ui') | + Out-Null + $sampleName = "$material-$scene-$iteration-$mode" + $output = Invoke-AdbChecked @( + 'shell', 'am', 'instrument', '-w', '-r', + '-e', 'class', 'com.moriafly.salt.sample.ui.MaterialPerformanceBenchmark', + '-e', 'materialBenchmark', 'true', + '-e', 'materialType', $material, + '-e', 'scene', $scene, + '-e', 'mode', $mode, + '-e', 'iteration', "$iteration", + '-e', 'durationMs', "$DurationMs", + 'com.moriafly.salt.ui.test/androidx.test.runner.AndroidJUnitRunner' + ) + $output | Set-Content (Join-Path $runDirectory "$sampleName.log") + $jsonLine = $output | Where-Object { + $_ -match '^INSTRUMENTATION_STATUS: materialBenchmark=' + } | Select-Object -Last 1 + if (-not $jsonLine -or -not ($output -match '^OK \(\d+ tests?\)\s*$')) { + throw "Benchmark failed: $sampleName. See $runDirectory" + } + $sample = $jsonLine.Substring($jsonLine.IndexOf('=') + 1) | ConvertFrom-Json + $samples.Add($sample) + $metadata.totalFrames += $sample.frameCount + $metadata | ConvertTo-Json | + Set-Content (Join-Path $runDirectory 'environment.json') + $samples.ToArray() | ConvertTo-Json -Depth 10 | + Set-Content (Join-Path $runDirectory 'samples.json') + Write-Output ( + '[{0}/{1}] {2}: frames={3}, GPU median={4:N2} ms, P95={5:N2} ms, PSS={6:N1} MiB' -f + $samples.Count, $sampleCount, $sampleName, $sample.frameCount, + $sample.gpuMs.median, $sample.totalMs.p95, + ($sample.memoryAfter.'total-pssKb' / 1024) + ) + } + } + } + } + + $summary = foreach ($material in $Materials) { + foreach ($scene in $Scenes) { + $matching = @($samples | Where-Object { + $_.materialType -eq $material -and $_.scene -eq $scene + }) + $dynamic = @($matching | Where-Object mode -eq 'Dynamic') + $cached = @($matching | Where-Object mode -eq 'Static') + $liveGpu = Get-Median @($dynamic | ForEach-Object { $_.gpuMs.median }) + $cachedGpu = Get-Median @($cached | ForEach-Object { $_.gpuMs.median }) + $liveP95 = Get-Median @($dynamic | ForEach-Object { $_.totalMs.p95 }) + $cachedP95 = Get-Median @($cached | ForEach-Object { $_.totalMs.p95 }) + $livePss = Get-Median @($dynamic | ForEach-Object { $_.memoryAfter.'total-pssKb' }) + $cachedPss = Get-Median @($cached | ForEach-Object { $_.memoryAfter.'total-pssKb' }) + $readbacks = @($cached | Where-Object { $_.readbackMs.Count -gt 0 }) + [pscustomobject][ordered]@{ + materialType = $material + scene = $scene + dynamicGpuMedianMs = $liveGpu + staticGpuMedianMs = $cachedGpu + gpuTimeReductionPercent = if ($liveGpu -gt 0) { + 100 * ($liveGpu - $cachedGpu) / $liveGpu + } else { $null } + dynamicTotalP95Ms = $liveP95 + staticTotalP95Ms = $cachedP95 + totalP95ReductionPercent = 100 * ($liveP95 - $cachedP95) / $liveP95 + dynamicCpuMsPerFrame = Get-Median @($dynamic | ForEach-Object cpuMsPerFrame) + staticCpuMsPerFrame = Get-Median @($cached | ForEach-Object cpuMsPerFrame) + dynamicRenderedFps = Get-Median @($dynamic | ForEach-Object { + 1000.0 * $_.frameCount / $_.wallMs + }) + staticRenderedFps = Get-Median @($cached | ForEach-Object { + 1000.0 * $_.frameCount / $_.wallMs + }) + dynamicPssMiB = $livePss / 1024 + staticPssMiB = $cachedPss / 1024 + pssIncreaseMiB = ($cachedPss - $livePss) / 1024 + dynamicWarmupPssMiB = (Get-Median @($dynamic | ForEach-Object { + $_.memoryBefore.'total-pssKb' + })) / 1024 + staticWarmupPssMiB = (Get-Median @($cached | ForEach-Object { + $_.memoryBefore.'total-pssKb' + })) / 1024 + bitmapMiB = (Get-Median @($cached | ForEach-Object cacheBitmapBytes)) / 1MB + cacheReadyMs = Get-Median @($cached | ForEach-Object cacheReadyMs) + readbackTotalMs = if ($readbacks.Count -gt 0) { + Get-Median @($readbacks | ForEach-Object { + ($_.readbackMs | Measure-Object -Sum).Sum + }) + } else { $null } + bakeFrameMaxMs = Get-Median @($cached | ForEach-Object { $_.bakeTotalMs.max }) + dynamicDeadlineMissPercent = Get-Median @($dynamic | ForEach-Object { + 100.0 * $_.deadlineMisses / $_.frameCount + }) + staticDeadlineMissPercent = Get-Median @($cached | ForEach-Object { + 100.0 * $_.deadlineMisses / $_.frameCount + }) + } + } + } + $summary | ConvertTo-Json -Depth 5 | Set-Content (Join-Path $runDirectory 'summary.json') + $summary | Export-Csv -NoTypeInformation (Join-Path $runDirectory 'summary.csv') + $report = [System.Collections.Generic.List[string]]::new() + $report.Add('# Material cache measurements') + $report.Add('') + $report.Add("Label: $Label. Device: $Device. $Repetitions repeats per mode and case.") + $report.Add('Values are medians across runs; GPU reduction compares Dynamic with Static.') + $report.Add('') + $report.Add('| Material | Scene | GPU ms Dynamic / Static | Reduction | Frame P95 ms Dynamic / Static | PSS delta MiB | Bitmap MiB | Ready ms | Baking max frame ms |') + $report.Add('|---|---|---:|---:|---:|---:|---:|---:|---:|') + foreach ($row in $summary) { + $report.Add(( + '| {0} | {1} | {2:N2} / {3:N2} | {4:N1}% | {5:N2} / {6:N2} | {7:N2} | {8:N2} | {9:N1} | {10:N1} |' -f + $row.materialType, $row.scene, $row.dynamicGpuMedianMs, $row.staticGpuMedianMs, + $row.gpuTimeReductionPercent, $row.dynamicTotalP95Ms, $row.staticTotalP95Ms, + $row.pssIncreaseMiB, $row.bitmapMiB, $row.cacheReadyMs, $row.bakeFrameMaxMs + )) + } + $report.Add('') + $report.Add('Bitmap allocations and process PSS measure different things. Driver/GPU memory may be absent from emulator PSS; do not treat PSS delta as total graphics cost.') + $report.Add('Cache readiness includes frame scheduling and the settle delay. Readback latency is separate; baking frame metrics measure visible-frame cost. A longest frame is not the same as a readback duration.') + $report.Add('The bitmap budget excludes transient capture resources. Emulator/SwiftShader measurements do not predict device GPU performance.') + $report.Add('Raw frames, memory checkpoints and readback durations: samples.json. APK hashes, source revision and total frame count: environment.json. Correctness gate: correctness.log.') + $report | Set-Content (Join-Path $runDirectory 'REPORT.md') + if ($BaselineReport) { + $baselineEnvironment = Get-Content -Raw (Join-Path $BaselineReport 'environment.json') | + ConvertFrom-Json + if ($baselineEnvironment.testApkSha256 -ne $metadata.testApkSha256) { + throw 'Comparison requires the same instrumentation APK; raw current results are saved.' + } + $baselineRows = Get-Content -Raw (Join-Path $BaselineReport 'summary.json') | + ConvertFrom-Json + $comparison = foreach ($row in $summary) { + $before = $baselineRows | Where-Object { + $_.materialType -eq $row.materialType -and $_.scene -eq $row.scene + } + if (@($before).Count -ne 1) { + throw "No unique baseline for $($row.materialType)/$($row.scene)" + } + [pscustomobject][ordered]@{ + materialType = $row.materialType + scene = $row.scene + oldStaticGpuMs = $before.staticGpuMedianMs + newDynamicGpuMs = $row.dynamicGpuMedianMs + newStaticGpuMs = $row.staticGpuMedianMs + newGpuSavedVsDynamicPercent = $row.gpuTimeReductionPercent + staticGpuSavedVsOldPercent = 100 * ( + $before.staticGpuMedianMs - $row.staticGpuMedianMs + ) / $before.staticGpuMedianMs + newDynamicP95Ms = $row.dynamicTotalP95Ms + newStaticP95Ms = $row.staticTotalP95Ms + oldBitmapMiB = $before.bitmapMiB + newBitmapMiB = $row.bitmapMiB + newDynamicPssMiB = $row.dynamicPssMiB + newStaticPssMiB = $row.staticPssMiB + newPssDeltaMiB = $row.pssIncreaseMiB + oldReadyMs = $before.cacheReadyMs + newReadyMs = $row.cacheReadyMs + oldBakeFrameMaxMs = $before.bakeFrameMaxMs + newBakeFrameMaxMs = $row.bakeFrameMaxMs + newReadbackMs = $row.readbackTotalMs + } + } + $comparison | ConvertTo-Json -Depth 5 | + Set-Content (Join-Path $runDirectory 'comparison.json') + $comparison | Export-Csv -NoTypeInformation (Join-Path $runDirectory 'comparison.csv') + $comparisonReport = [System.Collections.Generic.List[string]]::new() + $comparisonReport.Add('# Material cache: before and after review fixes') + $comparisonReport.Add('') + $comparisonReport.Add("Baseline: $BaselineReport. Current: $runDirectory.") + $comparisonReport.Add("Both runs use the same instrumentation APK. Current run: $Repetitions repeats, $DurationMs ms per measurement. Check both environment.json files for APK hashes and protocol details.") + $comparisonReport.Add('') + $comparisonReport.Add('| Material | Scene | New GPU ms Dynamic / Static | GPU saved vs Dynamic | Old Static GPU ms | Static GPU saved vs old | New frame P95 ms Dynamic / Static |') + $comparisonReport.Add('|---|---|---:|---:|---:|---:|---:|') + foreach ($row in $comparison) { + $comparisonReport.Add(( + '| {0} | {1} | {2:N2} / {3:N2} | {4:N1}% | {5:N2} | {6:N1}% | {7:N2} / {8:N2} |' -f + $row.materialType, $row.scene, $row.newDynamicGpuMs, $row.newStaticGpuMs, + $row.newGpuSavedVsDynamicPercent, $row.oldStaticGpuMs, + $row.staticGpuSavedVsOldPercent, $row.newDynamicP95Ms, $row.newStaticP95Ms + )) + } + $comparisonReport.Add('') + $comparisonReport.Add('| Material | Scene | Bitmap MiB old / new | New PSS delta MiB vs Dynamic | Ready ms old / new | Baking max frame ms old / new | New readback ms |') + $comparisonReport.Add('|---|---|---:|---:|---:|---:|---:|') + foreach ($row in $comparison) { + $comparisonReport.Add(( + '| {0} | {1} | {2:N2} / {3:N2} | {4:N2} | {5:N1} / {6:N1} | {7:N1} / {8:N1} | {9:N1} |' -f + $row.materialType, $row.scene, $row.oldBitmapMiB, $row.newBitmapMiB, + $row.newPssDeltaMiB, $row.oldReadyMs, $row.newReadyMs, + $row.oldBakeFrameMaxMs, $row.newBakeFrameMaxMs, $row.newReadbackMs + )) + } + $comparisonReport.Add('') + $comparisonReport.Add('ARGB width and height are halved: retained bitmap bytes fall by 75%. The source bitmap budget is 16 MiB; transient capture and driver resources are outside this budget.') + $comparisonReport.Add('PSS is a separate process measurement. Emulator graphics accounting is incomplete; negative or small deltas are measurement variation, not evidence that cached images are free. Read samples.json for memory checkpoints and callback drops.') + $comparisonReport.Add('Ready latency includes the intentional size-settle delay. Readback is still synchronous on Android; the baking frame column exposes the remaining one-off cost. Frame maxima are medians of the per-run maxima.') + $comparisonReport.Add('Small gains and regressions may be noise. GPU-time reduction is not an equal FPS increase. SwiftShader results require confirmation on physical devices before drawing device-level conclusions.') + $comparisonReport | Set-Content (Join-Path $runDirectory 'COMPARISON.md') + } + $summary | Format-Table materialType, scene, gpuTimeReductionPercent, pssIncreaseMiB, + bitmapMiB, cacheReadyMs -AutoSize | Out-String | Write-Output + Write-Output "Results: $runDirectory" +} finally { + Pop-Location +} diff --git a/shared/src/commonMain/kotlin/com/moriafly/salt/ui/sample/ui/MainContent.kt b/shared/src/commonMain/kotlin/com/moriafly/salt/ui/sample/ui/MainContent.kt index 73b6c939..e4f06c38 100644 --- a/shared/src/commonMain/kotlin/com/moriafly/salt/ui/sample/ui/MainContent.kt +++ b/shared/src/commonMain/kotlin/com/moriafly/salt/ui/sample/ui/MainContent.kt @@ -56,6 +56,7 @@ private val navigatorConfig = SavedStateConfiguration { subclass(ScreenRoute.Dialog::class, ScreenRoute.Dialog.serializer()) subclass(ScreenRoute.Sheet::class, ScreenRoute.Sheet.serializer()) subclass(ScreenRoute.Material::class, ScreenRoute.Material.serializer()) + subclass(ScreenRoute.MaterialTest::class, ScreenRoute.MaterialTest.serializer()) } } } diff --git a/shared/src/commonMain/kotlin/com/moriafly/salt/ui/sample/ui/navigation/AppNavigation.kt b/shared/src/commonMain/kotlin/com/moriafly/salt/ui/sample/ui/navigation/AppNavigation.kt index d5f60967..679d5be7 100644 --- a/shared/src/commonMain/kotlin/com/moriafly/salt/ui/sample/ui/navigation/AppNavigation.kt +++ b/shared/src/commonMain/kotlin/com/moriafly/salt/ui/sample/ui/navigation/AppNavigation.kt @@ -40,6 +40,7 @@ import com.moriafly.salt.ui.sample.ui.screen.LicenseScreen import com.moriafly.salt.ui.sample.ui.screen.ListScreen import com.moriafly.salt.ui.sample.ui.screen.MainScreen import com.moriafly.salt.ui.sample.ui.screen.MaterialScreen +import com.moriafly.salt.ui.sample.ui.screen.MaterialTestScreen import com.moriafly.salt.ui.sample.ui.screen.SheetScreen @OptIn(UnstableSaltUiApi::class) @@ -132,6 +133,7 @@ fun AppNavigation( entry { DialogScreen() } entry { SheetScreen() } entry { MaterialScreen() } + entry { MaterialTestScreen() } } ) } diff --git a/shared/src/commonMain/kotlin/com/moriafly/salt/ui/sample/ui/navigation/ScreenRoute.kt b/shared/src/commonMain/kotlin/com/moriafly/salt/ui/sample/ui/navigation/ScreenRoute.kt index e3423426..8611d92f 100644 --- a/shared/src/commonMain/kotlin/com/moriafly/salt/ui/sample/ui/navigation/ScreenRoute.kt +++ b/shared/src/commonMain/kotlin/com/moriafly/salt/ui/sample/ui/navigation/ScreenRoute.kt @@ -47,4 +47,7 @@ sealed class ScreenRoute : NavKey { @Serializable data object Material : ScreenRoute() + + @Serializable + data object MaterialTest : ScreenRoute() } diff --git a/shared/src/commonMain/kotlin/com/moriafly/salt/ui/sample/ui/screen/MaterialScreen.kt b/shared/src/commonMain/kotlin/com/moriafly/salt/ui/sample/ui/screen/MaterialScreen.kt index 6dd82a36..853751be 100644 --- a/shared/src/commonMain/kotlin/com/moriafly/salt/ui/sample/ui/screen/MaterialScreen.kt +++ b/shared/src/commonMain/kotlin/com/moriafly/salt/ui/sample/ui/screen/MaterialScreen.kt @@ -1,13 +1,17 @@ package com.moriafly.salt.ui.sample.ui.screen import androidx.compose.runtime.Composable +import com.moriafly.salt.ui.Item import com.moriafly.salt.ui.ItemCheck import com.moriafly.salt.ui.ItemOuterTitle import com.moriafly.salt.ui.RoundedColumn -import com.moriafly.salt.ui.UnstableSaltUiApi +import com.moriafly.salt.ui.sample.ui.navigation.LocalSaltNavigator +import com.moriafly.salt.ui.sample.ui.navigation.ScreenRoute import com.moriafly.salt.ui.sample.ui.screen.basic.BasicScreenColumn import com.moriafly.salt.ui.sample.util.AppConfig import com.moriafly.salt.ui.screen.BasicScreenStyle +import com.moriafly.salt.ui.screen.ScreenCard +import com.moriafly.salt.ui.UnstableSaltUiApi @OptIn(UnstableSaltUiApi::class) @Composable @@ -15,6 +19,14 @@ fun MaterialScreen() { BasicScreenColumn( title = "Material" ) { + val navigator = LocalSaltNavigator.current + ScreenCard { + Item( + onClick = { navigator.navigate(ScreenRoute.MaterialTest) }, + text = "Material cache test", + sub = "Compare Dynamic and Static backgrounds" + ) + } ItemOuterTitle("Title Bar Backdrop Type") RoundedColumn { BasicScreenStyle.TitleBarBackdropType.supportedEntries().forEach { style -> diff --git a/shared/src/commonMain/kotlin/com/moriafly/salt/ui/sample/ui/screen/MaterialTestScreen.kt b/shared/src/commonMain/kotlin/com/moriafly/salt/ui/sample/ui/screen/MaterialTestScreen.kt new file mode 100644 index 00000000..628faeca --- /dev/null +++ b/shared/src/commonMain/kotlin/com/moriafly/salt/ui/sample/ui/screen/MaterialTestScreen.kt @@ -0,0 +1,352 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.moriafly.salt.ui.sample.ui.screen + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import com.moriafly.salt.ui.Button +import com.moriafly.salt.ui.ButtonAppearance +import com.moriafly.salt.ui.ControlSize +import com.moriafly.salt.ui.ItemButton +import com.moriafly.salt.ui.ItemDropdown +import com.moriafly.salt.ui.ItemSlider +import com.moriafly.salt.ui.ItemSwitcher +import com.moriafly.salt.ui.ItemValue +import com.moriafly.salt.ui.SaltMaterial +import com.moriafly.salt.ui.SaltTheme +import com.moriafly.salt.ui.Text +import com.moriafly.salt.ui.UnstableSaltUiApi +import com.moriafly.salt.ui.innerPadding +import com.moriafly.salt.ui.material.MaterialSource +import com.moriafly.salt.ui.material.MaterialSourceMode +import com.moriafly.salt.ui.material.MaterialType +import com.moriafly.salt.ui.material.material +import com.moriafly.salt.ui.material.subMaterial +import com.moriafly.salt.ui.popup.PopupMenuItem +import com.moriafly.salt.ui.sample.ui.screen.basic.BasicScreenBox +import com.moriafly.salt.ui.screen.ScreenCard +import kotlin.math.roundToInt +import kotlin.math.sin + +private enum class MaterialTestScene( + val widthFraction: Float, + val heightFraction: Float, + val panelCount: Int +) { + Small(0.42f, 0.4f, 1), + Large(0.8f, 0.78f, 1), + Shared(0.42f, 0.36f, 4) +} + +@OptIn(UnstableSaltUiApi::class) +@Composable +fun MaterialTestScreen( + modifier: Modifier = Modifier +) { + var materialType by rememberSaveable { mutableStateOf(MaterialType.BlurryGlass) } + var panelScene by rememberSaveable { mutableStateOf(MaterialTestScene.Large) } + var static by rememberSaveable { mutableStateOf(false) } + var dark by rememberSaveable { mutableStateOf(false) } + var animate by rememberSaveable { mutableStateOf(false) } + var wallpaper by rememberSaveable { mutableIntStateOf(0) } + var contentKey by rememberSaveable { mutableIntStateOf(0) } + var sourceHeight by rememberSaveable { mutableFloatStateOf(260f) } + BasicScreenBox( + modifier = modifier, + title = "Material test" + ) { contentPadding -> + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + ) { + val maximumHeight = maxHeight.value * 0.55f + val minimumHeight = minOf(240f, maximumHeight * 0.7f) + val previewHeight = sourceHeight.coerceIn(minimumHeight, maximumHeight) + Column( + modifier = Modifier + .fillMaxSize() + ) { + SaltTheme( + configs = SaltTheme.configs.copy(isDarkTheme = dark), + material = SaltMaterial(materialType) + ) { + MaterialTestPreview( + mode = if (static) { + MaterialSourceMode.Static(contentKey) + } else { + MaterialSourceMode.Dynamic + }, + scene = panelScene, + wallpaper = wallpaper, + animate = animate, + modifier = Modifier + .innerPadding() + .fillMaxWidth() + .height(previewHeight.dp) + ) + } + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + ) { + ScreenCard( + header = "Source", + footer = "Static reuses background pixels until the key or size changes." + ) { + ItemSwitcher( + state = static, + onChange = { static = it }, + text = "Static source", + sub = if (static) { + "Static key $contentKey" + } else { + "Dynamic" + } + ) + ItemValue(text = "Wallpaper", sub = "Version ${wallpaper + 1}") + ItemButton( + onClick = { + wallpaper++ + contentKey++ + }, + text = "Change wallpaper" + ) + ItemButton( + onClick = { contentKey++ }, + text = "Refresh cache", + enabled = static + ) + } + ScreenCard(header = "Appearance") { + ItemDropdown(text = "Material type", value = materialType.name) { + for (type in MaterialType.entries) { + if (type != MaterialType.None) { + PopupMenuItem( + onClick = { + materialType = type + state.dismiss() + }, + text = type.name, + selected = type == materialType + ) + } + } + } + ItemSwitcher( + state = dark, + onChange = { dark = it }, + text = "Dark preview" + ) + } + ScreenCard(header = "Scene") { + ItemDropdown(text = "Panel layout", value = panelScene.name) { + for (scene in MaterialTestScene.entries) { + PopupMenuItem( + onClick = { + panelScene = scene + state.dismiss() + }, + text = scene.name, + selected = scene == panelScene + ) + } + } + ItemSwitcher( + state = animate, + onChange = { animate = it }, + text = "Animate panels" + ) + ItemSlider( + value = previewHeight, + onValueChange = { sourceHeight = it }, + text = "Source height", + sub = "${previewHeight.roundToInt()} dp", + valueRange = minimumHeight..maximumHeight + ) + } + } + } + } + } +} + +@OptIn(UnstableSaltUiApi::class) +@Composable +private fun MaterialTestPreview( + mode: MaterialSourceMode, + scene: MaterialTestScene, + wallpaper: Int, + animate: Boolean, + modifier: Modifier = Modifier +) { + var taps by rememberSaveable { mutableIntStateOf(0) } + var phase by remember { mutableFloatStateOf(0f) } + LaunchedEffect(animate) { + phase = 0f + if (animate) { + val start = withFrameNanos { it } + while (true) { + withFrameNanos { + phase = sin((it - start) / 1_000_000_000.0 * 2).toFloat() + } + } + } + } + val shared = scene == MaterialTestScene.Shared + Layout( + modifier = modifier + .clip(SaltTheme.shapes.large) + .semantics { contentDescription = "Material preview" }, + content = { + MaterialSource( + modifier = Modifier + .fillMaxSize(), + materialSelf = false, + mode = mode + ) { + MaterialTestWallpaper( + version = wallpaper, + modifier = Modifier + .fillMaxSize() + ) + } + for (index in 0 until scene.panelCount) { + val subBackground = shared && index % 2 == 1 + val effect = if (subBackground) { + Modifier.subMaterial() + } else { + Modifier.material() + } + Column( + modifier = Modifier + .clip(SaltTheme.shapes.medium) + .then(effect) + .innerPadding(), + verticalArrangement = Arrangement.Center + ) { + if (index == 0) { + Button( + onClick = { taps++ }, + text = "Tap count: $taps", + appearance = ButtonAppearance.Subtle, + size = ControlSize.Small + ) + } else { + Text( + text = if (subBackground) { "Sub-background" } else { "Background" }, + style = SaltTheme.textStyles.sub + ) + } + } + } + } + ) { measurables, constraints -> + val width = constraints.maxWidth + val height = constraints.maxHeight + val background = measurables.first().measure(Constraints.fixed(width, height)) + val panelConstraints = Constraints.fixed( + width = (width * scene.widthFraction).roundToInt(), + height = (height * scene.heightFraction).roundToInt() + ) + val panels = List(scene.panelCount) { index -> + measurables[index + 1].measure(panelConstraints) + } + layout(width, height) { + background.placeRelative(0, 0) + val motion = phase * 0.02f + for (index in panels.indices) { + val x = if (shared) { + 0.04f + index % 2 * 0.5f + } else { + (1 - scene.widthFraction) / 2 + } + val y = if (shared) { + 0.07f + index / 2 * 0.49f + } else { + (1 - scene.heightFraction) / 2 + } + panels[index].placeRelative( + x = (width * (x + motion)).roundToInt(), + y = (height * (y + motion)).roundToInt() + ) + } + } + } +} + +@Composable +private fun MaterialTestWallpaper( + version: Int, + modifier: Modifier = Modifier +) { + val brush = remember(version) { + val colors = if (version % 2 == 0) { + listOf(Color(0xFFE44F70), Color(0xFF6546D7), Color(0xFF25C9BE)) + } else { + listOf(Color(0xFFF6AE37), Color(0xFFDF516C), Color(0xFF2B7BC8)) + } + Brush.linearGradient(colors) + } + Canvas(modifier = modifier) { + drawRect(brush = brush) + val cellWidth = size.width / 8 + val cellHeight = size.height / 6 + for (row in 0 until 6) { + for (column in 0 until 8) { + if ((row + column) % 2 == 0) { + drawRect( + color = Color.White.copy(alpha = 0.25f), + topLeft = Offset(column * cellWidth, row * cellHeight), + size = Size(cellWidth, cellHeight) + ) + } + } + } + } +} diff --git a/ui2/src/androidMain/kotlin/com/moriafly/salt/ui/material/MaterialAllocation.android.kt b/ui2/src/androidMain/kotlin/com/moriafly/salt/ui/material/MaterialAllocation.android.kt new file mode 100644 index 00000000..a5117d8f --- /dev/null +++ b/ui2/src/androidMain/kotlin/com/moriafly/salt/ui/material/MaterialAllocation.android.kt @@ -0,0 +1,22 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.moriafly.salt.ui.material + +internal actual fun isMaterialAllocationFailure( + failure: Error +): Boolean = failure is OutOfMemoryError diff --git a/ui2/src/commonMain/kotlin/com/moriafly/salt/ui/SaltTheme.kt b/ui2/src/commonMain/kotlin/com/moriafly/salt/ui/SaltTheme.kt index 1b52a66a..de8af24a 100644 --- a/ui2/src/commonMain/kotlin/com/moriafly/salt/ui/SaltTheme.kt +++ b/ui2/src/commonMain/kotlin/com/moriafly/salt/ui/SaltTheme.kt @@ -25,10 +25,10 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.remember import androidx.compose.runtime.staticCompositionLocalOf -import com.moriafly.salt.ui.material.LocalHazeState +import com.moriafly.salt.ui.material.LocalMaterialState +import com.moriafly.salt.ui.material.MaterialState import com.moriafly.salt.ui.material.MaterialType import com.moriafly.salt.ui.screen.BasicScreenStyle -import dev.chrisbanes.haze.HazeState /** * The main entry point for defining the theme. @@ -79,8 +79,8 @@ fun SaltTheme( content: @Composable () -> Unit ) { val materialType = material.type - val hazeState = remember(materialType) { - if (materialType != MaterialType.None) HazeState() else null + val materialState = remember(materialType) { + if (materialType != MaterialType.None) MaterialState() else null } val colors = if (configs.isDarkTheme) dynamicColors.dark else dynamicColors.light @@ -94,7 +94,7 @@ fun SaltTheme( LocalSaltShapes provides shapes, LocalSaltMaterial provides material, LocalBasicScreenStyle provides basicScreenStyle, - LocalHazeState provides hazeState + LocalMaterialState provides materialState ) { ProvideTextStyle( value = textStyles.main, diff --git a/ui2/src/commonMain/kotlin/com/moriafly/salt/ui/material/Material.kt b/ui2/src/commonMain/kotlin/com/moriafly/salt/ui/material/Material.kt index 127e48e5..35c3c0fc 100644 --- a/ui2/src/commonMain/kotlin/com/moriafly/salt/ui/material/Material.kt +++ b/ui2/src/commonMain/kotlin/com/moriafly/salt/ui/material/Material.kt @@ -25,17 +25,13 @@ import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import com.moriafly.salt.ui.SaltTheme import com.moriafly.salt.ui.UnstableSaltUiApi -import com.moriafly.salt.ui.thenIf -import dev.chrisbanes.haze.HazeInput -import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.blur.HazeBlurStyle -import dev.chrisbanes.haze.blur.hazeBlur -import dev.chrisbanes.haze.hazeSource /** * Material effect applied by [Modifier.material] and [Modifier.subMaterial]. @@ -84,6 +80,41 @@ enum class MaterialLayer { SubBackground } +/** + * Describes how the content of a [MaterialSource] changes over time. + */ +@UnstableSaltUiApi +@Immutable +sealed interface MaterialSourceMode { + /** + * The source content may change at any time. + */ + data object Dynamic : MaterialSourceMode + + /** + * Declares visually unchanged content for the same [key], layout, and rendering environment, + * allowing material pixels to be cached and reused by consumers. + * + * Represent every content change, including asynchronous image loading, with a new [key]. Keys + * are compared using [Any.equals]; use an immutable value with stable equality. [Unit] can be + * used for content without external changes. + * + * Prefer wallpaper images or content drawn with fixed colors. Static source content must not + * depend on changing theme colors such as [SaltTheme.colors] without including their resolved + * values in [key]. The same rule applies to custom CompositionLocals, text styles, shapes, and + * other visual inputs: their reads inside content are not observed by the pixel cache. A + * Compose [Color] with a fixed value is safe; a theme color can be used as the content key. + * + * Source size, density, layout direction, dark/light mode, and consumer material styles + * invalidate caches automatically. Palette changes within the same dark/light mode require a + * new content key. Results belong to this source instance and may be rebuilt. Source content + * continues to compose normally; rendering paths without cache support remain dynamic. + */ + data class Static( + val key: Any + ) : MaterialSourceMode +} + /** * Creates a Material background layer for implementing [Modifier.material] and * [Modifier.subMaterial]. @@ -96,23 +127,25 @@ enum class MaterialLayer { * * Using a Composable instead of a Modifier here is intentional - it encourages developers to * explicitly specify the hierarchy of MaterialSource and use it only once. + * + * @param mode Describes whether the source content changes dynamically or is static for a content + * key. Static sources allow material pixels to be baked and shared by consumers. Multiple sources + * in the same theme, consumers extending outside the source or using a different transform, and + * inherited custom Haze styles currently use the dynamic pipeline to preserve their appearance. */ @UnstableSaltUiApi @Composable fun MaterialSource( modifier: Modifier = Modifier, materialSelf: Boolean = true, + mode: MaterialSourceMode = MaterialSourceMode.Dynamic, content: @Composable BoxScope.() -> Unit ) { Box( modifier = modifier ) { - val hazeState = LocalHazeState.current - Box( - modifier = Modifier - .thenIf(hazeState != null) { - hazeSource(hazeState) - }, + MaterialSourceContent( + mode = mode, content = content ) @@ -126,6 +159,22 @@ fun MaterialSource( } } +@Deprecated("Maintained for binary compatibility", level = DeprecationLevel.HIDDEN) +@UnstableSaltUiApi +@Composable +fun MaterialSource( + modifier: Modifier = Modifier, + materialSelf: Boolean = true, + content: @Composable BoxScope.() -> Unit +) { + MaterialSource( + modifier = modifier, + materialSelf = materialSelf, + mode = MaterialSourceMode.Dynamic, + content = content + ) +} + /** * Disables material effects for child components, commonly used in Dialogs. */ @@ -135,7 +184,7 @@ fun DisableMaterial( content: @Composable () -> Unit ) { CompositionLocalProvider( - LocalHazeState provides null + LocalMaterialState provides null ) { content() } @@ -173,9 +222,9 @@ internal fun Modifier.basicMaterial( isDarkTheme: Boolean, fallback: Color ): Modifier { - val hazeState = LocalHazeState.current + val state = LocalMaterialState.current - return if (hazeState != null) { + return if (state != null) { val blurStyle = when (type) { MaterialType.None -> HazeBlurStyle MaterialType.BlurryGlass -> SaltHazeStyles.blurryGlass(layer, isDarkTheme) @@ -183,13 +232,10 @@ internal fun Modifier.basicMaterial( MaterialType.Mica -> SaltHazeStyles.mica(layer, isDarkTheme) MaterialType.Premium -> SaltHazeStyles.premium(layer, isDarkTheme) } - hazeBlur( - input = HazeInput.Sources(hazeState), - style = blurStyle - ) + materialEffect(state, blurStyle) } else { background(fallback) } } -internal val LocalHazeState = compositionLocalOf { null } +internal val LocalMaterialState = compositionLocalOf { null } diff --git a/ui2/src/commonMain/kotlin/com/moriafly/salt/ui/material/MaterialCache.kt b/ui2/src/commonMain/kotlin/com/moriafly/salt/ui/material/MaterialCache.kt new file mode 100644 index 00000000..368c75d2 --- /dev/null +++ b/ui2/src/commonMain/kotlin/com/moriafly/salt/ui/material/MaterialCache.kt @@ -0,0 +1,114 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(UnstableSaltUiApi::class) + +package com.moriafly.salt.ui.material + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import com.moriafly.salt.ui.SaltTheme +import com.moriafly.salt.ui.UnstableSaltUiApi +import dev.chrisbanes.haze.LocalHazePerformanceMode +import dev.chrisbanes.haze.blur.HazeBlurStyle +import dev.chrisbanes.haze.hazeSource + +@Composable +private fun MaterialState.rememberSource( + mode: MaterialSourceMode +): MaterialSourceState { + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + val isDarkTheme = SaltTheme.configs.isDarkTheme + // Equal declarations retain the source; supported environment changes invalidate its pixels. + // Content-specific dependencies (including palette values) belong in mode.Static.key. + // Entry sizes are tracked separately by the positioning callback. + return key(this, mode) { + val source = remember(density, layoutDirection, isDarkTheme) { + createSource(mode) + } + DisposableEffect(source) { + register(source) + onDispose { + unregister(source) + } + } + source + } +} + +@Composable +internal fun MaterialSourceState.rememberEntry( + style: HazeBlurStyle +): MaterialCacheEntry? { + val density = LocalDensity.current + val performanceMode = LocalHazePerformanceMode.current + return key( + this, + style, + performanceMode, + density, + size + ) { + var entry by remember { mutableStateOf(null) } + DisposableEffect(this) { + val acquired = acquire(style, performanceMode, density) + entry = acquired + onDispose { + if (acquired != null) { + release(acquired) + } + } + } + entry + } +} + +@Composable +internal fun BoxScope.MaterialSourceContent( + mode: MaterialSourceMode, + content: @Composable BoxScope.() -> Unit +) { + val state = LocalMaterialState.current + if (state == null) { + Box(content = content) + } else { + val source = state.rememberSource(mode) + + Box( + modifier = Modifier + .onGloballyPositioned(source.onPositioned) + .hazeSource(state.hazeState), + content = content + ) + MaterialCacheContent( + state = state, + source = source + ) + } +} diff --git a/ui2/src/commonMain/kotlin/com/moriafly/salt/ui/material/MaterialCacheCapture.kt b/ui2/src/commonMain/kotlin/com/moriafly/salt/ui/material/MaterialCacheCapture.kt new file mode 100644 index 00000000..ad5b0250 --- /dev/null +++ b/ui2/src/commonMain/kotlin/com/moriafly/salt/ui/material/MaterialCacheCapture.kt @@ -0,0 +1,156 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(UnstableSaltUiApi::class) + +package com.moriafly.salt.ui.material + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.graphics.rememberGraphicsLayer +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntSize +import com.moriafly.salt.ui.UnstableSaltUiApi +import dev.chrisbanes.haze.HazeInput +import dev.chrisbanes.haze.blur.HazeBlurStyle +import dev.chrisbanes.haze.blur.LocalHazeBlurStyle +import dev.chrisbanes.haze.blur.hazeBlur +import kotlin.time.TimeSource +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.delay + +private const val CACHE_SETTLE_MILLIS = 80L + +@Composable +internal fun BoxScope.MaterialCacheContent( + state: MaterialState, + source: MaterialSourceState +) { + val size = source.size + val hasSize = size.width > 0 && size.height > 0 + val canPrepare = source.mode is MaterialSourceMode.Static && + state.sources.singleOrNull() === source && hasSize + if (canPrepare) { + var settledSize by remember(source) { mutableStateOf(IntSize.Zero) } + LaunchedEffect(source, size) { + settledSize = IntSize.Zero + // Leave the first visible frames live and coalesce resize bursts before readback. + repeat(2) { withFrameNanos {} } + delay(CACHE_SETTLE_MILLIS) + settledSize = size + } + if (settledSize == size) { + // Keep each capture alive while other styles enter or leave. + for (entry in source.entries) { + key(entry) { + Box( + modifier = Modifier + .matchParentSize() + ) { + MaterialCacheCapture( + state = state, + entry = entry + ) + } + } + } + } + } +} + +@Composable +private fun MaterialCacheCapture( + state: MaterialState, + entry: MaterialCacheEntry +) { + if (entry.result == null) { + val layer = rememberGraphicsLayer() + // Drawing signals the coroutine without writing back to composition state. + val recorded = remember(layer) { CompletableDeferred() } + LaunchedEffect(state, entry, layer) { + recorded.await() + // Keep readback after the recording frame and its following render submission. + repeat(2) { withFrameNanos {} } + val started = TimeSource.Monotonic.markNow() + try { + val image = state.readback(layer) + entry.readbackNanos = started.elapsedNow().inWholeNanoseconds + entry.result = Result.success(image) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + // A failed readback uses the live pipeline until invalidated. + entry.result = Result.failure(failure) + } catch (failure: Error) { + if (!isMaterialAllocationFailure(failure)) { + throw failure + } + // This optional allocation must not turn a working live surface into a crash. + entry.result = Result.failure(failure) + } + } + CompositionLocalProvider( + LocalDensity provides entry.density, + LocalHazeBlurStyle provides HazeBlurStyle + ) { + val width = with(entry.density) { entry.size.width.toDp() } + val height = with(entry.density) { entry.size.height.toDp() } + Box( + modifier = Modifier + .requiredSize(width, height) + .drawWithContent { + if (!recorded.isCompleted) { + layer.record(size = entry.imageSize) { + scale( + scaleX = entry.imageSize.width.toFloat() / entry.size.width, + scaleY = entry.imageSize.height.toFloat() / entry.size.height, + pivot = Offset.Zero + ) { + this@drawWithContent.drawContent() + } + } + recorded.complete(Unit) + } + } + .hazeBlur( + input = HazeInput.Sources(state.hazeState), + style = entry.style, + performanceMode = entry.performanceMode + ) + ) + } + } +} + +internal expect fun isMaterialAllocationFailure( + failure: Error +): Boolean diff --git a/ui2/src/commonMain/kotlin/com/moriafly/salt/ui/material/MaterialCacheState.kt b/ui2/src/commonMain/kotlin/com/moriafly/salt/ui/material/MaterialCacheState.kt new file mode 100644 index 00000000..0f5d9b41 --- /dev/null +++ b/ui2/src/commonMain/kotlin/com/moriafly/salt/ui/material/MaterialCacheState.kt @@ -0,0 +1,155 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(UnstableSaltUiApi::class) + +package com.moriafly.salt.ui.material + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.neverEqualPolicy +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.layer.GraphicsLayer +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntSize +import com.moriafly.salt.ui.UnstableSaltUiApi +import dev.chrisbanes.haze.HazePerformanceMode +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.blur.HazeBlurStyle + +private const val MAX_CACHE_BYTES = 16L * 1024 * 1024 + +// UI-thread state only: composition owns registration/leases and the capture host owns GPU layers. +// Each source owns its entries and byte reservations; sources never share baked pixels. +internal class MaterialState( + private val cacheBudgetBytes: Long = MAX_CACHE_BYTES, + val readback: suspend (GraphicsLayer) -> ImageBitmap = { it.toImageBitmap() } +) { + val hazeState = HazeState() + private val sourceStates = mutableStateListOf() + + internal fun createSource(mode: MaterialSourceMode): MaterialSourceState = + MaterialSourceState(mode, cacheBudgetBytes) + + internal fun register(source: MaterialSourceState) { + sourceStates.add(source) + } + + internal fun unregister(source: MaterialSourceState) { + sourceStates.remove(source) + } + + val sources: List + get() = sourceStates.toList() +} + +internal class MaterialSourceState( + val mode: MaterialSourceMode, + private val cacheBudgetBytes: Long +) { + private val cachedEntries = mutableStateListOf() + val entries: List + get() = cachedEntries.toList() + var size by mutableStateOf(IntSize.Zero) + private set + + // Compose reuses coordinate objects; each actual positioning callback must notify consumers. + var coordinates by mutableStateOf(null, neverEqualPolicy()) + private set + + val onPositioned: (LayoutCoordinates) -> Unit = { + size = it.size + coordinates = it + } + + internal fun acquire( + style: HazeBlurStyle, + performanceMode: HazePerformanceMode, + density: Density + ): MaterialCacheEntry? { + cachedEntries.removeAll { it.size != size } + val existing = cachedEntries.firstOrNull { + it.style == style && it.performanceMode == performanceMode && it.density == density + } + if (existing != null) { + existing.consumers++ + return existing + } + if (size.width <= 0 || size.height <= 0) { + return null + } + + val imageSize = IntSize(maxOf(1, size.width / 2), maxOf(1, size.height / 2)) + val bytes = imageSize.width.toLong() * imageSize.height * 4 + if (bytes > cacheBudgetBytes) { + return null + } + // Prefer active surfaces. An unused image is evicted before admitting another variant. + if (cachedEntries.sumOf { it.bytes } + bytes > cacheBudgetBytes) { + cachedEntries.removeAll { it.consumers == 0 } + } + if (cachedEntries.sumOf { it.bytes } + bytes > cacheBudgetBytes) { + return null + } + + return MaterialCacheEntry( + style = style, + performanceMode = performanceMode, + density = density, + size = size, + imageSize = imageSize + ).also { + it.consumers = 1 + cachedEntries.add(it) + } + } + + internal fun release( + entry: MaterialCacheEntry + ) { + entry.consumers-- + if (entry.consumers != 0 || entry !in cachedEntries) { + return + } + if (entry.image == null) { + cachedEntries.remove(entry) + return + } + // Keep only the most recently released image, within the same total byte budget. + cachedEntries.removeAll { it !== entry && it.consumers == 0 } + } +} + +// An entry has identity: replacing it must also replace its capture coroutine and graphics layer. +// Eviction drops our image reference. Do not recycle pixels that an in-flight draw may still use. +internal class MaterialCacheEntry( + val style: HazeBlurStyle, + val performanceMode: HazePerformanceMode, + val density: Density, + val size: IntSize, + val imageSize: IntSize +) { + val bytes = imageSize.width.toLong() * imageSize.height * 4 + var result by mutableStateOf?>(null) + var consumers = 0 + var readbackNanos = 0L + val image: ImageBitmap? + get() = result?.getOrNull() +} diff --git a/ui2/src/commonMain/kotlin/com/moriafly/salt/ui/material/MaterialEffect.kt b/ui2/src/commonMain/kotlin/com/moriafly/salt/ui/material/MaterialEffect.kt new file mode 100644 index 00000000..e60fb037 --- /dev/null +++ b/ui2/src/commonMain/kotlin/com/moriafly/salt/ui/material/MaterialEffect.kt @@ -0,0 +1,145 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(UnstableSaltUiApi::class) + +package com.moriafly.salt.ui.material + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.neverEqualPolicy +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.structuralEqualityPolicy +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.findRootCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import com.moriafly.salt.ui.UnstableSaltUiApi +import dev.chrisbanes.haze.HazeInput +import dev.chrisbanes.haze.blur.HazeBlurStyle +import dev.chrisbanes.haze.blur.LocalHazeBlurStyle +import dev.chrisbanes.haze.blur.hazeBlur +import kotlin.math.abs + +private const val COORDINATE_TOLERANCE = 0.01f + +@Composable +internal fun Modifier.materialEffect( + state: MaterialState, + blurStyle: HazeBlurStyle +): Modifier { + val source = state.sources.singleOrNull() + // Multiple sources and opaque inherited styles cannot be baked independently without changing + // blending or consumer-sized masks. Keep their existing live rendering. + val hasCustomStyle = LocalHazeBlurStyle.current != HazeBlurStyle + return if (source?.mode is MaterialSourceMode.Static && !hasCustomStyle) { + var coordinates by remember { mutableStateOf(null, neverEqualPolicy()) } + val onPositioned = remember { { value: LayoutCoordinates -> coordinates = value } } + val positioned = onGloballyPositioned(onPositioned) + val eligible by remember(source) { + derivedStateOf(structuralEqualityPolicy()) { + val sourceCoordinates = source.coordinates + val consumerCoordinates = coordinates + sourceCoordinates != null && consumerCoordinates != null && + cacheOffset(sourceCoordinates, consumerCoordinates) != null + } + } + val entry = if (eligible) { + source.rememberEntry(blurStyle) + } else { + null + } + val image = entry?.image + if (entry != null && image != null) { + positioned.drawWithContent { + val sourceCoordinates = source.coordinates + val consumerCoordinates = coordinates + if (sourceCoordinates != null && consumerCoordinates != null) { + val offset = cacheOffset(sourceCoordinates, consumerCoordinates) + if (offset != null) { + clipRect { + translate(-offset.x, -offset.y) { + drawImage( + image = image, + dstSize = entry.size, + filterQuality = FilterQuality.Low + ) + } + } + } + } + drawContent() + } + } else { + positioned.liveMaterial(state, blurStyle) + } + } else { + liveMaterial(state, blurStyle) + } +} + +private fun Modifier.liveMaterial( + state: MaterialState, + style: HazeBlurStyle +): Modifier = hazeBlur( + input = HazeInput.Sources(state.hazeState), + style = style +) + +private fun cacheOffset( + source: LayoutCoordinates, + consumer: LayoutCoordinates +): Offset? { + if (!source.isAttached || !consumer.isAttached) { + return null + } + if (source.findRootCoordinates() !== consumer.findRootCoordinates()) { + return null + } + + val width = consumer.size.width.toFloat() + val height = consumer.size.height.toFloat() + val start = source.localPositionOf(consumer, Offset.Zero) + val right = source.localPositionOf(consumer, Offset(width, 0f)) + val bottom = source.localPositionOf(consumer, Offset(0f, height)) + val horizontal = right - start + val vertical = bottom - start + // A relative scale/rotation changes the blur radius; out-of-bounds consumers need uncached + // edge pixels. Both cases retain the live pipeline. + val tolerance = COORDINATE_TOLERANCE + val sameHorizontalAxis = abs(horizontal.x - width) < tolerance && abs(horizontal.y) < tolerance + val sameVerticalAxis = abs(vertical.x) < tolerance && abs(vertical.y - height) < tolerance + if (!sameHorizontalAxis || !sameVerticalAxis) { + return null + } + + val startsInside = start.x >= -tolerance && start.y >= -tolerance + val endsInside = right.x <= source.size.width + tolerance && + bottom.y <= source.size.height + tolerance + if (!startsInside || !endsInside) { + return null + } + return start +} diff --git a/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/material/MaterialAllocation.desktop.kt b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/material/MaterialAllocation.desktop.kt new file mode 100644 index 00000000..a5117d8f --- /dev/null +++ b/ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/material/MaterialAllocation.desktop.kt @@ -0,0 +1,22 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.moriafly.salt.ui.material + +internal actual fun isMaterialAllocationFailure( + failure: Error +): Boolean = failure is OutOfMemoryError diff --git a/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/material/MaterialCacheRenderingTest.kt b/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/material/MaterialCacheRenderingTest.kt new file mode 100644 index 00000000..bf9301da --- /dev/null +++ b/ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/material/MaterialCacheRenderingTest.kt @@ -0,0 +1,1067 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.moriafly.salt.ui.material + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.getValue +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.ComposeWindow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.v2.runComposeUiTest +import androidx.compose.ui.test.v2.runDesktopComposeUiTest +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.rememberWindowState +import com.moriafly.salt.ui.SaltConfigs +import com.moriafly.salt.ui.SaltColors +import com.moriafly.salt.ui.SaltDynamicColors +import com.moriafly.salt.ui.SaltMaterial +import com.moriafly.salt.ui.SaltTheme +import com.moriafly.salt.ui.UnstableSaltUiApi +import dev.chrisbanes.haze.blur.HazeBlurStyle +import dev.chrisbanes.haze.blur.LocalHazeBlurStyle +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.awaitCancellation +import java.awt.Rectangle +import java.awt.Robot +import java.awt.GraphicsEnvironment +import org.junit.Assume.assumeFalse +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertSame +import kotlin.test.assertTrue + +@OptIn(ExperimentalTestApi::class, UnstableSaltUiApi::class) +class MaterialCacheRenderingTest { + @Test + fun replacingSourceDuringReadbackCancelsTheOldCaptureAndDisplaysTheNewVersion() = runComposeUiTest { + var version by mutableStateOf(0) + var captures = 0 + val started = CompletableDeferred() + val cancelled = CompletableDeferred() + val materialState = MaterialState(readback = { layer -> + captures++ + if (captures == 1) { + started.complete(Unit) + try { + awaitCancellation() + } finally { + cancelled.complete(Unit) + } + } else { + layer.toImageBitmap() + } + }) + setContent { + SaltTheme( + configs = SaltConfigs.default(isDarkTheme = false), + material = SaltMaterial(MaterialType.BlurryGlass) + ) { + CompositionLocalProvider(LocalMaterialState provides materialState) { + Box(modifier = Modifier.size(160.dp)) { + MaterialSource(materialSelf = false, mode = MaterialSourceMode.Static(version)) { + val color = if (version == 0) { Color.Red } else { Color.Blue } + Box(modifier = Modifier.fillMaxSize().background(color)) + } + Box(modifier = Modifier.fillMaxSize().material().testTag("material")) + } + } + } + } + waitUntil(timeoutMillis = 5_000) { started.isCompleted } + val red = onNodeWithTag("material").captureToImage().toPixelMap()[80, 80] + assertTrue(red.red > red.blue + 0.1f, "Readback must leave the live surface visible: $red") + runOnIdle { version++ } + waitUntil(timeoutMillis = 5_000) { + cancelled.isCompleted && + materialState.sources.singleOrNull()?.entries?.any { it.image != null } == true + } + val blue = onNodeWithTag("material").captureToImage().toPixelMap()[80, 80] + assertTrue(blue.blue > blue.red + 0.1f, "The new version must replace the pending capture: $blue") + } + + @Test + fun inheritedHazeStyleUsesLiveRenderingAndClearingItRestoresCachedPixels() = runComposeUiTest { + var customStyle by mutableStateOf(false) + lateinit var materialState: MaterialState + setContent { + SaltTheme( + configs = SaltConfigs.default(isDarkTheme = false), + material = SaltMaterial(MaterialType.BlurryGlass) + ) { + materialState = LocalMaterialState.current!! + Box(modifier = Modifier.size(160.dp)) { + MaterialSource(materialSelf = false, mode = MaterialSourceMode.Static(Unit)) { + Box(modifier = Modifier.fillMaxSize().background(Color.Red)) + } + val style = if (customStyle) { HazeBlurStyle { alpha(0f) } } else { HazeBlurStyle } + CompositionLocalProvider(LocalHazeBlurStyle provides style) { + Box(modifier = Modifier.fillMaxSize().material().testTag("material")) + } + } + } + } + waitUntil(timeoutMillis = 5_000) { + materialState.sources.singleOrNull()?.entries?.any { it.image != null } == true + } + val cached = onNodeWithTag("material").captureToImage() + val original = runOnIdle { materialState.sources.single().entries.single().image } + runOnIdle { customStyle = true } + val transparent = onNodeWithTag("material").captureToImage().toPixelMap()[80, 80] + assertTrue( + transparent.red > 0.99f && transparent.blue < 0.01f, + "Inherited zero alpha must reveal the source: $transparent" + ) + runOnIdle { customStyle = false } + val restored = onNodeWithTag("material").captureToImage() + assertTrue(cached.averageDifference(restored) < 0.005f) + runOnIdle { assertSame(original, materialState.sources.single().entries.single().image) } + } + + @Test + fun multipleSourcesUseLiveRenderingAndRemovingOneRestoresTheCachedSource() = runComposeUiTest { + var showSecondSource by mutableStateOf(false) + var mode by mutableStateOf(MaterialSourceMode.Static(Unit)) + lateinit var materialState: MaterialState + setContent { + SaltTheme( + configs = SaltConfigs.default(isDarkTheme = false), + material = SaltMaterial(MaterialType.BlurryGlass) + ) { + materialState = LocalMaterialState.current!! + Box(modifier = Modifier.size(160.dp)) { + MaterialSource(materialSelf = false, mode = mode) { + Box(modifier = Modifier.fillMaxSize().background(Color.Red)) + } + if (showSecondSource) { + MaterialSource(materialSelf = false, mode = mode) { + Box(modifier = Modifier.fillMaxSize().background(Color.Blue)) + } + } + Box(modifier = Modifier.fillMaxSize().material().testTag("material")) + } + } + } + waitUntil(timeoutMillis = 5_000) { + materialState.sources.singleOrNull()?.entries?.any { it.image != null } == true + } + val cachedRed = onNodeWithTag("material").captureToImage() + runOnIdle { showSecondSource = true } + val fallback = onNodeWithTag("material").captureToImage() + val blue = fallback.toPixelMap()[80, 80] + assertTrue(blue.blue > blue.red + 0.1f, "The second source must reach the live effect: $blue") + runOnIdle { mode = MaterialSourceMode.Dynamic } + val dynamic = onNodeWithTag("material").captureToImage() + assertTrue(fallback.averageDifference(dynamic) < 0.015f) + + runOnIdle { + mode = MaterialSourceMode.Static(Unit) + showSecondSource = false + } + waitUntil(timeoutMillis = 5_000) { + materialState.sources.singleOrNull()?.entries?.any { it.image != null } == true + } + val restored = onNodeWithTag("material").captureToImage() + assertTrue(cachedRed.averageDifference(restored) < 0.005f) + } + + @Test + fun resolvedColorKeysRefreshCachedPixelsButEqualPalettesKeepThem() = runComposeUiTest { + var colors by mutableStateOf(SaltColors.defaultLight(highlight = Color.Red)) + var sourceColor by mutableStateOf(null) + lateinit var materialState: MaterialState + setContent { + SaltTheme( + configs = SaltConfigs.default(isDarkTheme = false), + dynamicColors = SaltDynamicColors.default(light = colors), + material = SaltMaterial(MaterialType.BlurryGlass) + ) { + materialState = LocalMaterialState.current!! + Box(modifier = Modifier.size(160.dp)) { + MaterialSource( + materialSelf = false, + mode = MaterialSourceMode.Static(SaltTheme.colors.highlight) + ) { + val color = sourceColor ?: SaltTheme.colors.highlight + Box(modifier = Modifier.fillMaxSize().background(color)) + } + Box(modifier = Modifier.fillMaxSize().material().testTag("material")) + } + } + } + waitUntil(timeoutMillis = 5_000) { + materialState.sources.singleOrNull()?.entries?.any { it.image != null } == true + } + val red = onNodeWithTag("material").captureToImage() + val original = runOnIdle { materialState.sources.single().entries.single().image } + + // Equal palette values must not accidentally turn an ordinary recomposition into a bake. + // Changing unversioned source pixels makes unwanted rebaking observable in the image. + runOnIdle { + sourceColor = Color.Green + colors = colors.copy() + } + val retained = onNodeWithTag("material").captureToImage() + assertTrue(red.averageDifference(retained) < 0.005f) + runOnIdle { + assertSame(original, materialState.sources.single().entries.single().image) + } + + runOnIdle { + sourceColor = null + colors = colors.copy(highlight = Color.Blue) + } + waitUntil(timeoutMillis = 5_000) { + materialState.sources.singleOrNull()?.entries?.any { + it.image != null && it.image !== original + } == true + } + val blue = onNodeWithTag("material").captureToImage().toPixelMap()[80, 80] + assertTrue(blue.blue > blue.red + 0.1f, "A resolved color key must refresh pixels: $blue") + } + + @Test + fun cachedBackgroundRendersInARealDesktopWindow() { + assumeFalse("Requires a real display", GraphicsEnvironment.isHeadless()) + runDesktopComposeUiTest { + var sourceColor by mutableStateOf(Color.Red) + var version by mutableStateOf(0) + lateinit var materialState: MaterialState + lateinit var nativeWindow: ComposeWindow + setContent { + Window( + onCloseRequest = {}, + title = "Material cache rendering test", + state = rememberWindowState(width = 240.dp, height = 180.dp), + undecorated = true, + alwaysOnTop = true + ) { + nativeWindow = window + SaltTheme(material = SaltMaterial(MaterialType.BlurryGlass)) { + materialState = LocalMaterialState.current!! + Box(modifier = Modifier.fillMaxSize()) { + MaterialSource( + materialSelf = false, + mode = MaterialSourceMode.Static(version) + ) { + Box(modifier = Modifier.fillMaxSize().background(sourceColor)) + } + Box(modifier = Modifier.fillMaxSize().material()) + } + } + } + } + waitUntil(timeoutMillis = 5_000) { + materialState.sources.singleOrNull()?.entries?.any { it.image != null } == true + } + val robot = Robot() + fun screenPixel(): Color { + val bounds = runOnIdle { + val location = nativeWindow.locationOnScreen + val centerX = location.x + nativeWindow.width / 2 + val centerY = location.y + nativeWindow.height / 2 + Rectangle(centerX - 4, centerY - 4, 8, 8) + } + val frame = robot.createScreenCapture(bounds) + return Color(frame.getRGB(4, 4)) + } + waitUntil(timeoutMillis = 5_000) { + val pixel = screenPixel() + pixel.red > pixel.blue + 0.1f + } + val original = screenPixel() + runOnIdle { sourceColor = Color.Blue } + waitForIdle() + val reused = screenPixel() + assertTrue( + abs(original.red - reused.red) < 0.02f && abs(original.blue - reused.blue) < 0.02f + ) + + runOnIdle { version++ } + waitUntil(timeoutMillis = 5_000) { + val pixel = screenPixel() + pixel.blue > pixel.red + 0.1f + } + } + } + + @Test + fun staticMaterialsMatchDynamicRendering() = runComposeUiTest { + var mode by mutableStateOf(MaterialSourceMode.Dynamic) + var type by mutableStateOf(MaterialType.BlurryGlass) + var dark by mutableStateOf(false) + var layer by mutableStateOf(MaterialLayer.Background) + lateinit var materialState: MaterialState + setContent { + SaltTheme( + configs = SaltConfigs.default(isDarkTheme = dark), + material = SaltMaterial(type) + ) { + materialState = LocalMaterialState.current!! + Box(modifier = Modifier.size(241.dp, 161.dp)) { + MaterialSource(materialSelf = false, mode = mode) { + Canvas(modifier = Modifier.fillMaxSize()) { + drawRect(Color.Red, size = Size(size.width / 2, size.height)) + drawRect( + Color.Blue, + topLeft = Offset(size.width / 2, 0f), + size = Size(size.width / 2, size.height) + ) + } + } + val material = when (layer) { + MaterialLayer.Background -> Modifier.material() + MaterialLayer.SubBackground -> Modifier.subMaterial() + } + Box(modifier = Modifier.fillMaxSize().testTag("material").then(material)) + } + } + } + + for (materialType in MaterialType.entries.filter { it != MaterialType.None }) { + for (isDark in listOf(false, true)) { + for (materialLayer in MaterialLayer.entries) { + runOnIdle { + mode = MaterialSourceMode.Dynamic + type = materialType + dark = isDark + layer = materialLayer + } + val dynamic = onNodeWithTag("material").captureToImage() + runOnIdle { mode = MaterialSourceMode.Static(Unit) } + waitUntil(timeoutMillis = 5_000) { + materialState.sources.singleOrNull()?.entries?.any { + it.image != null + } == true + } + val cached = onNodeWithTag("material").captureToImage() + val difference = dynamic.averageDifference(cached) + assertTrue( + difference < 0.015f, + "$materialType/$materialLayer/dark=$isDark cache difference: $difference" + ) + } + } + } + } + + @Test + fun contentKeyRebuildsBackgroundWithoutFreezingForeground() = runComposeUiTest { + var sourceColor by mutableStateOf(Color.Red) + var foreground by mutableStateOf(Color.Green) + var contentKey by mutableStateOf(Unit) + var sourceCompositions by mutableStateOf(0) + lateinit var materialState: MaterialState + setContent { + SaltTheme(material = SaltMaterial(MaterialType.BlurryGlass)) { + materialState = LocalMaterialState.current!! + Box(modifier = Modifier.size(160.dp)) { + MaterialSource( + modifier = Modifier.testTag("source-$sourceCompositions"), + materialSelf = false, + mode = MaterialSourceMode.Static(contentKey) + ) { + Box(modifier = Modifier.fillMaxSize().background(sourceColor)) + } + Box(modifier = Modifier.fillMaxSize().material().testTag("material")) { + Box( + modifier = Modifier + .size(24.dp) + .background(foreground) + .testTag("foreground") + ) + } + } + } + } + + waitUntil(timeoutMillis = 5_000) { + materialState.sources.singleOrNull()?.entries?.any { it.image != null } == true + } + val first = onNodeWithTag("material").captureToImage() + val originalBake = runOnIdle { materialState.sources.single().entries.single().image } + + // Deliberately change the raw source without advancing its declared version. This makes + // reuse visible and distinguishes the cached consumer from a live-rendering false positive. + runOnIdle { sourceColor = Color.Blue } + val reused = onNodeWithTag("material").captureToImage() + assertTrue( + first.averageDifference(reused) < 0.005f, + "An unchanged key must allow pixel reuse" + ) + + runOnIdle { + foreground = Color.Yellow + sourceCompositions++ + } + val updatedForeground = onNodeWithTag("foreground").captureToImage().toPixelMap()[8, 8] + assertTrue(updatedForeground.red > 0.9f && updatedForeground.green > 0.9f) + runOnIdle { + assertSame(originalBake, materialState.sources.single().entries.single().image) + contentKey = 1 + } + waitUntil(timeoutMillis = 5_000) { + materialState.sources.singleOrNull()?.entries?.any { + it.image != null && it.image !== originalBake + } == true + } + val updated = onNodeWithTag("material").captureToImage() + assertTrue( + first.averageDifference(updated) > 0.05f, + "A new content key must update the material" + ) + } + + @Test + fun consumersShareBakedPixelsAndDynamicModeRestoresLiveUpdates() = runComposeUiTest { + var mode by mutableStateOf(MaterialSourceMode.Static(Unit)) + var sourceColor by mutableStateOf(Color.Red) + lateinit var materialState: MaterialState + setContent { + SaltTheme(material = SaltMaterial(MaterialType.BlurryGlass)) { + materialState = LocalMaterialState.current!! + Box(modifier = Modifier.size(320.dp, 120.dp)) { + MaterialSource(materialSelf = false, mode = mode) { + Box(modifier = Modifier.fillMaxSize().background(sourceColor)) + } + Box(modifier = Modifier.size(80.dp).material().testTag("first")) + Box( + modifier = Modifier + .offset(x = 160.dp) + .size(80.dp) + .material() + .testTag("second") + ) + } + } + } + waitUntil(timeoutMillis = 5_000) { + materialState.sources.singleOrNull()?.entries?.singleOrNull()?.image != null + } + val first = onNodeWithTag("first").captureToImage() + val second = onNodeWithTag("second").captureToImage() + assertTrue( + first.averageDifference(second) < 0.005f, + "Both panels must render the same background" + ) + runOnIdle { + assertTrue(materialState.sources.single().entries.single().consumers == 2) + sourceColor = Color.Blue + mode = MaterialSourceMode.Dynamic + } + val liveFirst = onNodeWithTag("first").captureToImage() + val liveSecond = onNodeWithTag("second").captureToImage() + assertTrue( + first.averageDifference(liveFirst) > 0.05f, + "Dynamic mode must restore source updates" + ) + assertTrue(liveFirst.averageDifference(liveSecond) < 0.005f) + + // Re-enter with the same key after the old source registration has been disposed. + runOnIdle { mode = MaterialSourceMode.Static(Unit) } + waitUntil(timeoutMillis = 5_000) { + materialState.sources.singleOrNull()?.entries?.singleOrNull()?.image != null + } + val replacement = onNodeWithTag("first").captureToImage() + assertTrue( + liveFirst.averageDifference(replacement) < 0.005f, + "A new source must not reuse old pixels" + ) + } + + @Test + fun removingOneStyleKeepsOtherCachedPixelsAndReentryReusesItsImage() = runComposeUiTest { + var showBackground by mutableStateOf(true) + var sourceColor by mutableStateOf(Color.Red) + var version by mutableStateOf(0) + var mode by mutableStateOf(MaterialSourceMode.Dynamic) + lateinit var materialState: MaterialState + setContent { + SaltTheme( + configs = SaltConfigs.default(isDarkTheme = false), + material = SaltMaterial(MaterialType.BlurryGlass) + ) { + materialState = LocalMaterialState.current!! + Box( + modifier = Modifier.size(320.dp, 120.dp) + ) { + MaterialSource( + materialSelf = false, + mode = mode + ) { + Box( + modifier = Modifier.fillMaxSize().background(sourceColor) + ) + } + if (showBackground) { + Box( + modifier = Modifier.size(120.dp).material().testTag("background") + ) + } + Box( + modifier = Modifier + .offset(x = 160.dp) + .size(120.dp) + .subMaterial() + .testTag("subBackground") + ) + } + } + } + val liveRed = onNodeWithTag("subBackground").captureToImage() + runOnIdle { mode = MaterialSourceMode.Static(version) } + waitUntil(timeoutMillis = 5_000) { + materialState.sources.singleOrNull()?.entries?.count { it.image != null } == 2 + } + + // Changing the unversioned raw source makes an accidental recapture visible. + runOnIdle { sourceColor = Color.Blue } + val retainedBackground = onNodeWithTag("background").captureToImage() + runOnIdle { showBackground = false } + val retainedRed = onNodeWithTag("subBackground").captureToImage() + assertTrue( + liveRed.averageDifference(retainedRed) < 0.015f, + "Removing the first style must preserve the remaining style's capture" + ) + runOnIdle { showBackground = true } + waitUntil(timeoutMillis = 5_000) { + materialState.sources.singleOrNull()?.entries?.count { + it.image != null && it.consumers > 0 + } == 2 + } + val reenteredRed = onNodeWithTag("background").captureToImage() + val stillRed = onNodeWithTag("subBackground").captureToImage() + assertTrue(liveRed.averageDifference(stillRed) < 0.015f) + assertTrue(retainedBackground.averageDifference(reenteredRed) < 0.005f) + + runOnIdle { + version++ + mode = MaterialSourceMode.Static(version) + } + waitUntil(timeoutMillis = 5_000) { + materialState.sources.singleOrNull()?.entries?.count { it.image != null } == 2 + } + val versionedBlue = onNodeWithTag("subBackground").captureToImage() + val versionedBlueBackground = onNodeWithTag("background").captureToImage() + runOnIdle { mode = MaterialSourceMode.Dynamic } + val liveBlueBackground = onNodeWithTag("background").captureToImage() + val liveBlueSubBackground = onNodeWithTag("subBackground").captureToImage() + assertTrue(versionedBlueBackground.averageDifference(liveBlueBackground) < 0.015f) + assertTrue(versionedBlue.averageDifference(liveBlueSubBackground) < 0.015f) + assertTrue(liveRed.averageDifference(versionedBlue) > 0.05f) + } + + @Test + fun cachedConsumersFollowSourceCoordinatesAndBoundaries() = runComposeUiTest { + var mode by mutableStateOf(MaterialSourceMode.Dynamic) + var panelX by mutableStateOf(40.dp) + var type by mutableStateOf(MaterialType.BlurryGlass) + var layer by mutableStateOf(MaterialLayer.Background) + lateinit var materialState: MaterialState + setContent { + SaltTheme(material = SaltMaterial(type)) { + materialState = LocalMaterialState.current!! + Box(modifier = Modifier.size(440.dp, 160.dp).testTag("scene")) { + MaterialSource( + Modifier.offset(x = 40.dp).size(320.dp, 160.dp), + materialSelf = false, + mode = mode + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + drawRect(Color.Red, size = Size(size.width / 2, size.height)) + drawRect( + Color.Blue, + topLeft = Offset(size.width / 2, 0f), + size = Size(size.width / 2, size.height) + ) + } + } + Box( + modifier = Modifier + .offset(x = panelX, y = 32.dp) + .size(120.dp, 80.dp) + .then( + when (layer) { + MaterialLayer.Background -> Modifier.material() + MaterialLayer.SubBackground -> Modifier.subMaterial() + } + ) + .testTag("panel") + ) + } + } + } + + for (materialType in MaterialType.entries.filter { it != MaterialType.None }) { + for (materialLayer in MaterialLayer.entries) { + for (position in listOf(40.dp, 140.dp, 240.dp, 290.dp, 0.dp)) { + runOnIdle { + mode = MaterialSourceMode.Dynamic + type = materialType + layer = materialLayer + panelX = position + } + val dynamic = onNodeWithTag("panel").captureToImage() + val dynamicScene = onNodeWithTag("scene").captureToImage() + runOnIdle { mode = MaterialSourceMode.Static(Unit) } + waitForIdle() + waitUntil(timeoutMillis = 5_000) { + materialState.sources.singleOrNull()?.entries?.isEmpty() != false || + materialState.sources.singleOrNull()?.entries?.any { + it.image != null + } == true + } + val cached = onNodeWithTag("panel").captureToImage() + val cachedScene = onNodeWithTag("scene").captureToImage() + val difference = dynamic.averageDifference(cached) + assertTrue( + difference < 0.015f, + "$materialType/$materialLayer at $position cache difference: $difference" + ) + val sceneDifference = dynamicScene.averageDifference(cachedScene) + assertTrue( + sceneDifference < 0.015f, + "$materialType/$materialLayer at $position: $sceneDifference" + ) + } + } + } + } + + @Test + fun transformedConsumerFallsBackWhileStaticModeIsActive() = runComposeUiTest { + var mode by mutableStateOf(MaterialSourceMode.Static(Unit)) + var scale by mutableStateOf(1f) + var rotation by mutableStateOf(0f) + lateinit var materialState: MaterialState + setContent { + SaltTheme(material = SaltMaterial(MaterialType.BlurryGlass)) { + materialState = LocalMaterialState.current!! + Box(modifier = Modifier.size(320.dp, 200.dp)) { + MaterialSource(materialSelf = false, mode = mode) { + Canvas(modifier = Modifier.fillMaxSize()) { + drawRect(Color.Red, size = Size(size.width / 2, size.height)) + drawRect( + Color.Blue, + topLeft = Offset(size.width / 2, 0f), + size = Size(size.width / 2, size.height) + ) + } + } + Box( + modifier = Modifier + .offset(100.dp, 50.dp) + .size(120.dp, 80.dp) + .graphicsLayer { + scaleX = scale + scaleY = scale + rotationZ = rotation + } + .material() + .testTag("panel") + ) + } + } + } + waitUntil(timeoutMillis = 5_000) { + materialState.sources.singleOrNull()?.entries?.any { it.image != null } == true + } + for ((nextScale, nextRotation) in listOf(1.3f to 0f, 1f to 15f)) { + runOnIdle { + scale = nextScale + rotation = nextRotation + } + val transformedStatic = onNodeWithTag("panel").captureToImage() + runOnIdle { + assertTrue( + materialState.sources.single().entries.none { it.consumers > 0 }, + "Relative transforms must release the baked path" + ) + } + runOnIdle { mode = MaterialSourceMode.Dynamic } + val dynamic = onNodeWithTag("panel").captureToImage() + assertTrue( + transformedStatic.averageDifference(dynamic) < 0.015f, + "A transformed static-mode consumer must preserve live rendering" + ) + runOnIdle { mode = MaterialSourceMode.Static(Unit) } + } + } + + @Test + fun sourceSizeChangesRebuildCachedPixels() = runComposeUiTest { + var edge by mutableStateOf(160.dp) + lateinit var materialState: MaterialState + setContent { + SaltTheme(material = SaltMaterial(MaterialType.BlurryGlass)) { + materialState = LocalMaterialState.current!! + Box(modifier = Modifier.size(edge)) { + MaterialSource(materialSelf = false, mode = MaterialSourceMode.Static(Unit)) { + Canvas(modifier = Modifier.fillMaxSize()) { + val color = if (size.width < 200.dp.toPx()) { + Color.Red + } else { + Color.Blue + } + drawRect(color) + } + } + Box(modifier = Modifier.fillMaxSize().material().testTag("material")) + } + } + } + waitUntil(timeoutMillis = 5_000) { + materialState.sources.singleOrNull()?.entries?.any { it.image != null } == true + } + val before = onNodeWithTag("material").captureToImage().toPixelMap()[80, 80] + val originalBake = runOnIdle { materialState.sources.single().entries.single().image } + runOnIdle { edge = 240.dp } + waitUntil(timeoutMillis = 5_000) { + materialState.sources.singleOrNull()?.entries?.any { + it.size == materialState.sources.single().size && + it.image?.width == it.imageSize.width && + it.image?.height == it.imageSize.height && + it.image !== originalBake + } == true + } + val after = onNodeWithTag("material").captureToImage().toPixelMap()[120, 120] + assertTrue(before.red > before.blue + 0.1f, "The original source must be red: $before") + assertTrue( + after.blue > after.red + 0.1f, + "Resizing must render the new blue source: $after" + ) + } + + @Test + fun cacheAllocationFailuresPreserveLiveRendering() = runComposeUiTest { + var policy by mutableStateOf("budget") + var sourceColor by mutableStateOf(Color.Red) + lateinit var materialState: MaterialState + setContent { + SaltTheme( + configs = SaltConfigs.default(isDarkTheme = false), + material = SaltMaterial(MaterialType.BlurryGlass) + ) { + materialState = remember(policy) { + when (policy) { + "budget" -> MaterialState(cacheBudgetBytes = 1) + "oom" -> MaterialState(readback = { throw OutOfMemoryError("test") }) + else -> MaterialState(readback = { error("readback unavailable") }) + } + } + CompositionLocalProvider(LocalMaterialState provides materialState) { + Box(modifier = Modifier.size(160.dp)) { + MaterialSource( + materialSelf = false, + mode = MaterialSourceMode.Static(Unit) + ) { + Box(modifier = Modifier.fillMaxSize().background(sourceColor)) + } + Box( + modifier = Modifier.fillMaxSize().material().testTag("material") + ) + } + } + } + } + for (failure in listOf("budget", "oom", "exception")) { + runOnIdle { + policy = failure + sourceColor = Color.Red + } + if (failure != "budget") { + waitUntil(timeoutMillis = 5_000) { + materialState.sources.singleOrNull()?.entries?.any { + it.result?.isFailure == true + } == true + } + } + val red = onNodeWithTag("material").captureToImage().toPixelMap()[80, 80] + runOnIdle { sourceColor = Color.Blue } + val blue = onNodeWithTag("material").captureToImage().toPixelMap()[80, 80] + assertTrue(red.red > red.blue + 0.1f, "$failure must initially render red: $red") + assertTrue(blue.blue > blue.red + 0.1f, "$failure must keep live updates: $blue") + } + } + + @Test + fun theCacheBudgetKeepsActiveImagesAndEvictsAnIdleImageForAnotherStyle() = runComposeUiTest { + var showBackground by mutableStateOf(true) + var showSubBackground by mutableStateOf(true) + var sourceColor by mutableStateOf(Color.Red) + val materialState = MaterialState(cacheBudgetBytes = 30_000) + setContent { + SaltTheme( + configs = SaltConfigs.default(isDarkTheme = false), + material = SaltMaterial(MaterialType.BlurryGlass) + ) { + CompositionLocalProvider(LocalMaterialState provides materialState) { + Box(modifier = Modifier.size(160.dp)) { + MaterialSource( + materialSelf = false, + mode = MaterialSourceMode.Static(Unit) + ) { + Box(modifier = Modifier.fillMaxSize().background(sourceColor)) + } + if (showBackground) { + Box( + modifier = Modifier + .offset(x = 8.dp, y = 16.dp) + .size(56.dp) + .material() + .testTag("background") + ) + } + if (showSubBackground) { + Box( + modifier = Modifier + .offset(x = 88.dp, y = 16.dp) + .size(56.dp) + .subMaterial() + .testTag("subBackground") + ) + } + } + } + } + } + waitUntil(timeoutMillis = 5_000) { + materialState.sources.singleOrNull()?.entries?.singleOrNull()?.image != null + } + val redBackground = onNodeWithTag("background").captureToImage() + val redSubBackground = onNodeWithTag("subBackground").captureToImage() + runOnIdle { sourceColor = Color.Blue } + val retainedBackground = onNodeWithTag("background").captureToImage() + val liveBlueSubBackground = onNodeWithTag("subBackground").captureToImage() + assertTrue(redBackground.averageDifference(retainedBackground) < 0.005f) + assertTrue(redSubBackground.averageDifference(liveBlueSubBackground) > 0.05f) + + val original = runOnIdle { materialState.sources.single().entries.single().image } + runOnIdle { + showBackground = false + showSubBackground = false + } + runOnIdle { showSubBackground = true } + waitUntil(timeoutMillis = 5_000) { + materialState.sources.singleOrNull()?.entries?.singleOrNull()?.let { + it.image != null && it.image !== original + } == true + } + val cachedBlueSubBackground = onNodeWithTag("subBackground").captureToImage() + assertTrue(liveBlueSubBackground.averageDifference(cachedBlueSubBackground) < 0.015f) + runOnIdle { + showBackground = true + sourceColor = Color.Green + } + val liveGreen = onNodeWithTag("background").captureToImage().toPixelMap()[28, 28] + val stillBlueSubBackground = onNodeWithTag("subBackground").captureToImage() + assertTrue(liveGreen.green > liveGreen.blue + 0.1f) + assertTrue(cachedBlueSubBackground.averageDifference(stillBlueSubBackground) < 0.005f) + } + + @Test + fun sourceEnvironmentInvalidatesEvenWithAFixedConsumerTheme() = runComposeUiTest { + var dark by mutableStateOf(false) + var direction by mutableStateOf(LayoutDirection.Ltr) + lateinit var materialState: MaterialState + setContent { + SaltTheme( + configs = SaltConfigs.default(isDarkTheme = dark), + material = SaltMaterial(MaterialType.Mica) + ) { + materialState = LocalMaterialState.current!! + CompositionLocalProvider(LocalLayoutDirection provides direction) { + Box(modifier = Modifier.size(160.dp)) { + MaterialSource( + materialSelf = false, + mode = MaterialSourceMode.Static(Unit) + ) { + val color = when { + SaltTheme.configs.isDarkTheme -> Color.Green + LocalLayoutDirection.current == LayoutDirection.Rtl -> Color.Blue + else -> Color.Red + } + Box(modifier = Modifier.fillMaxSize().background(color)) + } + Box( + modifier = Modifier + .fillMaxSize() + .material(isDarkTheme = false) + .testTag("material") + ) + } + } + } + } + var previous: ImageBitmap? = null + for (step in 0..2) { + runOnIdle { + direction = if (step > 0) { + LayoutDirection.Rtl + } else { + LayoutDirection.Ltr + } + dark = step == 2 + } + waitUntil(timeoutMillis = 5_000) { + materialState.sources.singleOrNull()?.entries?.any { + it.image != null && it.image !== previous + } == true + } + val pixel = onNodeWithTag("material").captureToImage().toPixelMap()[80, 80] + when (step) { + 0 -> assertTrue(pixel.red > pixel.blue + 0.01f) + 1 -> assertTrue(pixel.blue > pixel.red + 0.01f) + 2 -> assertTrue(pixel.green > pixel.red + 0.01f) + } + previous = runOnIdle { materialState.sources.single().entries.single().image } + } + } + + @Test + fun resizingKeepsLivePixelsUntilTheFinalSizeIsSettled() = runComposeUiTest { + var edge by mutableStateOf(160.dp) + val capturedSizes = mutableListOf() + val materialState = MaterialState(readback = { + capturedSizes.add(it.size) + it.toImageBitmap() + }) + setContent { + SaltTheme( + configs = SaltConfigs.default(isDarkTheme = false), + material = SaltMaterial(MaterialType.BlurryGlass) + ) { + CompositionLocalProvider(LocalMaterialState provides materialState) { + Box(modifier = Modifier.size(edge)) { + MaterialSource( + materialSelf = false, + mode = MaterialSourceMode.Static(Unit) + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + val color = if (size.width < 200.dp.toPx()) { + Color.Red + } else { + Color.Blue + } + drawRect(color) + } + } + Box( + modifier = Modifier.fillMaxSize().material().testTag("material") + ) + } + } + } + } + waitUntil(timeoutMillis = 5_000) { + materialState.sources.singleOrNull()?.entries?.any { it.image != null } == true + } + val red = onNodeWithTag("material").captureToImage().toPixelMap()[80, 80] + mainClock.autoAdvance = false + for (step in 1..10) { + runOnIdle { edge = (160 + step * 8).dp } + mainClock.advanceTimeByFrame() + } + mainClock.autoAdvance = true + waitUntil(timeoutMillis = 5_000) { + materialState.sources.singleOrNull()?.entries?.any { + it.image != null && it.size == IntSize(240, 240) + } == true + } + val blue = onNodeWithTag("material").captureToImage().toPixelMap()[120, 120] + assertTrue(red.red > red.blue + 0.1f) + assertTrue(blue.blue > blue.red + 0.1f) + assertTrue( + capturedSizes.size == 2, + "A resize burst must not repeatedly bake: $capturedSizes" + ) + } + + @Test + fun staticMicaUpdatesWhenThemeChanges() = runComposeUiTest { + var dark by mutableStateOf(false) + lateinit var materialState: MaterialState + setContent { + SaltTheme( + configs = SaltConfigs.default(isDarkTheme = dark), + material = SaltMaterial(MaterialType.Mica) + ) { + materialState = LocalMaterialState.current!! + Box(modifier = Modifier.size(160.dp)) { + MaterialSource(mode = MaterialSourceMode.Static(Unit)) { + Box(modifier = Modifier.fillMaxSize().background(Color.Red)) + } + Box(modifier = Modifier.fillMaxSize().subMaterial().testTag("material")) + } + } + } + waitUntil(timeoutMillis = 5_000) { + materialState.sources.singleOrNull()?.entries?.count { it.image != null } == 2 + } + val light = onNodeWithTag("material").captureToImage().toPixelMap()[80, 80] + runOnIdle { dark = true } + waitUntil(timeoutMillis = 5_000) { + materialState.sources.singleOrNull()?.entries?.count { it.image != null } == 2 + } + val darkPixel = onNodeWithTag("material").captureToImage().toPixelMap()[80, 80] + assertTrue( + light.red + light.green + light.blue > 2.3f, + "Light Mica must remain light: $light" + ) + assertTrue( + darkPixel.red + darkPixel.green + darkPixel.blue < 0.6f, + "Dark Mica must rebuild with the theme: $darkPixel" + ) + } + + private fun ImageBitmap.averageDifference(other: ImageBitmap): Float { + val before = toPixelMap() + val after = other.toPixelMap() + var difference = 0f + for (y in 0 until height) { + for (x in 0 until width) { + val left = before[x, y] + val right = after[x, y] + difference += abs(left.red - right.red) + abs(left.green - right.green) + + abs(left.blue - right.blue) + abs(left.alpha - right.alpha) + } + } + return difference / (width * height * 4) + } +} diff --git a/ui2/src/iosMain/kotlin/com/moriafly/salt/ui/material/MaterialAllocation.ios.kt b/ui2/src/iosMain/kotlin/com/moriafly/salt/ui/material/MaterialAllocation.ios.kt new file mode 100644 index 00000000..986a42aa --- /dev/null +++ b/ui2/src/iosMain/kotlin/com/moriafly/salt/ui/material/MaterialAllocation.ios.kt @@ -0,0 +1,23 @@ +/* + * SaltUI + * Copyright (C) 2026 Moriafly + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.moriafly.salt.ui.material + +// Only JVM OutOfMemoryError is classified as a recoverable allocation failure. +internal actual fun isMaterialAllocationFailure( + failure: Error +): Boolean = false