diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextDecorationStyle.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextDecorationStyle.kt index 65294940ee5..45d7ed2032d 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextDecorationStyle.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/text/TextDecorationStyle.kt @@ -111,6 +111,26 @@ private fun drawDecorationLine( } } +/** + * The last offset [layout] can resolve a horizontal position for. Truncation (via `numberOfLines`) + * can leave the last line shorter than [Layout.getLineEnd] reports, since [layout] keeps the full + * untruncated text and only clips how much of it is laid out. When the last line ends in a tail + * ellipsis, anything from the ellipsis onward is unresolvable too; a leading/middle ellipsis + * doesn't shorten what's resolvable on that line, so it's left to [Layout.getLineEnd]. + */ +private fun visibleTextEnd(layout: Layout): Int { + val lastLine = layout.lineCount - 1 + val lineStart = layout.getLineStart(lastLine) + val lineEnd = layout.getLineEnd(lastLine) + val ellipsisStart = layout.getEllipsisStart(lastLine) + val ellipsisCount = layout.getEllipsisCount(lastLine) + return if (ellipsisCount > 0 && ellipsisStart + ellipsisCount == lineEnd - lineStart) { + lineStart + ellipsisStart + } else { + lineEnd + } +} + /** * Shared decoration drawing entry point used by [ReactUnderlineSpan] and [ReactStrikethroughSpan]. * Computes a density-aware stroke thickness, sets up a dedicated paint (to avoid mutating the @@ -128,6 +148,12 @@ internal fun drawSpannedDecoration( style: TextDecorationStyle, yOffsetForLine: (paint: Paint, baseline: Float, thickness: Float) -> Float, ) { + if (layout.lineCount == 0) return + val visibleEnd = visibleTextEnd(layout) + val clampedStart = min(start, visibleEnd) + val clampedEnd = min(end, visibleEnd) + if (clampedStart >= clampedEnd) return + val textPaint = layout.paint val effectiveColor = if (color != Color.TRANSPARENT) { @@ -174,13 +200,15 @@ internal fun drawSpannedDecoration( } } - val startLine = layout.getLineForOffset(start) - val endLine = layout.getLineForOffset(end) + val startLine = layout.getLineForOffset(clampedStart) + val endLine = layout.getLineForOffset(clampedEnd) for (line in startLine..endLine) { val baseline = layout.getLineBaseline(line).toFloat() val rawX1 = - if (line == startLine) layout.getPrimaryHorizontal(start) else layout.getLineLeft(line) - val rawX2 = if (line == endLine) layout.getPrimaryHorizontal(end) else layout.getLineRight(line) + if (line == startLine) layout.getPrimaryHorizontal(clampedStart) + else layout.getLineLeft(line) + val rawX2 = + if (line == endLine) layout.getPrimaryHorizontal(clampedEnd) else layout.getLineRight(line) // Normalize for RTL text where start may be to the right of end. val x1 = min(rawX1, rawX2) val x2 = max(rawX1, rawX2) diff --git a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextDecorationStyleTest.kt b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextDecorationStyleTest.kt index 10d95f696f1..ebf45d24eb6 100644 --- a/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextDecorationStyleTest.kt +++ b/packages/react-native/ReactAndroid/src/test/java/com/facebook/react/views/text/TextDecorationStyleTest.kt @@ -7,9 +7,34 @@ package com.facebook.react.views.text +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.os.Build +import android.text.Layout +import android.text.StaticLayout +import android.text.TextPaint +import android.text.TextUtils +import androidx.annotation.RequiresApi +import androidx.core.graphics.createBitmap import org.assertj.core.api.Assertions.assertThat import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.verifyNoInteractions +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.robolectric.annotation.TextLayoutMode +@RequiresApi(Build.VERSION_CODES.Q) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.UPSIDE_DOWN_CAKE, Build.VERSION_CODES.VANILLA_ICE_CREAM]) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@TextLayoutMode(TextLayoutMode.Mode.REALISTIC) class TextDecorationStyleTest { @Test fun fromStringSolid() { @@ -50,4 +75,140 @@ class TextDecorationStyleTest { fun fromStringEmptyDefaultsToSolid() { assertThat(TextDecorationStyle.fromString("")).isEqualTo(TextDecorationStyle.SOLID) } + + @Test + fun drawSpannedDecorationClampsSpanPastTailEllipsis() { + val layout = buildTailEllipsizedLayout() + val visibleEnd = layout.getLineStart(0) + layout.getEllipsisStart(0) + val baseline = layout.getLineBaseline(0).toFloat() + val x1 = layout.getPrimaryHorizontal(0) + val x2 = layout.getPrimaryHorizontal(visibleEnd) + val y = baseline + layout.paint.underlineThickness + 1f + val canvas = mock() + + drawSpannedDecoration( + 0, + TAIL_TEXT.length, + canvas, + layout, + Color.BLACK, + TextDecorationStyle.SOLID, + ) { _, lineBaseline, thickness -> + lineBaseline + thickness + 1f + } + + verify(canvas).drawLine(eq(x1), eq(y), eq(x2), eq(y), any()) + } + + @Test + fun drawSpannedDecorationSkipsSpanEntirelyPastTailEllipsis() { + val layout = buildTailEllipsizedLayout() + val visibleEnd = layout.getLineStart(0) + layout.getEllipsisStart(0) + + for (style in TextDecorationStyle.entries) { + val canvas = mock() + drawSpannedDecoration(visibleEnd, TAIL_TEXT.length, canvas, layout, Color.BLACK, style) { + _, + lineBaseline, + thickness -> + lineBaseline + thickness + 1f + } + verifyNoInteractions(canvas) + } + } + + @Test + fun skipsDecorationsOutsideTruncatedLayout() { + val text = + "Visible first line\n\nThird paragraph\n\nHidden decorated text: decorated text outside the one-line layout" + val layout = buildLayout(text, text.indexOf('\n')) + val start = text.indexOf("decorated text outside") + assertThat(layout.lineCount).isEqualTo(1) + assertThat(layout.text.length).isEqualTo(text.length) + assertThat(layout.getLineEnd(0)).isLessThan(start) + + for (style in TextDecorationStyle.entries) { + assertThat(drawDecoration(start, text.length, layout, style)).isZero() + } + } + + @Test + fun clipsDecorationsCrossingTruncatedLayout() { + val text = "Visible first line\nHidden decorated text" + val layout = buildLayout(text, text.indexOf('\n')) + assertThat(layout.getLineEnd(0)).isLessThan(text.length) + + for (style in TextDecorationStyle.entries) { + assertThat(drawDecoration(8, text.length, layout, style)).isEqualTo(1) + } + } + + @Test + fun keepsVisibleDecorations() { + val layout = buildLayout("Visible first line") + + for (style in TextDecorationStyle.entries) { + assertThat(drawDecoration(0, 7, layout, style)).isEqualTo(1) + } + } + + @Test + fun skipsEmptyDecorations() { + val layout = buildLayout("Visible first line") + assertThat(drawDecoration(0, 0, layout, TextDecorationStyle.SOLID)).isZero() + } + + @Test + fun skipsZeroLineLayout() { + val layout = mock() + assertThat(drawDecoration(0, 1, layout, TextDecorationStyle.SOLID)).isZero() + } + + @Test + fun keepsUntruncatedDecorations() { + val text = "Visible first line\nHidden decorated text" + val layout = buildLayout(text) + + for (style in TextDecorationStyle.entries) { + assertThat(drawDecoration(8, text.length, layout, style)).isEqualTo(2) + } + } + + private fun buildLayout(text: String, end: Int = text.length): StaticLayout = + StaticLayout.Builder.obtain(text, 0, end, TextPaint().apply { textSize = 20f }, 400).build() + + private fun drawDecoration( + start: Int, + end: Int, + layout: Layout, + style: TextDecorationStyle, + ): Int { + val canvas = Canvas(createBitmap(400, 200, Bitmap.Config.ARGB_8888)) + var drawnLines = 0 + drawSpannedDecoration(start, end, canvas, layout, Color.BLACK, style) { _, baseline, _ -> + drawnLines++ + baseline + } + return drawnLines + } + + /** + * A single line, tail-ellipsized right after "Hello" because the paragraph break in [TAIL_TEXT] + * hides everything after it once `maxLines` is reached. + */ + private fun buildTailEllipsizedLayout(): StaticLayout { + val paint = TextPaint().apply { textSize = 32f } + val layout = + StaticLayout.Builder.obtain(TAIL_TEXT, 0, TAIL_TEXT.length, paint, 400) + .setMaxLines(1) + .setEllipsize(TextUtils.TruncateAt.END) + .build() + assertThat(layout.lineCount).isEqualTo(1) + assertThat(layout.getEllipsisCount(0)).isGreaterThan(0) + return layout + } + + private companion object { + const val TAIL_TEXT = "Hello\ndecorated world" + } }