Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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<Canvas>()

// 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<Canvas>()

// 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"
}
}