diff --git a/compose/foundation/foundation/src/desktopMain/java/androidx/compose/foundation/text/BasicText.desktop.kt b/compose/foundation/foundation/src/desktopMain/java/androidx/compose/foundation/text/BasicText.desktop.kt new file mode 100644 index 0000000000000..e88fca2b1483e --- /dev/null +++ b/compose/foundation/foundation/src/desktopMain/java/androidx/compose/foundation/text/BasicText.desktop.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * 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 androidx.compose.foundation.text + +internal actual fun getDeviceAvailableCoreProcessors(): Int { + return Runtime.getRuntime().availableProcessors() +} \ No newline at end of file diff --git a/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/BasicText.ios.kt b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/BasicText.ios.kt new file mode 100644 index 0000000000000..b9ee22d6b00a8 --- /dev/null +++ b/compose/foundation/foundation/src/iosMain/kotlin/androidx/compose/foundation/text/BasicText.ios.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * 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 androidx.compose.foundation.text + +import platform.Foundation.NSProcessInfo + +internal actual fun getDeviceAvailableCoreProcessors(): Int = NSProcessInfo.processInfo.activeProcessorCount.toInt() \ No newline at end of file diff --git a/compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/text/BasicText.macos.kt b/compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/text/BasicText.macos.kt new file mode 100644 index 0000000000000..b9ee22d6b00a8 --- /dev/null +++ b/compose/foundation/foundation/src/macosMain/kotlin/androidx/compose/foundation/text/BasicText.macos.kt @@ -0,0 +1,21 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * 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 androidx.compose.foundation.text + +import platform.Foundation.NSProcessInfo + +internal actual fun getDeviceAvailableCoreProcessors(): Int = NSProcessInfo.processInfo.activeProcessorCount.toInt() \ No newline at end of file diff --git a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/BasicText.skiko.kt b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/BasicText.skiko.kt index 1d8b23ce61a85..a70abce14eb9f 100644 --- a/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/BasicText.skiko.kt +++ b/compose/foundation/foundation/src/skikoMain/kotlin/androidx/compose/foundation/text/BasicText.skiko.kt @@ -16,14 +16,25 @@ package androidx.compose.foundation.text +import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Composable import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.InternalComposeUiApi +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalPlatformBackgroundTextMeasurementExecutor import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.MultiParagraphIntrinsics +import androidx.compose.ui.text.ParagraphIntrinsics import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.resolveDefaults +import androidx.compose.ui.util.trace +import kotlinx.coroutines.Runnable -@Suppress("ComposableNaming") +@OptIn(InternalComposeUiApi::class) @Composable @NonRestartableComposable internal actual fun BackgroundTextMeasurement( @@ -32,10 +43,47 @@ internal actual fun BackgroundTextMeasurement( fontFamilyResolver: FontFamily.Resolver, softWrap: Boolean, ) { - // TODO: https://youtrack.jetbrains.com/issue/CMP-7818 + val executor = LocalPlatformBackgroundTextMeasurementExecutor.current + if (executor != null && shouldPrefetch(text.length)) { + val layoutDirection = LocalLayoutDirection.current + val density = LocalDensity.current + + try { + val task = Runnable { + trace("BackgroundTextMeasurement") { + Snapshot.withMutableSnapshot { + val resolvedStyle = resolveDefaults(style, layoutDirection) + val intrinsics = + ParagraphIntrinsics( + text = text, + style = resolvedStyle, + density = density, + fontFamilyResolver = fontFamilyResolver, + annotations = + emptyList>(), + placeholders = emptyList(), + softWrap = softWrap, + ) + // It is important that maxIntrinsicWidth is called before minIntrinsicWidth + // because the primary role of background text measurement is to warm the + // platform word layout cache. + + // maxIntrinsicWidth premeasures all words in the given text. This warms + // the platform word layout cache so that when the UI thread starts + // measuring the Text composable, the text layout would be faster. + intrinsics.maxIntrinsicWidth + // minIntrinsicWidth creates a BreakIterator which in turn initializes and + // caches an instance of BreakIteratorCache in `android.icu.text` + intrinsics.minIntrinsicWidth + } + } + } + executor.execute(task) + } catch (_: Exception) {} + } } -@Suppress("ComposableNaming") +@OptIn(InternalComposeUiApi::class) @Composable @NonRestartableComposable internal actual fun BackgroundTextMeasurement( @@ -45,5 +93,79 @@ internal actual fun BackgroundTextMeasurement( placeholders: List>?, softWrap: Boolean, ) { - // TODO: https://youtrack.jetbrains.com/issue/CMP-7818 + val executor = LocalPlatformBackgroundTextMeasurementExecutor.current + if (executor != null && shouldPrefetch(text.length)) { + val layoutDirection = LocalLayoutDirection.current + val density = LocalDensity.current + + try { + val task = Runnable { + trace("BackgroundTextMeasurement") { + Snapshot.withMutableSnapshot { + val resolvedStyle = resolveDefaults(style, layoutDirection) + val intrinsics = + MultiParagraphIntrinsics( + annotatedString = text, + style = resolvedStyle, + density = density, + placeholders = placeholders ?: emptyList(), + fontFamilyResolver = fontFamilyResolver, + softWrap = softWrap, + ) + // It is important that maxIntrinsicWidth is called before minIntrinsicWidth + // because the primary role of background text measurement is to warm the + // platform word layout cache. + + // maxIntrinsicWidth premeasures all words in the given text. This warms + // the platform word layout cache so that when the UI thread starts + // measuring the Text composable, the text layout would be faster. + intrinsics.maxIntrinsicWidth + // minIntrinsicWidth creates a BreakIterator which in turn initializes and + // caches an instance of BreakIteratorCache in `android.icu.text` + intrinsics.minIntrinsicWidth + } + } + } + executor.execute(task) + } catch (_: Exception) {} + } } + +/** + * The minimum number of CPU cores that should exist for us to consider attempting text prefetch. + */ +private const val PrefetchTextMinimumCoreCount = 4 + +/** + * Defines the shortest text length that can be considered for prefetching. Texts that are shorter + * than this number are usually not worth creating a threading overhead. + */ +private const val MinTextLengthThreshold = 8 + +/** + * Defines the longest text length that can be considered for prefetching. Texts that are longer + * than this number have a chance to flood the cache to cause overflow, essentially leading to + * double measurement that causes performance regression. + */ +private const val MaxTextLengthThreshold = 1000 + +/** Reading the core count is expensive. Do it once and cache it globally. */ +private var backingCoreCountSatisfactory: Boolean? = null + +@VisibleForTesting +internal val coreCountSatisfactory: Boolean + get() { + if (backingCoreCountSatisfactory == null) { + backingCoreCountSatisfactory = + getDeviceAvailableCoreProcessors() >= PrefetchTextMinimumCoreCount + } + return backingCoreCountSatisfactory!! + } + +internal fun shouldPrefetch(textLength: Int): Boolean { + return textLength >= MinTextLengthThreshold && + textLength < MaxTextLengthThreshold && + coreCountSatisfactory +} + +internal expect fun getDeviceAvailableCoreProcessors(): Int \ No newline at end of file diff --git a/compose/foundation/foundation/src/webMain/kotlin/androidx/compose/foundation/text/BasicText.web.kt b/compose/foundation/foundation/src/webMain/kotlin/androidx/compose/foundation/text/BasicText.web.kt new file mode 100644 index 0000000000000..30a78ecb8a236 --- /dev/null +++ b/compose/foundation/foundation/src/webMain/kotlin/androidx/compose/foundation/text/BasicText.web.kt @@ -0,0 +1,20 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * 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 androidx.compose.foundation.text + +//language=Js +internal actual fun getDeviceAvailableCoreProcessors(): Int = js("window.navigator.hardwareConcurrency") \ No newline at end of file diff --git a/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/PlatformBackgroundTextMeasurementExecutor.desktop.kt b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/PlatformBackgroundTextMeasurementExecutor.desktop.kt new file mode 100644 index 0000000000000..513b130f3cff2 --- /dev/null +++ b/compose/ui/ui/src/desktopMain/kotlin/androidx/compose/ui/platform/PlatformBackgroundTextMeasurementExecutor.desktop.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * 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 androidx.compose.ui.platform + +actual typealias PlatformBackgroundTextMeasurementExecutor = java.util.concurrent.Executor \ No newline at end of file diff --git a/compose/ui/ui/src/nonJvmMain/kotlin/androidx/compose/ui/platform/PlatformBackgroundTextMeasurementExecutor.nonJvm.kt b/compose/ui/ui/src/nonJvmMain/kotlin/androidx/compose/ui/platform/PlatformBackgroundTextMeasurementExecutor.nonJvm.kt new file mode 100644 index 0000000000000..48fcd4b9c3fd2 --- /dev/null +++ b/compose/ui/ui/src/nonJvmMain/kotlin/androidx/compose/ui/platform/PlatformBackgroundTextMeasurementExecutor.nonJvm.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * 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 androidx.compose.ui.platform + +import androidx.compose.ui.InternalComposeUiApi +import kotlinx.coroutines.Runnable + +@InternalComposeUiApi +actual interface PlatformBackgroundTextMeasurementExecutor { + actual fun execute(runnable: Runnable) +} \ No newline at end of file diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/CompositionLocals.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/CompositionLocals.skiko.kt index 0f0e953a810fe..ba437f5274d7f 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/CompositionLocals.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/CompositionLocals.skiko.kt @@ -68,6 +68,18 @@ val LocalPlatformPrefetchScheduler = staticCompositionLocalOf { + null +} + @OptIn(ExperimentalMediaQueryApi::class) @Composable internal fun ProvidePlatformCompositionLocals( @@ -105,6 +117,7 @@ internal fun ProvidePlatformCompositionLocals( LocalPlatformScreenReader provides platformContext.screenReader, LocalPlatformWindowInsets provides platformContext.windowInsets, LocalPlatformPrefetchScheduler provides platformContext.prefetchScheduler, + LocalPlatformBackgroundTextMeasurementExecutor provides platformContext.backgroundTextMeasurementExecutor, androidx.lifecycle.compose.LocalLifecycleOwner provides platformContext.architectureComponentsOwner.lifecycleOwner, LocalSavedStateRegistryOwner provides platformContext.architectureComponentsOwner.savedStateRegistryOwner, LocalSaveableStateRegistry provides saveableStateRegistry, diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/PlatformBackgroundTextMeasurementExecutor.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/PlatformBackgroundTextMeasurementExecutor.kt new file mode 100644 index 0000000000000..f1c35a5151a01 --- /dev/null +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/PlatformBackgroundTextMeasurementExecutor.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2026 The Android Open Source Project + * + * 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 androidx.compose.ui.platform + +import androidx.compose.ui.InternalComposeUiApi +import kotlinx.coroutines.Runnable + +@InternalComposeUiApi +expect interface PlatformBackgroundTextMeasurementExecutor { + fun execute(runnable: Runnable) +} \ No newline at end of file diff --git a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/PlatformContext.skiko.kt b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/PlatformContext.skiko.kt index 74318ced1baa8..cd6bd3229d9a0 100644 --- a/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/PlatformContext.skiko.kt +++ b/compose/ui/ui/src/skikoMain/kotlin/androidx/compose/ui/platform/PlatformContext.skiko.kt @@ -229,6 +229,13 @@ interface PlatformContext { val prefetchScheduler: PlatformPrefetchScheduler get() = NoOpPlatformPrefetchScheduler /** + * Executor used to perform background text measurement work. + * + * If non-null, text measurement may be offloaded to this executor to avoid + * blocking the UI thread. Default is `null` which indicates no platform + * provided background executor. + */ + val backgroundTextMeasurementExecutor: PlatformBackgroundTextMeasurementExecutor? get() = null * Media-related information exposed to the composition. * * This provides platform-specific environment details such as window posture,