From a86e8a4f26e4ce2f3e3c883c410105cf98edb76e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=8D=E8=A6=81=E7=B3=96=E9=86=8B=E6=94=BE=E6=A4=92?= =?UTF-8?q?=E7=9B=90?= <62319450+Moriafly@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:59:18 +0800 Subject: [PATCH] feat(material): cache versioned static backgrounds Add opt-in MaterialSourceMode.Static while preserving live rendering by default and the previous MaterialSource binary entry point. Share half-resolution material pixels between eligible consumers, reserve up to 16 MiB per source, retain at most one idle image, and fall back to Haze for unsupported geometry, multiple sources, inherited styles or readback errors. Separate source registration and consumer leases from cache state, offscreen capture and material drawing so invalidation and resource ownership are explicit. Keep content changes under immutable version keys: cover loading, image updates and resolved theme-dependent inputs must update the key; the cache does not enumerate theme palettes or observe arbitrary content reads. Add a navigable sample with cache, wallpaper, appearance and moving-panel controls, plus a non-debuggable Android benchmark variant and a script that exports raw frame timing, readback latency, memory and repeated comparisons. Validation: - 17 desktop material rendering tests passed, including native-window pixels, geometry boundaries, budgets, readback failures and cancellation during a source version change, and inherited-style fallback and cache reentry. - Four hardware-rendered Android tests passed on the API 36.1 emulator. - The Android sample navigation/control/recreation test passed with real input. - The benchmark script completed a Mica/Small Dynamic-versus-Static smoke run with one 1000 ms sample per mode and exported reports. Static GPU time was slightly higher in that emulator run; this is not evidence of a speedup. - Desktop sample compilation and ui2/shared iOS metadata compilation passed. iOS binaries and runtime rendering were not tested on this Windows host. - javap confirmed both old and new MaterialSource JVM signatures. - git diff --cached --check passed. --- README.md | 71 ++ androidApp/build.gradle.kts | 8 + .../ui/MaterialHardwareRenderingTest.kt | 549 +++++++++ .../sample/ui/MaterialPerformanceBenchmark.kt | 418 +++++++ .../ui/MaterialSampleInteractionTest.kt | 222 ++++ scripts/benchmark-material-cache.ps1 | 304 +++++ .../moriafly/salt/ui/sample/ui/MainContent.kt | 1 + .../ui/sample/ui/navigation/AppNavigation.kt | 2 + .../ui/sample/ui/navigation/ScreenRoute.kt | 3 + .../ui/sample/ui/screen/MaterialScreen.kt | 14 +- .../ui/sample/ui/screen/MaterialTestScreen.kt | 352 ++++++ .../ui/material/MaterialAllocation.android.kt | 22 + .../kotlin/com/moriafly/salt/ui/SaltTheme.kt | 10 +- .../com/moriafly/salt/ui/material/Material.kt | 84 +- .../salt/ui/material/MaterialCache.kt | 114 ++ .../salt/ui/material/MaterialCacheCapture.kt | 156 +++ .../salt/ui/material/MaterialCacheState.kt | 155 +++ .../salt/ui/material/MaterialEffect.kt | 145 +++ .../ui/material/MaterialAllocation.desktop.kt | 22 + .../ui/material/MaterialCacheRenderingTest.kt | 1067 +++++++++++++++++ .../ui/material/MaterialAllocation.ios.kt | 23 + 21 files changed, 3717 insertions(+), 25 deletions(-) create mode 100644 androidApp/src/androidTest/kotlin/com/moriafly/salt/sample/ui/MaterialHardwareRenderingTest.kt create mode 100644 androidApp/src/androidTest/kotlin/com/moriafly/salt/sample/ui/MaterialPerformanceBenchmark.kt create mode 100644 androidApp/src/androidTest/kotlin/com/moriafly/salt/sample/ui/MaterialSampleInteractionTest.kt create mode 100644 scripts/benchmark-material-cache.ps1 create mode 100644 shared/src/commonMain/kotlin/com/moriafly/salt/ui/sample/ui/screen/MaterialTestScreen.kt create mode 100644 ui2/src/androidMain/kotlin/com/moriafly/salt/ui/material/MaterialAllocation.android.kt create mode 100644 ui2/src/commonMain/kotlin/com/moriafly/salt/ui/material/MaterialCache.kt create mode 100644 ui2/src/commonMain/kotlin/com/moriafly/salt/ui/material/MaterialCacheCapture.kt create mode 100644 ui2/src/commonMain/kotlin/com/moriafly/salt/ui/material/MaterialCacheState.kt create mode 100644 ui2/src/commonMain/kotlin/com/moriafly/salt/ui/material/MaterialEffect.kt create mode 100644 ui2/src/desktopMain/kotlin/com/moriafly/salt/ui/material/MaterialAllocation.desktop.kt create mode 100644 ui2/src/desktopTest/kotlin/com/moriafly/salt/ui/material/MaterialCacheRenderingTest.kt create mode 100644 ui2/src/iosMain/kotlin/com/moriafly/salt/ui/material/MaterialAllocation.ios.kt 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