TextLayoutResult.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.graphics.Path
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.style.ResolvedTextDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection

/**
 * The data class which holds the set of parameters of the text layout computation.
 */
class TextLayoutInput(
    /**
     * The text used for computing text layout.
     */
    val text: AnnotatedString,

    /**
     * The text layout used for computing this text layout.
     */
    val style: TextStyle,

    /**
     * A list of [Placeholder]s inserted into text layout that reserves space to embed icons or
     * custom emojis. A list of bounding boxes will be returned in
     * [TextLayoutResult.placeholderRects] that corresponds to this input.
     *
     * @see TextLayoutResult.placeholderRects
     * @see MultiParagraph
     * @see MultiParagraphIntrinsics
     */
    val placeholders: List<AnnotatedString.Range<Placeholder>>,

    /**
     * The maxLines param used for computing this text layout.
     */
    val maxLines: Int,

    /**
     * The maxLines param used for computing this text layout.
     */
    val softWrap: Boolean,

    /**
     * The overflow param used for computing this text layout
     */
    val overflow: TextOverflow,

    /**
     * The density param used for computing this text layout.
     */
    val density: Density,

    /**
     * The layout direction used for computing this text layout.
     */
    val layoutDirection: LayoutDirection,

    /**
     * The font resource loader used for computing this text layout.
     */
    val resourceLoader: Font.ResourceLoader,

    /**
     * The minimum width provided while calculating this text layout.
     */
    val constraints: Constraints
) {

    fun copy(
        text: AnnotatedString = this.text,
        style: TextStyle = this.style,
        placeholders: List<AnnotatedString.Range<Placeholder>> = this.placeholders,
        maxLines: Int = this.maxLines,
        softWrap: Boolean = this.softWrap,
        overflow: TextOverflow = this.overflow,
        density: Density = this.density,
        layoutDirection: LayoutDirection = this.layoutDirection,
        resourceLoader: Font.ResourceLoader = this.resourceLoader,
        constraints: Constraints = this.constraints
    ): TextLayoutInput {
        return TextLayoutInput(
            text = text,
            style = style,
            placeholders = placeholders,
            maxLines = maxLines,
            softWrap = softWrap,
            overflow = overflow,
            density = density,
            layoutDirection = layoutDirection,
            resourceLoader = resourceLoader,
            constraints = constraints
        )
    }

    override operator fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is TextLayoutInput) return false

        if (text != other.text) return false
        if (style != other.style) return false
        if (placeholders != other.placeholders) return false
        if (maxLines != other.maxLines) return false
        if (softWrap != other.softWrap) return false
        if (overflow != other.overflow) return false
        if (density != other.density) return false
        if (layoutDirection != other.layoutDirection) return false
        if (resourceLoader != other.resourceLoader) return false
        if (constraints != other.constraints) return false

        return true
    }

    override fun hashCode(): Int {
        var result = text.hashCode()
        result = 31 * result + style.hashCode()
        result = 31 * result + placeholders.hashCode()
        result = 31 * result + maxLines
        result = 31 * result + softWrap.hashCode()
        result = 31 * result + overflow.hashCode()
        result = 31 * result + density.hashCode()
        result = 31 * result + layoutDirection.hashCode()
        result = 31 * result + resourceLoader.hashCode()
        result = 31 * result + constraints.hashCode()
        return result
    }

    override fun toString(): String {
        return "TextLayoutInput(" +
            "text=$text, " +
            "style=$style, " +
            "placeholders=$placeholders, " +
            "maxLines=$maxLines, " +
            "softWrap=$softWrap, " +
            "overflow=$overflow, " +
            "density=$density, " +
            "layoutDirection=$layoutDirection, " +
            "resourceLoader=$resourceLoader, " +
            "constraints=$constraints" +
            ")"
    }
}

/**
 * The data class which holds text layout result.
 */
