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 65294940ee5c..95b4ee4e8e5b 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 @@ -174,13 +194,19 @@ internal fun drawSpannedDecoration( } } - val startLine = layout.getLineForOffset(start) - val endLine = layout.getLineForOffset(end) + val visibleEnd = visibleTextEnd(layout) + val clampedStart = min(start, visibleEnd) + val clampedEnd = min(end, visibleEnd) + + 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 10d95f696f1b..ab05a7f19143 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,21 @@ package com.facebook.react.views.text +import android.graphics.Canvas +import android.graphics.Color +import android.text.StaticLayout +import android.text.TextPaint +import android.text.TextUtils import org.assertj.core.api.Assertions.assertThat import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.robolectric.RobolectricTestRunner +@RunWith(RobolectricTestRunner::class) class TextDecorationStyleTest { @Test fun fromStringSolid() { @@ -50,4 +62,74 @@ 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 canvas = mock() + + // The span covers the whole string, well past what survived the ellipsis; pre-fix this + // called layout.getPrimaryHorizontal(TAIL_TEXT.length) and crashed with + // IndexOutOfBoundsException, since the ellipsized layout only resolves up to visibleEnd. + drawSpannedDecoration( + 0, + TAIL_TEXT.length, + canvas, + layout, + Color.BLACK, + TextDecorationStyle.SOLID, + ) { _, lineBaseline, thickness -> + lineBaseline + thickness + 1f + } + + verify(canvas).drawLine(eq(x1), eq(baseline + 1f), eq(x2), eq(baseline + 1f), any()) + } + + @Test + fun drawSpannedDecorationSkipsSpanEntirelyPastTailEllipsis() { + val layout = buildTailEllipsizedLayout() + val visibleEnd = layout.getLineStart(0) + layout.getEllipsisStart(0) + val baseline = layout.getLineBaseline(0).toFloat() + val x = layout.getPrimaryHorizontal(visibleEnd) + val canvas = mock() + + // The whole span (e.g. a nested Text) starts after the ellipsis, fully hidden: it must + // collapse to a zero-length line at the visible boundary, not draw anything past it. + drawSpannedDecoration( + visibleEnd, + TAIL_TEXT.length, + canvas, + layout, + Color.BLACK, + TextDecorationStyle.SOLID, + ) { _, lineBaseline, thickness -> + lineBaseline + thickness + 1f + } + + verify(canvas).drawLine(eq(x), eq(baseline + 1f), eq(x), eq(baseline + 1f), any()) + } + + /** + * 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" + } }