TextPainter.kt

/*
 * 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.compose.ui.text

import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Canvas
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.DrawTransform
import androidx.compose.ui.graphics.drawscope.withTransform
import androidx.compose.ui.graphics.isUnspecified
import androidx.compose.ui.graphics.takeOrElse
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.style.modulate
import androidx.compose.ui.unit.IntSize
import kotlin.math.ceil
import kotlin.math.roundToInt

object TextPainter {

    // TODO(b/236964276): Deprecate when TextMeasurer and drawText are no longer Experimental
    /**
     * Paints the text onto the given canvas.
     *
     * @param canvas a canvas to be drawn
     * @param textLayoutResult a result of text layout
     */
    @OptIn(ExperimentalTextApi::class)
    fun paint(canvas: Canvas, textLayoutResult: TextLayoutResult) {
        val needClipping = textLayoutResult.hasVisualOverflow &&
            textLayoutResult.layoutInput.overflow != TextOverflow.Visible
        if (needClipping) {
            val width = textLayoutResult.size.width.toFloat()
            val height = textLayoutResult.size.height.toFloat()
            val bounds = Rect(Offset.Zero, Size(width, height))
            canvas.save()
            canvas.clipRect(bounds)
        }
        try {
            val brush = textLayoutResult.layoutInput.style.brush
            if (brush != null) {
                textLayoutResult.multiParagraph.paint(
                    canvas,
                    brush,
                    textLayoutResult.layoutInput.style.alpha,
                    textLayoutResult.layoutInput.style.shadow,
                    textLayoutResult.layoutInput.style.textDecoration
                )
            } else {
                textLayoutResult.multiParagraph.paint(
                    canvas,
                    textLayoutResult.layoutInput.style.color,
                    textLayoutResult.layoutInput.style.shadow,
                    textLayoutResult.layoutInput.style.textDecoration
                )
            }
        } finally {
            if (needClipping) {
                canvas.restore()
            }
        }
    }
}

/**
 * Draw styled text using a TextMeasurer.
 *
 * This draw function supports multi-styling and async font loading.
 *
 * TextMeasurer carries an internal cache to optimize text layout measurement for repeated calls
 * in draw phase. If layout affecting attributes like font size, font weight, overflow, softWrap,
 * etc. are changed in consecutive calls to this method, TextMeasurer and its internal cache that
 * holds layout results may not offer any benefits. Check out [TextMeasurer] and drawText
 * overloads that take [TextLayoutResult] to learn more about text layout and draw phase
 * optimizations.
 *
 * @param textMeasurer Measures and lays out the text
 * @param text Text to be drawn
 * @param topLeft Offsets the text from top left point of the current coordinate system.
 * @param style the [TextStyle] to be applied to the text
 * @param overflow How visual overflow should be handled.
 * @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 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 will be truncated according to
 * [overflow] and [softWrap]. If it is not null, then it must be greater than zero.
 * @param placeholders a list of [Placeholder]s that specify ranges of text which will be
 * skipped during layout and replaced with [Placeholder]. It's required that the range of each
 * [Placeholder] doesn't cross paragraph boundary, otherwise [IllegalArgumentException] is
 * thrown.
 * @param size how wide and tall the text is allowed to be. [IntSize.width] will define the width
 * of the text. [IntSize.height] helps defining the number of lines that fit if [softWrap] is
 * enabled and [overflow] is [TextOverflow.Ellipsis]. Otherwise, [IntSize.height] either defines
 * where the text is clipped ([TextOverflow.Clip]) or becomes no-op.
 *
 * @see TextMeasurer
 */
@ExperimentalTextApi
fun DrawScope.drawText(
    textMeasurer: TextMeasurer,
    text: AnnotatedString,
    topLeft: Offset = Offset.Zero,
    style: TextStyle = TextStyle.Default,
    overflow: TextOverflow = TextOverflow.Clip,
    softWrap: Boolean = true,
    maxLines: Int = Int.MAX_VALUE,
    placeholders: List<AnnotatedString.Range<Placeholder>> = emptyList(),
    size: IntSize = IntSize(
        width = ceil(this.size.width).roundToInt(),
        height = ceil(this.size.height).roundToInt()
    )
) {
    val textLayoutResult = textMeasurer.measure(
        text = text,
        style = style,
        overflow = overflow,
        softWrap = softWrap,
        maxLines = maxLines,
        placeholders = placeholders,
        size = size,
        layoutDirection = layoutDirection,
        density = this
    )

    withTransform({
        translate(topLeft.x, topLeft.y)
        clip(textLayoutResult)
    }) {
        textLayoutResult.multiParagraph.paint(drawContext.canvas)
    }
}

/**
 * Draw text using a TextMeasurer.
 *
 * This draw function supports only one text style, and async font loading.
 *
 * TextMeasurer carries an internal cache to optimize text layout measurement for repeated calls
 * in draw phase. If layout affecting attributes like font size, font weight, overflow, softWrap,
 * etc. are changed in consecutive calls to this method, TextMeasurer and its internal cache that
 * holds layout results may not offer any benefits. Check out [TextMeasurer] and drawText overloads that take [TextLayoutResult] to learn
 * more about text layout and draw phase optimizations.
 *
 * @param textMeasurer Measures and lays out the text
 * @param text Text to be drawn
 * @param topLeft Offsets the text from top left point of the current coordinate system.
 * @param style the [TextStyle] to be applied to the text
 * @param overflow How visual overflow should be handled.
 * @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 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 will be truncated according to
 * [overflow] and [softWrap]. If it is not null, then it must be greater than zero.
 * @param size how wide and tall the text is allowed to be. [IntSize.width] will define the width
 * of the text. [IntSize.height] helps defining the number of lines that fit if [softWrap] is
 * enabled and [overflow] is [TextOverflow.Ellipsis]. Otherwise, [IntSize.height] either defines
 * where the text is clipped ([TextOverflow.Clip]) or becomes no-op.
 *
 * @see TextMeasurer
 */