class TextLayoutResult constructor(
    /**
     * The parameters used for computing this text layout result.
     */
    val layoutInput: TextLayoutInput,

    /**
     * The multi paragraph object.
     *
     * This is the result of the text layout computation.
     */
    val multiParagraph: MultiParagraph,

    /**
     * The amount of space required to paint this text in Int.
     */
    val size: IntSize
) {
    /**
     * The distance from the top to the alphabetic baseline of the first line.
     */
    val firstBaseline: Float = multiParagraph.firstBaseline

    /**
     * The distance from the top to the alphabetic baseline of the last line.
     */
    val lastBaseline: Float = multiParagraph.lastBaseline

    /**
     * Returns true if the text is too tall and couldn't fit with given height.
     */
    val didOverflowHeight: Boolean get() = multiParagraph.didExceedMaxLines ||
        size.height < multiParagraph.height

    /**
     * Returns true if the text is too wide and couldn't fit with given width.
     */
    val didOverflowWidth: Boolean get() = size.width < multiParagraph.width

    /**
     * Returns true if either vertical overflow or horizontal overflow happens.
     */
    val hasVisualOverflow: Boolean get() = didOverflowWidth || didOverflowHeight

    /**
     * Returns a list of bounding boxes that is reserved for [TextLayoutInput.placeholders].
     * Each [Rect] in this list corresponds to the [Placeholder] passed to
     * [TextLayoutInput.placeholders] and it will have the height and width specified in the
     * [Placeholder]. It's guaranteed that [TextLayoutInput.placeholders] and
     * [TextLayoutResult.placeholderRects] will have same length and order.
     *
     * @see TextLayoutInput.placeholders
     * @see Placeholder
     */
    val placeholderRects: List<Rect?> = multiParagraph.placeholderRects

    /**
     * Returns a number of lines of this text layout
     */
    val lineCount: Int get() = multiParagraph.lineCount

    /**
     * Returns the end offset of the given line, inclusive.
     *
     * @param lineIndex the line number
     * @return the start offset of the line
     */
    fun getLineStart(lineIndex: Int): Int = multiParagraph.getLineStart(lineIndex)

    /**
     * Returns the end offset of the given line
     *
     * Characters being ellipsized are treated as invisible characters. So that if visibleEnd is
     * false, it will return line end including the ellipsized characters and vice verse.
     *
     * @param lineIndex the line number
     * @param visibleEnd if true, the returned line end will not count trailing whitespaces or
     * linefeed characters. Otherwise, this function will return the logical line end. By default
     * it's false.
     * @return an exclusive end offset of the line.
     */
    fun getLineEnd(lineIndex: Int, visibleEnd: Boolean = false): Int =
        multiParagraph.getLineEnd(lineIndex, visibleEnd)

    /**
     * Returns true if ellipsis happens on the given line, otherwise returns false
     *
     * @param lineIndex a 0 based line index
     * @return true if ellipsis happens on the given line, otherwise false
     */
    fun isLineEllipsized(lineIndex: Int): Boolean = multiParagraph.isLineEllipsized(lineIndex)

    /**
     * Returns the top y coordinate of the given line.
     *
     * @param lineIndex the line number
     * @return the line top y coordinate
     */
    fun getLineTop(lineIndex: Int): Float = multiParagraph.getLineTop(lineIndex)

    /**
     * Returns the bottom y coordinate of the given line.
     *
     * @param lineIndex the line number
     * @return the line bottom y coordinate
     */
    fun getLineBottom(lineIndex: Int): Float = multiParagraph.getLineBottom(lineIndex)

    /**
     * Returns the left x coordinate of the given line.
     *
     * @param lineIndex the line number
     * @return the line left x coordinate
     */
    fun getLineLeft(lineIndex: Int): Float = multiParagraph.getLineLeft(lineIndex)

    /**
     * Returns the right x coordinate of the given line.
     *
     * @param lineIndex the line number
     * @return the line right x coordinate
     */
    fun getLineRight(lineIndex: Int): Float = multiParagraph.getLineRight(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.
     *
     * @param offset a character offset
     * @return the 0 origin line number.
     */
    fun getLineForOffset(offset: Int): Int = multiParagraph.getLineForOffset(offset)

    /**
     * Returns line number closest to the given graphical vertical position.
     *
     * If you ask for a vertical position before 0, you get 0; if you ask for a vertical position
     * beyond the last line, you get the last line.
     *
     * @param vertical the vertical position
     * @return the 0 origin line number.
     */
    fun getLineForVerticalPosition(vertical: Float): Int =
        multiParagraph.getLineForVerticalPosition(vertical)

    /**
     * Get the horizontal position for the specified text [offset].
     *
     * Returns the relative distance from the text starting offset. For example, if the paragraph
     * direction is Left-to-Right, this function returns positive value as a distance from the
     * left-most edge. If the paragraph direction is Right-to-Left, this function returns negative
     * value as a distance from the right-most edge.
     *
     * [usePrimaryDirection] argument is taken into account only when the offset is in the BiDi
     * directional transition point. [usePrimaryDirection] is true means use the primary
     * direction run's coordinate, and use the secondary direction's run's coordinate if false.
     *
     * @param offset a character offset
     * @param usePrimaryDirection true for using the primary run's coordinate if the given
     * offset is in the BiDi directional transition point.
     * @return the relative distance from the text starting edge.
     * @see MultiParagraph.getHorizontalPosition
     */
    fun getHorizontalPosition(offset: Int, usePrimaryDirection: Boolean): Float =
        multiParagraph.getHorizontalPosition(offset, usePrimaryDirection)

    /**
     * Get the text direction of the paragraph containing the given offset.
     *
     * @param offset a character offset
     * @return the paragraph direction
     */
    fun getParagraphDirection(offset: Int): ResolvedTextDirection =
        multiParagraph.getParagraphDirection(offset)

    /**
     * Get the text direction of the resolved BiDi run that the character at the given offset
     * associated with.
     *
     * @param offset a character offset
     * @return the direction of the BiDi run of the given character offset.
     */
    fun getBidiRunDirection(offset: Int): ResolvedTextDirection =
        multiParagraph.getBidiRunDirection(offset)

    /**
     *  Returns the character offset closest to the given graphical position.
     *
     *  @param position a graphical position in this text layout
     *  @return a character offset that is closest to the given graphical position.
     */
    fun getOffsetForPosition(position: Offset): Int =
        multiParagraph.getOffsetForPosition(position)

    /**
     * Returns the bounding box of the character for given character offset.
     *
     * @param offset a character offset
     * @return a bounding box for the character in pixels.
     */
    fun getBoundingBox(offset: Int): Rect = 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 = multiParagraph.getWordBoundary(offset)

    /**
     * Returns the rectangle of the cursor area
     *
     * @param offset An character offset of the cursor
     * @return a rectangle of cursor region
     */
    fun getCursorRect(offset: Int): Rect = multiParagraph.getCursorRect(offset)

    /**
     * Returns path that enclose the given text range.
     *
     * @param start an inclusive start character offset
     * @param end an exclusive end character offset
     * @return a drawing path
     */
    fun getPathForRange(start: Int, end: Int): Path = multiParagraph.getPathForRange(start, end)

    fun copy(
        layoutInput: TextLayoutInput = this.layoutInput,
        size: IntSize = this.size
    ): TextLayoutResult {
        return TextLayoutResult(
            layoutInput = layoutInput,
            multiParagraph = multiParagraph,
            size = size
        )
    }

    override operator fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is TextLayoutResult) return false

        if (layoutInput != other.layoutInput) return false
        if (multiParagraph != other.multiParagraph) return false
        if (size != other.size) return false
        if (firstBaseline != other.firstBaseline) return false
        if (lastBaseline != other.lastBaseline) return false
        if (placeholderRects != other.placeholderRects) return false

        return true
    }

    override fun hashCode(): Int {
        var result = layoutInput.hashCode()
        result = 31 * result + multiParagraph.hashCode()
        result = 31 * result + size.hashCode()
        result = 31 * result + firstBaseline.hashCode()
        result = 31 * result + lastBaseline.hashCode()
        result = 31 * result + placeholderRects.hashCode()
        return result
    }

    override fun toString(): String {
        return "TextLayoutResult(" +
            "layoutInput=$layoutInput, " +
            "multiParagraph=$multiParagraph, " +
            "size=$size, " +
            "firstBaseline=$firstBaseline, " +
            "lastBaseline=$lastBaseline, " +
            "placeholderRects=$placeholderRects" +
            ")"
    }
}

@Deprecated(
    "Unused public function which was added for testing. The function does not do " +
        "anything usable for Compose text APIs. The function is now deprecated and will be " +
        "removed soon"
)
fun createTextLayoutResult(
    layoutInput: TextLayoutInput =
        TextLayoutInput(
            text = AnnotatedString(""),
            style = TextStyle(),
            placeholders = emptyList(),
            maxLines = 1,
            softWrap = false,
            overflow = TextOverflow.Clip,
            density = Density(1f),
            layoutDirection = LayoutDirection.Ltr,
            resourceLoader = object : Font.ResourceLoader {
                override fun load(font: Font): Any {
                    return false
                }
            },
            constraints = Constraints()
        ),
    multiParagraph: MultiParagraph = MultiParagraph(
        annotatedString = layoutInput.text,
        style = layoutInput.style,
        width = 0f,
        density = layoutInput.density,
        resourceLoader = layoutInput.resourceLoader
    ),
    size: IntSize = IntSize.Zero
): TextLayoutResult = TextLayoutResult(
    layoutInput, multiParagraph, size
)