blob: ad41e951d86d501d56188541f840862f7eab231a [file] [log] [blame]
/*
* Copyright 2019 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.ui.text
import androidx.annotation.RestrictTo
import androidx.annotation.VisibleForTesting
import androidx.ui.core.Constraints
import androidx.ui.core.Density
import androidx.ui.core.IntPxSize
import androidx.ui.core.LayoutDirection
import androidx.ui.core.PxPosition
import androidx.ui.core.Sp
import androidx.ui.core.constrain
import androidx.ui.core.px
import androidx.ui.core.round
import androidx.ui.core.sp
import androidx.ui.engine.geometry.Offset
import androidx.ui.engine.geometry.Rect
import androidx.ui.engine.geometry.Size
import androidx.ui.graphics.BlendMode
import androidx.ui.graphics.Canvas
import androidx.ui.graphics.Color
import androidx.ui.graphics.LinearGradientShader
import androidx.ui.graphics.Paint
import androidx.ui.graphics.Shader
import androidx.ui.text.font.Font
import androidx.ui.text.style.TextAlign
import androidx.ui.text.style.TextDirection
import androidx.ui.text.style.TextDirectionAlgorithm
import androidx.ui.text.style.TextOverflow
/** The default font size if none is specified. */
private val DefaultFontSize: Sp = 14.sp
/**
* Resolve text style to be able to pass to underlying paragraphs.
*
* We need to pass non-null font size to underlying paragraph.
*/
private fun resolveTextStyle(style: TextStyle?) =
if (style == null) {
TextStyle(fontSize = DefaultFontSize)
} else if (style.fontSize == null) {
style.copy(fontSize = DefaultFontSize)
} else {
style
}
/**
* An object that paints text onto a [Canvas].
*
* To use a [TextDelegate], follow these steps:
*
* 1. Create an [AnnotatedString] and pass it to the [TextDelegate] constructor.
*
* 2. Call [layout] to prepare the paragraph.
*
* 3. Call [paint] as often as desired to paint the paragraph.
*
* If the width of the area into which the text is being painted changes, return to step 2. If the
* text to be painted changes, return to step 1.
*
* @param text the text to paint.
*
* @param style The text style specified to render the text. Notice that you can also set text
* style on the given [AnnotatedString], and the style set on [text] always has higher priority
* than this setting. But if only one global text style is needed, passing it to [TextDelegate]
* is always preferred.
*
* @param paragraphStyle style configuration that applies only to paragraphs such as text alignment,
* or text direction.
*
* @param maxLines An optional maximum number of lines for the text to span, wrapping if
* necessary. If the text exceeds the given number of lines, it is truncated such that subsequent
* lines are dropped.
*
* @param softWrap Whether the text should break at soft line breaks. If false, the glyphs in the
* text will be positioned as if there was unlimited horizontal space. If [softWrap] is false,
* [overflow] and [TextAlign] may have unexpected effects.
*
* @param overflow How visual overflow should be handled. Specifically, the ellipsis is applied
* to the last line before the line truncated by [maxLines], if [maxLines] is non-null and that
* line overflows the width constraint.
*
* @param layoutDirection The composable layout direction.
*
* @hide
*/
@RestrictTo(RestrictTo.Scope.LIBRARY)
class TextDelegate(
val text: AnnotatedString,
style: TextStyle? = null,
paragraphStyle: ParagraphStyle? = null,
val maxLines: Int? = null,
val softWrap: Boolean = true,
val overflow: TextOverflow = TextOverflow.Clip,
val density: Density,
val layoutDirection: LayoutDirection,
val resourceLoader: Font.ResourceLoader
) {
/**
* The data class which holds text layout result.
*/
@VisibleForTesting
internal data class LayoutResult(
/**
* The multi paragraph object.
*
* The text layout is already computed.
*/
val multiParagraph: MultiParagraph,
/**
* The amount of space required to paint this text.
*/
val size: Size,
/**
* The minimum width provided while calculating this result.
*/
val minWidth: Float,
/**
* The maximum width provided while calculating this result.
*/
val maxWidth: Float
) {
private val didOverflowHeight: Boolean get() = multiParagraph.didExceedMaxLines
val didOverflowWidth: Boolean get() = size.width < multiParagraph.width
val hasVisualOverflow: Boolean get() = didOverflowWidth || didOverflowHeight
}
/**
* The resolved text style.
*/
val textStyle: TextStyle = resolveTextStyle(style)
/**
* The paragraph style.
*
* If null is passed to constructor, use default paragraph style.
*/
val paragraphStyle: ParagraphStyle = when {
paragraphStyle == null -> ParagraphStyle(
textDirectionAlgorithm = resolveTextDirectionAlgorithm(layoutDirection, null)
)
paragraphStyle.textDirectionAlgorithm == null -> paragraphStyle.copy(
textDirectionAlgorithm = resolveTextDirectionAlgorithm(layoutDirection, null)
)
else -> paragraphStyle
}
/**
* The text layout result. null if text layout is not computed.
*/
@VisibleForTesting
internal var layoutResult: LayoutResult? = null
private var overflowShader: Shader? = null
private inline fun <T> assumeLayout(block: (LayoutResult) -> T) =
block(layoutResult ?: throw AssertionError("layout must be called first"))
private inline fun <T> assumeIntrinsics(block: (MultiParagraphIntrinsics) -> T) =
block(paragraphIntrinsics
?: throw AssertionError("layoutForIntrinsics must be called first")
)
/**
* The width for text if all soft wrap opportunities were taken.
*
* Valid only after [layout] has been called.
*/
val minIntrinsicWidth: Float
get() = assumeIntrinsics { it.minIntrinsicWidth }
/**
* The width at which increasing the width of the text no longer decreases the height.
*
* Valid only after [layout] has been called.
*/
val maxIntrinsicWidth: Float
get() = assumeIntrinsics { it.maxIntrinsicWidth }
@VisibleForTesting
internal var paragraphIntrinsics: MultiParagraphIntrinsics? = null
/**
* The horizontal space required to paint this text.
*
* Valid only after [layout] has been called.
*/
val width: Float
get() = assumeLayout { it.size.width }
/**
* The vertical space from the top of text box to the baseline of the first line.
*
* Valid only after [layout] has been called.
*/
val firstBaseline: Float
get() = assumeLayout { it.multiParagraph.firstBaseline }
/**
* The vertical space from the top of text box to the baseline of the last line.
*
* Valid only after [layout] has been called.
*/
val lastBaseline: Float
get() = assumeLayout { it.multiParagraph.lastBaseline }
/**
* The vertical space required to paint this text.
*
* Valid only after [layout] has been called.
*/
val height: Float
get() = assumeLayout { it.size.height }
init {
assert(maxLines == null || maxLines > 0)
}
fun layoutIntrinsics(): MultiParagraphIntrinsics {
var intrinsics = paragraphIntrinsics ?: MultiParagraphIntrinsics(
annotatedString = text,
textStyle = textStyle,
paragraphStyle = paragraphStyle,
density = density,
resourceLoader = resourceLoader
)
paragraphIntrinsics = intrinsics
return intrinsics
}
/**
* Computes the visual position of the glyphs for painting the text.
*
* The text will layout with a width that's as close to its max intrinsic width as possible
* while still being greater than or equal to `minWidth` and less than or equal to `maxWidth`.
*/
private fun layoutText(minWidth: Float, maxWidth: Float): MultiParagraph {
val paragraphIntrinsics = layoutIntrinsics()
// if minWidth == maxWidth the width is fixed.
// therefore we can pass that value to our paragraph and use it
// if minWidth != maxWidth there is a range
// then we should check if the max intrinsic width is in this range to decide the
// width to be passed to Paragraph
// if max intrinsic width is between minWidth and maxWidth
// we can use it to layout
// else if max intrinsic width is greater than maxWidth, we can only use maxWidth
// else if max intrinsic width is less than minWidth, we should use minWidth
val width = if (minWidth == maxWidth) {
maxWidth
} else {
paragraphIntrinsics.maxIntrinsicWidth.coerceIn(minWidth, maxWidth)
}
return MultiParagraph(
intrinsics = paragraphIntrinsics,
maxLines = maxLines,
ellipsis = overflow == TextOverflow.Ellipsis,
constraints = ParagraphConstraints(width = width)
)
}
fun layout(constraints: Constraints) {
val minWidth = constraints.minWidth.value.toFloat()
val widthMatters = softWrap || overflow == TextOverflow.Ellipsis
val maxWidth = if (widthMatters) {
constraints.maxWidth.value.toFloat()
} else {
Float.POSITIVE_INFINITY
}
// If the layout result is the same one we computed before, just return the previous
// result.
layoutResult?.let {
if (it.minWidth == minWidth && it.maxWidth == maxWidth) return@layout
}
val multiParagraph = layoutText(minWidth, maxWidth)
val size = constraints.constrain(
IntPxSize(multiParagraph.width.px.round(), multiParagraph.height.px.round())
).let {
Size(it.width.value.toFloat(), it.height.value.toFloat())
}
layoutResult = LayoutResult(multiParagraph, size, minWidth, maxWidth).also {
overflowShader = createOverflowShader(it)
}
}
/**
* Paints the text onto the given canvas.
*
* Valid only after [layout] has been called.
*
* If you cannot see the text being painted, check that your text color does not conflict with
* the background on which you are drawing. The default text color is white (to contrast with
* the default black background color), so if you are writing an application with a white
* background, the text will not be visible by default.
*
* To set the text style, specify a [TextStyle] when creating the [TextSpan] that you pass to
* the [TextDelegate] constructor or to the [text] property.
*/
fun paint(canvas: Canvas) = assumeLayout { layoutResult ->
// Ideally we could compute the min/max intrinsic width/height with a
// non-destructive operation. However, currently, computing these values
// will destroy state inside the painter. If that happens, we need to
// get back the correct state by calling layout again.
//
// TODO(abarth): Make computing the min/max intrinsic width/height
// a non-destructive operation.
//
// If you remove this call, make sure that changing the textAlign still
// works properly.
// TODO(qqd): Need to figure out where this constraints come from and how to make
// it non-null. For now Compose Text version does not need to layout text again. Comment it.
// layoutTextWithConstraints(constraints!!)
if (layoutResult.hasVisualOverflow) {
val bounds = Rect.fromLTWH(0f, 0f, layoutResult.size.width, layoutResult.size.height)
if (overflowShader != null) {
// This layer limits what the shader below blends with to be just the text
// (as opposed to the text and its background).
canvas.saveLayer(bounds, Paint())
} else {
canvas.save()
}
canvas.clipRect(bounds)
}
layoutResult.multiParagraph.paint(canvas)
val size = layoutResult.size
if (layoutResult.hasVisualOverflow) {
if (overflowShader != null) {
val bounds = Rect.fromLTWH(0f, 0f, size.width, size.height)
val paint = Paint()
paint.blendMode = BlendMode.multiply
paint.shader = overflowShader
canvas.drawRect(bounds, paint)
}
canvas.restore()
}
}
/**
* Draws text background of the given range.
*
* If the given range is empty, do nothing.
*
* @param start inclusive start character offset of the drawing range.
* @param end exclusive end character offset of the drawing range.
* @param color a color to be used for drawing background.
* @param canvas the target canvas.
*/
fun paintBackground(
start: Int,
end: Int,
color: Color,
canvas: Canvas
) = assumeLayout { layoutResult ->
if (start == end) return
val selectionPath = layoutResult.multiParagraph.getPathForRange(start, end)
canvas.drawPath(selectionPath, Paint().apply { this.color = color })
}
/**
* Draws the cursor at the given character offset.
*
* @param offset the cursor offset in the text.
* @param canvas the target canvas.
*/
fun paintCursor(offset: Int, canvas: Canvas) = assumeLayout { layoutResult ->
val cursorRect = layoutResult.multiParagraph.getCursorRect(offset)
canvas.drawRect(cursorRect, Paint().apply { this.color = Color.Black })
}
/**
* Returns the bottom y coordinate of the given line.
*/
fun getLineBottom(lineIndex: Int): Float = assumeLayout { layoutResult ->
layoutResult.multiParagraph.getLineBottom(lineIndex)
}
/**
* Returns the line number on which the specified text offset appears.
* If you ask for a position before 0, you get 0; if you ask for a position
* beyond the end of the text, you get the last line.
*/
fun getLineForOffset(offset: Int): Int = assumeLayout { layoutResult ->
layoutResult.multiParagraph.getLineForOffset(offset)
}
/**
* Get the horizontal position for the specified text [offset].
* @see MultiParagraph.getHorizontalPosition
*/
fun getHorizontalPosition(offset: Int, usePrimaryDirection: Boolean): Float =
assumeLayout { layoutResult ->
layoutResult.multiParagraph.getHorizontalPosition(offset, usePrimaryDirection)
}
/**
* Get the text direction of the paragraph containing the given offset.
*/
fun getParagraphDirection(offset: Int): TextDirection = assumeLayout { layoutResult ->
layoutResult.multiParagraph.getParagraphDirection(offset)
}
/**
* Get the text direction of the character at the given offset.
*/
fun getBidiRunDirection(offset: Int): TextDirection = assumeLayout { layoutResult ->
layoutResult.multiParagraph.getBidiRunDirection(offset)
}
/** Returns the character offset closest to the given graphical position. */
fun getOffsetForPosition(position: PxPosition): Int = assumeLayout { layoutResult ->
layoutResult.multiParagraph.getOffsetForPosition(position)
}
/**
* Returns the bounding box as Rect of the character for given character offset. Rect includes
* the top, bottom, left and right of a character.
*
* Valid only after [layout] has been called.
*/
fun getBoundingBox(offset: Int): Rect = assumeLayout { layoutResult ->
layoutResult.multiParagraph.getBoundingBox(offset)
}
/**
* Returns the text range of the word at the given character offset. Characters not part of a
* word, such as spaces, symbols, and punctuation, have word breaks on both sides. In such
* cases, this method will return a text range that contains the given character offset.
*
* Word boundaries are defined more precisely in Unicode Standard Annex #29
* <http://www.unicode.org/reports/tr29/#Word_Boundaries>.
*/
fun getWordBoundary(offset: Int): TextRange = assumeLayout { layoutResult ->
layoutResult.multiParagraph.getWordBoundary(offset)
}
}
private fun TextDelegate.createOverflowShader(
layoutResult: TextDelegate.LayoutResult
): Shader? {
return if (layoutResult.hasVisualOverflow && overflow == TextOverflow.Fade) {
val paragraph = Paragraph(
text = "\u2026", // horizontal ellipsis
style = textStyle,
paragraphStyle = paragraphStyle,
textStyles = listOf(),
density = density,
resourceLoader = resourceLoader,
constraints = ParagraphConstraints(Float.POSITIVE_INFINITY)
)
val fadeWidth = paragraph.maxIntrinsicWidth
val fadeHeight = paragraph.height
if (layoutResult.didOverflowWidth) {
// FIXME: Should only fade the last line, i.e., should use last line's direction.
// (b/139496055)
val (fadeStart, fadeEnd) = if (layoutDirection == LayoutDirection.Rtl) {
Pair(fadeWidth, 0.0f)
} else {
Pair(layoutResult.size.width - fadeWidth, layoutResult.size.width)
}
LinearGradientShader(
Offset(fadeStart, 0.0f),
Offset(fadeEnd, 0.0f),
listOf(Color(0xFFFFFFFF), Color(0x00FFFFFF))
)
} else {
val fadeEnd = layoutResult.size.height
val fadeStart = fadeEnd - fadeHeight
LinearGradientShader(
Offset(0.0f, fadeStart),
Offset(0.0f, fadeEnd),
listOf(Color(0xFFFFFFFF), Color(0x00FFFFFF))
)
}
} else {
null
}
}
/**
* If [textDirectionAlgorithm] is null returns a [TextDirectionAlgorithm] based on
* [layoutDirection].
*/
@VisibleForTesting
internal fun resolveTextDirectionAlgorithm(
layoutDirection: LayoutDirection,
textDirectionAlgorithm: TextDirectionAlgorithm?
): TextDirectionAlgorithm {
return textDirectionAlgorithm
?: when (layoutDirection) {
LayoutDirection.Ltr -> TextDirectionAlgorithm.ContentOrLtr
LayoutDirection.Rtl -> TextDirectionAlgorithm.ContentOrRtl
}
}