@ExperimentalTextApi
fun DrawScope.drawText(
    textMeasurer: TextMeasurer,
    text: String,
    topLeft: Offset = Offset.Zero,
    style: TextStyle = TextStyle.Default,
    overflow: TextOverflow = TextOverflow.Clip,
    softWrap: Boolean = true,
    maxLines: Int = Int.MAX_VALUE,
    size: IntSize = IntSize(
        width = ceil(this.size.width).roundToInt(),
        height = ceil(this.size.height).roundToInt()
    )
) {
    val textLayoutResult = textMeasurer.measure(
        text = AnnotatedString(text),
        style = style,
        overflow = overflow,
        softWrap = softWrap,
        maxLines = maxLines,
        size = size,
        layoutDirection = layoutDirection,
        density = this
    )

    withTransform({
        translate(topLeft.x, topLeft.y)
        clip(textLayoutResult)
    }) {
        textLayoutResult.multiParagraph.paint(drawContext.canvas)
    }
}

/**
 * Draw an existing text layout as produced by [TextMeasurer].
 *
 * This draw function cannot relayout when async font loading resolves. If using async fonts or
 * other dynamic text layout, you are responsible for invalidating layout on changes.
 *
 * @param textLayoutResult Text Layout to be drawn
 * @param color Text color to use
 * @param topLeft Offsets the text from top left point of the current coordinate system.
 * @param alpha opacity to be applied to the [color] from 0.0f to 1.0f representing fully
 * transparent to fully opaque respectively
 * @param shadow The shadow effect applied on the text.
 * @param textDecoration The decorations to paint on the text (e.g., an underline).
 *
 * @sample androidx.compose.ui.text.samples.DrawTextLayoutResultSample
 */
@ExperimentalTextApi
fun DrawScope.drawText(
    textLayoutResult: TextLayoutResult,
    color: Color = Color.Unspecified,
    topLeft: Offset = Offset.Zero,
    alpha: Float = Float.NaN,
    shadow: Shadow? = null,
    textDecoration: TextDecoration? = null
) {
    val newShadow = shadow ?: textLayoutResult.layoutInput.style.shadow
    val newTextDecoration = textDecoration ?: textLayoutResult.layoutInput.style.textDecoration

    withTransform({
        translate(topLeft.x, topLeft.y)
        clip(textLayoutResult)
    }) {
        // if text layout was created using brush, and [color] is unspecified, we should treat this
        // like drawText(brush) call
        val brush = textLayoutResult.layoutInput.style.brush
        if (brush != null && color.isUnspecified) {
            textLayoutResult.multiParagraph.paint(
                drawContext.canvas,
                brush,
                if (!alpha.isNaN()) alpha else textLayoutResult.layoutInput.style.alpha,
                newShadow,
                newTextDecoration
            )
        } else {
            textLayoutResult.multiParagraph.paint(
                drawContext.canvas,
                color.takeOrElse { textLayoutResult.layoutInput.style.color }.modulate(alpha),
                newShadow,
                newTextDecoration
            )
        }
    }
}

/**
 * Draw an existing text layout as produced by [TextMeasurer].
 *
 * This draw function cannot relayout when async font loading resolves. If using async fonts or
 * other dynamic text layout, you are responsible for invalidating layout on changes.
 *
 * @param textLayoutResult Text Layout to be drawn
 * @param brush The brush to use when drawing the text.
 * @param topLeft Offsets the text from top left point of the current coordinate system.
 * @param alpha Opacity to be applied to [brush] from 0.0f to 1.0f representing fully
 * transparent to fully opaque respectively.
 * @param shadow The shadow effect applied on the text.
 * @param textDecoration The decorations to paint on the text (e.g., an underline).
 *
 * @sample androidx.compose.ui.text.samples.DrawTextLayoutResultSample
 */
@ExperimentalTextApi
fun DrawScope.drawText(
    textLayoutResult: TextLayoutResult,
    brush: Brush,
    topLeft: Offset = Offset.Zero,
    alpha: Float = Float.NaN,
    shadow: Shadow? = null,
    textDecoration: TextDecoration? = null
) {
    val newShadow = shadow ?: textLayoutResult.layoutInput.style.shadow
    val newTextDecoration = textDecoration ?: textLayoutResult.layoutInput.style.textDecoration

    withTransform({
        translate(topLeft.x, topLeft.y)
        clip(textLayoutResult)
    }) {
        textLayoutResult.multiParagraph.paint(
            drawContext.canvas,
            brush,
            if (!alpha.isNaN()) alpha else textLayoutResult.layoutInput.style.alpha,
            newShadow,
            newTextDecoration
        )
    }
}

private fun DrawTransform.clip(textLayoutResult: TextLayoutResult) {
    if (textLayoutResult.hasVisualOverflow &&
        textLayoutResult.layoutInput.overflow != TextOverflow.Visible) {
        clipRect(
            left = 0f,
            top = 0f,
            right = textLayoutResult.size.width.toFloat(),
            bottom = textLayoutResult.size.height.toFloat()
        )
    }
}