Skip to content

Commit 59933d2

Browse files
committed
Extract the Skiko text backend into the ui-skiko module
Move the Skia-backed implementation of ui-text into ui-skiko, leaving ui-text's nonAndroidMain Skia-free, mirroring the graphics extraction. ui-text keeps expect/actual delegators resolving a registered ComposeUiTextImplementation; the org.jetbrains.skia paragraph, font and text-paint code lives in ui-skiko. - Paragraph building, intrinsics, char helpers and the font subsystem (FontFamilyResolver, font loading, PlatformFont) relocate to ui-skiko. - The font subsystem is ui-text's own public API with per-platform loadTypeface actuals, so the unavoidable public Skia-typed surface stays in ui-text's skikoMain compat layer (Skia-free nonAndroidMain -> Skia skikoMain -> platform leaves); only the implementation moves. - ComposeUiSkikoRuntime.registerSkikoComposeImplementation() now wires both the graphics and text implementations (plus the temporary SkikoGraphicsCompat bridge). API baselines are regenerated in a later commit.
1 parent 785e275 commit 59933d2

57 files changed

Lines changed: 1978 additions & 1054 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

compose/ui/ui-text/src/desktopMain/kotlin/androidx/compose/ui/text/CharHelpers.jvm.kt renamed to compose/ui/ui-skiko/src/desktopMain/kotlin/androidx/compose/ui/text/CharHelpers.jvm.kt

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,6 @@
1515
*/
1616
package androidx.compose.ui.text
1717

18-
19-
/**
20-
* Get strong (R, L or AL) direction type.
21-
* See https://www.unicode.org/reports/tr9/
22-
*/
2318
internal actual fun CodePoint.strongDirectionType(): StrongDirectionType =
2419
when (getDirectionality()) {
2520
CharDirectionality.LEFT_TO_RIGHT -> StrongDirectionType.Ltr
@@ -29,6 +24,7 @@ internal actual fun CodePoint.strongDirectionType(): StrongDirectionType =
2924

3025
else -> StrongDirectionType.None
3126
}
27+
3228
internal actual fun CodePoint.isNeutralDirection(): Boolean =
3329
when (getDirectionality()) {
3430
CharDirectionality.OTHER_NEUTRALS,
@@ -41,8 +37,5 @@ internal actual fun CodePoint.isNeutralDirection(): Boolean =
4137
internal actual fun CodePoint.isNonSpacingMark(): Boolean =
4238
getDirectionality() == CharDirectionality.NONSPACING_MARK
4339

44-
/**
45-
* Get the Unicode directionality of a character.
46-
*/
4740
private fun CodePoint.getDirectionality(): CharDirectionality =
4841
CharDirectionality.valueOf(Character.getDirectionality(this).toInt())

compose/ui/ui-text/src/desktopMain/kotlin/androidx/compose/ui/text/platform/ParagraphLayouter.desktop.kt renamed to compose/ui/ui-skiko/src/desktopMain/kotlin/androidx/compose/ui/text/platform/ParagraphLayouter.desktop.kt

File renamed without changes.

compose/ui/ui-text/src/nativeMain/kotlin/androidx/compose/ui/text/platform/ParagraphLayouter.native.kt renamed to compose/ui/ui-skiko/src/nativeMain/kotlin/androidx/compose/ui/text/platform/ParagraphLayouter.native.kt

File renamed without changes.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Copyright 2026 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package androidx.compose.ui.platform
18+
19+
import androidx.compose.ui.InternalComposeUiApi
20+
import androidx.compose.ui.graphics.ComposeUiGraphicsImplementationRegistry
21+
import androidx.compose.ui.graphics.PlatformRenderEffect
22+
import androidx.compose.ui.graphics.SkikoGraphicsCompat
23+
import androidx.compose.ui.graphics.SkikoGraphicsCompatRegistry
24+
import androidx.compose.ui.graphics.SkikoGraphicsImplementation
25+
import androidx.compose.ui.graphics.skiaImageFilter
26+
import androidx.compose.ui.text.ComposeUiTextImplementationRegistry
27+
import androidx.compose.ui.text.SkikoTextImplementation
28+
import org.jetbrains.skia.ImageFilter
29+
30+
@OptIn(InternalComposeUiApi::class)
31+
@Suppress("DEPRECATION")
32+
@InternalComposeUiApi
33+
fun registerSkikoComposeImplementation() {
34+
ComposeUiGraphicsImplementationRegistry.register(SkikoGraphicsImplementation)
35+
ComposeUiTextImplementationRegistry.register(SkikoTextImplementation)
36+
SkikoGraphicsCompatRegistry.register(SkikoGraphicsCompatImpl)
37+
}
38+
39+
/**
40+
* Temporary bridge backing the deprecated Skia-returning members that cannot be relocated to this
41+
* module (see [SkikoGraphicsCompat]). Delegates to the proper `skiaImageFilter` extension.
42+
*/
43+
@OptIn(InternalComposeUiApi::class)
44+
@Suppress("DEPRECATION")
45+
private object SkikoGraphicsCompatImpl : SkikoGraphicsCompat {
46+
override fun imageFilter(renderEffect: PlatformRenderEffect): ImageFilter =
47+
renderEffect.skiaImageFilter
48+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
/*
2+
* Copyright 2026 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
@file:OptIn(InternalComposeUiApi::class)
18+
19+
package androidx.compose.ui.text
20+
21+
import androidx.compose.ui.InternalComposeUiApi
22+
import org.jetbrains.skia.Font as SkFont
23+
24+
/**
25+
* Access to the default Skia font behind a paragraph implementation. Kept here (in :ui-skiko) so the
26+
* skia type does not leak back into the skia-free ui-text `nonAndroidMain`. Primarily for tests.
27+
*/
28+
@InternalComposeUiApi
29+
val ComposeUiParagraphImplementation.skiaDefaultFont: SkFont
30+
get() = (this as PlatformParagraphImpl).defaultFont

compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/SkiaParagraph.skiko.kt renamed to compose/ui/ui-skiko/src/nonAndroidMain/kotlin/androidx/compose/ui/text/PlatformParagraphImpl.nonAndroid.kt

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,12 @@
1414
* limitations under the License.
1515
*/
1616

17+
@file:OptIn(InternalComposeUiApi::class)
18+
1719
package androidx.compose.ui.text
1820

1921
import org.jetbrains.skia.Rect as SkRect
22+
import androidx.compose.ui.InternalComposeUiApi
2023
import androidx.compose.ui.geometry.Offset
2124
import androidx.compose.ui.geometry.Rect
2225
import androidx.compose.ui.geometry.Size
@@ -30,8 +33,8 @@ import androidx.compose.ui.graphics.asComposePath
3033
import androidx.compose.ui.graphics.drawscope.DrawStyle
3134
import androidx.compose.ui.graphics.skiaCanvas
3235
import androidx.compose.ui.graphics.toComposeRect
33-
import androidx.compose.ui.text.internal.requirePrecondition
34-
import androidx.compose.ui.text.platform.SkiaParagraphIntrinsics
36+
import androidx.compose.ui.text.platform.PlatformParagraphIntrinsicsImpl
37+
import androidx.compose.ui.text.platform.requireParagraphPrecondition
3538
import androidx.compose.ui.text.platform.cursorHorizontalPosition
3639
import androidx.compose.ui.text.style.LineHeightStyle
3740
import androidx.compose.ui.text.style.ResolvedTextDirection
@@ -51,12 +54,12 @@ import org.jetbrains.skia.paragraph.RectWidthMode
5154
import org.jetbrains.skia.paragraph.TextBox
5255
import org.jetbrains.skia.paragraph.Paragraph as SkParagraph
5356

54-
internal class SkiaParagraph(
55-
private val paragraphIntrinsics: SkiaParagraphIntrinsics,
57+
internal class PlatformParagraphImpl(
58+
private val paragraphIntrinsics: PlatformParagraphIntrinsicsImpl,
5659
val maxLines: Int,
5760
private val overflow: TextOverflow,
5861
val constraints: Constraints
59-
) : Paragraph {
62+
) : ComposeUiParagraphImplementation {
6063
private val layouter = paragraphIntrinsics.layouter().apply {
6164
setParagraphStyle(maxLines, ellipsis)
6265
}
@@ -77,11 +80,11 @@ internal class SkiaParagraph(
7780
}
7881

7982
init {
80-
requirePrecondition(constraints.minHeight == 0 && constraints.minWidth == 0) {
83+
requireParagraphPrecondition(constraints.minHeight == 0 && constraints.minWidth == 0) {
8184
"Setting Constraints.minWidth and Constraints.minHeight is not supported, " +
8285
"these should be the default zero values instead."
8386
}
84-
requirePrecondition(maxLines >= 1) { "maxLines should be greater than 0" }
87+
requireParagraphPrecondition(maxLines >= 1) { "maxLines should be greater than 0" }
8588

8689
// Size is not known until layout is complete but to apply it, we need to re-create
8790
// skia's paragraph :'(
@@ -160,7 +163,7 @@ internal class SkiaParagraph(
160163
}
161164

162165
override fun getPathForRange(start: Int, end: Int): Path {
163-
requirePrecondition(start in 0..end && end <= text.length) {
166+
requireParagraphPrecondition(start in 0..end && end <= text.length) {
164167
"start($start) or end($end) is out of range [0..${text.length}]," +
165168
" or start > end!"
166169
}
@@ -184,7 +187,7 @@ internal class SkiaParagraph(
184187
val line = lineMetricsForOffset(offset)!!
185188

186189
// workaround for https://bugs.chromium.org/p/skia/issues/detail?id=11321 :(
187-
// Otherwise it shows a big cursor on a new empty line https://youtrack.jetbrains.com/issue/CMP-1895
190+
// Otherwise it shows a big cursor on a new empty line https://github.com/JetBrains/compose-jb/issues/1895
188191
val isNewEmptyLine = offset - 1 == line.startIndex && offset == text.length
189192
val metrics = defaultFont.metrics
190193

@@ -229,13 +232,13 @@ internal class SkiaParagraph(
229232
floor((line.baseline + line.descent).toFloat())
230233
} ?: 0f
231234

232-
internal fun getLineAscent(lineIndex: Int): Float =
235+
override fun getLineAscent(lineIndex: Int): Float =
233236
-(lineMetrics.getOrNull(lineIndex)?.ascent?.toFloat() ?: 0f)
234237

235238
override fun getLineBaseline(lineIndex: Int): Float =
236239
lineMetrics.getOrNull(lineIndex)?.baseline?.toFloat() ?: 0f
237240

238-
internal fun getLineDescent(lineIndex: Int): Float =
241+
override fun getLineDescent(lineIndex: Int): Float =
239242
lineMetrics.getOrNull(lineIndex)?.descent?.toFloat() ?: 0f
240243

241244
private fun lineMetricsForOffset(offset: Int): LineMetrics? =
@@ -440,8 +443,8 @@ internal class SkiaParagraph(
440443

441444
if (isNonSpacingMark) {
442445
// Find the boundaries of the complex character
443-
val precedingBreak = text.findPrecedingBreak(glyphPosition)
444-
val followingBreak = text.findFollowingBreak(glyphPosition)
446+
val precedingBreak = findSkikoPrecedingBreak(text, glyphPosition)
447+
val followingBreak = findSkikoFollowingBreak(text, glyphPosition)
445448

446449
// If we're inside a complex character, jump to the end of it
447450
if (precedingBreak != glyphPosition && followingBreak != glyphPosition) {
@@ -541,7 +544,7 @@ internal class SkiaParagraph(
541544
}
542545

543546
override fun getBoundingBox(offset: Int): Rect {
544-
requirePrecondition(offset in text.indices) {
547+
requireParagraphPrecondition(offset in text.indices) {
545548
"offset($offset) is out of bounds [0,${text.length})"
546549
}
547550
val box = getBoxForwardByOffset(offset) ?: getBoxBackwardByOffset(offset, text.length)!!
@@ -661,7 +664,7 @@ internal class SkiaParagraph(
661664
*/
662665
@Suppress("NOTHING_TO_INLINE")
663666
private inline fun checkOffsetIsValid(offset: Int) {
664-
requirePrecondition(offset in 0..text.length) {
667+
requireParagraphPrecondition(offset in 0..text.length) {
665668
"offset($offset) is out of bounds [0,${text.length}]"
666669
}
667670
}
@@ -673,6 +676,7 @@ private fun LineMetrics.trimFirstAscent(
673676
): LineMetrics {
674677
if (textStyle.lineHeight.isUnspecified) return this
675678
val style = textStyle.lineHeightStyle ?: LineHeightStyle.Default
679+
@Suppress("INVISIBLE_REFERENCE") // FIXME: Make [isTrimFirstLineTop] public
676680
val ascent = if (style.trim.isTrimFirstLineTop()) {
677681
-fontMetrics.ascent.toDouble()
678682
} else {
@@ -687,6 +691,7 @@ private fun LineMetrics.trimLastDescent(
687691
): LineMetrics {
688692
if (textStyle.lineHeight.isUnspecified) return this
689693
val style = textStyle.lineHeightStyle ?: LineHeightStyle.Default
694+
@Suppress("INVISIBLE_REFERENCE") // FIXME: Make [isTrimLastLineBottom] public
690695
val descent = if (style.trim.isTrimLastLineBottom()) {
691696
fontMetrics.descent.toDouble()
692697
} else {
@@ -725,7 +730,7 @@ private fun LineMetrics.copy(
725730
lineNumber = lineNumber
726731
)
727732

728-
private fun Paragraph.numberOfLinesThatFitMaxHeight(maxHeight: Int): Int {
733+
private fun PlatformParagraphImpl.numberOfLinesThatFitMaxHeight(maxHeight: Int): Int {
729734
for (lineIndex in 0 until lineCount) {
730735
if (getLineBottom(lineIndex) > maxHeight) return lineIndex
731736
}

compose/ui/ui-text/src/skikoMain/kotlin/androidx/compose/ui/text/CharHelpers.skiko.kt renamed to compose/ui/ui-skiko/src/nonAndroidMain/kotlin/androidx/compose/ui/text/SkikoCharHelpers.nonAndroid.kt

Lines changed: 8 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,16 @@ import kotlin.experimental.ExperimentalNativeApi
2020
import kotlin.jvm.JvmInline
2121
import org.jetbrains.skia.BreakIterator
2222

23-
internal actual fun String.findPrecedingBreak(index: Int): Int {
24-
val it = BreakIterator.makeCharacterInstance()
25-
it.setText(this)
26-
return it.preceding(index)
23+
internal fun findSkikoPrecedingBreak(text: String, index: Int): Int {
24+
val iterator = BreakIterator.makeCharacterInstance()
25+
iterator.setText(text)
26+
return iterator.preceding(index)
2727
}
2828

29-
internal actual fun String.findFollowingBreak(index: Int): Int {
30-
val it = BreakIterator.makeCharacterInstance()
31-
it.setText(this)
32-
return it.following(index)
29+
internal fun findSkikoFollowingBreak(text: String, index: Int): Int {
30+
val iterator = BreakIterator.makeCharacterInstance()
31+
iterator.setText(text)
32+
return iterator.following(index)
3333
}
3434

3535
/**
@@ -44,42 +44,24 @@ internal value class StrongDirectionType private constructor(val value: Int) {
4444
}
4545
}
4646

47-
// TODO Remove once it's available in common stdlib https://youtrack.jetbrains.com/issue/KT-23251
4847
internal typealias CodePoint = Int
4948

50-
/**
51-
* Converts a surrogate pair to a unicode code point.
52-
*/
5349
@OptIn(ExperimentalNativeApi::class)
5450
private fun Char.Companion.toCodePoint(high: Char, low: Char): CodePoint =
5551
(((high - MIN_HIGH_SURROGATE) shl 10) or (low - MIN_LOW_SURROGATE)) + MIN_SUPPLEMENTARY_CODE_POINT
5652

57-
/**
58-
* The minimum value of a supplementary code point, `\u0x10000`.
59-
*/
6053
private const val MIN_SUPPLEMENTARY_CODE_POINT: Int = 0x10000
61-
62-
/**
63-
* The maximum value of a Unicode code point.
64-
*/
6554
private const val MAX_CODE_POINT = 0X10FFFF
6655

6756
internal fun CodePoint.charCount(): Int = if (this >= MIN_SUPPLEMENTARY_CODE_POINT) 2 else 1
6857

69-
/**
70-
* Checks if the codepoint specified is a supplementary codepoint or not.
71-
*/
7258
internal fun CodePoint.isSupplementaryCodePoint(): Boolean =
7359
this in MIN_SUPPLEMENTARY_CODE_POINT..MAX_CODE_POINT
7460

7561
internal expect fun CodePoint.strongDirectionType(): StrongDirectionType
7662
internal expect fun CodePoint.isNeutralDirection(): Boolean
7763
internal expect fun CodePoint.isNonSpacingMark(): Boolean
7864

79-
/**
80-
* Determine direction based on the first strong directional character.
81-
* Only considers the characters outside isolate pairs.
82-
*/
8365
internal fun CharSequence.firstStrongDirectionType(): StrongDirectionType {
8466
for (codePoint in codePointsOutsideDirectionalIsolate) {
8567
return when (val strongDirectionType = codePoint.strongDirectionType()) {
@@ -90,16 +72,7 @@ internal fun CharSequence.firstStrongDirectionType(): StrongDirectionType {
9072
return StrongDirectionType.None
9173
}
9274

93-
/**
94-
* U+2066 LEFT-TO-RIGHT ISOLATE (LRI)
95-
* U+2067 RIGHT-TO-LEFT ISOLATE (RLI)
96-
* U+2068 FIRST STRONG ISOLATE (FSI)
97-
*/
9875
private val PUSH_DIRECTIONAL_ISOLATE_RANGE: IntRange = 0x2066..0x2068
99-
100-
/**
101-
* U+2069 POP DIRECTIONAL ISOLATE (PDI)
102-
*/
10376
private const val POP_DIRECTIONAL_ISOLATE_CODE_POINT: Int = 0x2069
10477

10578
private val CharSequence.codePointsOutsideDirectionalIsolate get() = sequence {
@@ -126,9 +99,6 @@ internal val CharSequence.codePoints get() = sequence {
12699
}
127100
}
128101

129-
/**
130-
* Returns the character (Unicode code point) at the specified index.
131-
*/
132102
internal fun CharSequence.codePointAt(index: Int): CodePoint {
133103
val high = this[index]
134104
if (high.isHighSurrogate() && index + 1 < this.length) {
@@ -140,9 +110,6 @@ internal fun CharSequence.codePointAt(index: Int): CodePoint {
140110
return high.code
141111
}
142112

143-
/**
144-
* Returns the character (Unicode code point) before the specified index.
145-
*/
146113
internal fun CharSequence.codePointBefore(index: Int): CodePoint {
147114
val low = this[index]
148115
if (low.isLowSurrogate() && index - 1 >= 0) {

0 commit comments

Comments
 (0)