All Downloads are FREE. Search and download functionalities are using the official Maven repository.

skikoMain.androidx.compose.ui.text.SkiaParagraph.skiko.kt Maven / Gradle / Ivy

/*
 * Copyright 2023 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 org.jetbrains.skia.Rect as SkRect
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.graphics.drawscope.DrawStyle
import androidx.compose.ui.text.platform.SkiaParagraphIntrinsics
import androidx.compose.ui.text.platform.cursorHorizontalPosition
import androidx.compose.ui.text.style.ResolvedTextDirection
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Constraints
import kotlin.math.floor
import org.jetbrains.skia.IRange
import org.jetbrains.skia.paragraph.*

internal class SkiaParagraph(
    intrinsics: ParagraphIntrinsics,
    val maxLines: Int,
    val ellipsis: Boolean,
    val constraints: Constraints
) : Paragraph {

    private val ellipsisChar = if (ellipsis) "\u2026" else ""

    private val paragraphIntrinsics = intrinsics as SkiaParagraphIntrinsics

    private val layouter = paragraphIntrinsics.layouter().apply {
        setParagraphStyle(
            maxLines = maxLines,
            ellipsis = ellipsisChar
        )
    }

    /**
     * Paragraph isn't always immutable, it could be changed via [paint] method without
     * rerunning layout
     */
    private var paragraph = layouter.layoutParagraph(
        width = width
    )

    init {
        paragraph.layout(width)
    }

    private val text: String
        get() = paragraphIntrinsics.text

    override val width: Float
        get() = constraints.maxWidth.toFloat()

    override val height: Float
        get() = paragraph.height

    override val minIntrinsicWidth: Float
        get() = paragraphIntrinsics.minIntrinsicWidth

    override val maxIntrinsicWidth: Float
        get() = paragraphIntrinsics.maxIntrinsicWidth

    override val firstBaseline: Float
        get() = lineMetrics.firstOrNull()?.run { baseline.toFloat() } ?: 0f

    override val lastBaseline: Float
        get() = lineMetrics.lastOrNull()?.run { baseline.toFloat() } ?: 0f

    override val didExceedMaxLines: Boolean
        get() = paragraph.didExceedMaxLines()

    override val lineCount: Int
        // workaround for https://bugs.chromium.org/p/skia/issues/detail?id=11321
        // workaround for invalid paragraph layout result
        get() = if (text == "" || paragraph.lineNumber < 1) {
            1
        } else {
            paragraph.lineNumber
        }

    override val placeholderRects: List
        get() =
            paragraph.rectsForPlaceholders.map {
                it.rect.toComposeRect()
            }

    override fun getPathForRange(start: Int, end: Int): Path {
        val boxes = paragraph.getRectsForRange(
            start,
            end,
            RectHeightMode.MAX,
            RectWidthMode.TIGHT
        )
        val path = Path()
        for (b in boxes) {
            path.asSkiaPath().addRect(b.rect)
        }
        return path
    }

    override fun getCursorRect(offset: Int): Rect {
        val horizontal = getHorizontalPosition(offset, true)
        val line = lineMetricsForOffset(offset)!!

        // workaround for https://bugs.chromium.org/p/skia/issues/detail?id=11321 :(
        // Otherwise it shows a big cursor on a new empty line https://github.com/JetBrains/compose-jb/issues/1895
        val isNewEmptyLine = offset - 1 == line.startIndex && offset == text.length
        val metrics = layouter.defaultFont.metrics

        val asc = line.ascent.let {
            if (isNewEmptyLine) {
                val ascent = -metrics.ascent.toDouble()
                it.coerceAtMost(ascent)
            } else {
                it
            }
        }
        val desc = line.descent.let {
            if (isNewEmptyLine) {
                val descent = metrics.descent.toDouble()
                it.coerceAtMost(descent)
            } else {
                it
            }
        }

        return Rect(
            horizontal,
            (line.baseline - asc).toFloat(),
            horizontal,
            (line.baseline + desc).toFloat()
        )
    }

    override fun getLineLeft(lineIndex: Int): Float =
        lineMetrics.getOrNull(lineIndex)?.left?.toFloat() ?: 0f

    override fun getLineRight(lineIndex: Int): Float =
        lineMetrics.getOrNull(lineIndex)?.right?.toFloat() ?: 0f

    override fun getLineTop(lineIndex: Int) =
        lineMetrics.getOrNull(lineIndex)?.let { line ->
            floor((line.baseline - line.ascent).toFloat())
        } ?: 0f

    override fun getLineBottom(lineIndex: Int) =
        lineMetrics.getOrNull(lineIndex)?.let { line ->
            floor((line.baseline + line.descent).toFloat())
        } ?: 0f

    private fun lineMetricsForOffset(offset: Int): LineMetrics? {
        checkOffsetIsValid(offset)
        val metrics = lineMetrics
        for (line in metrics) {
            if (offset < line.endIncludingNewline) {
                return line
            }
        }
        if (metrics.isEmpty()) {
            return null
        }
        return metrics.last()
    }

    override fun getLineHeight(lineIndex: Int) =
        lineMetrics.getOrNull(lineIndex)?.height?.toFloat() ?: 0f

    override fun getLineWidth(lineIndex: Int) =
        lineMetrics.getOrNull(lineIndex)?.width?.toFloat() ?: 0f

    override fun getLineStart(lineIndex: Int) =
        lineMetrics.getOrNull(lineIndex)?.startIndex ?: 0

    override fun getLineEnd(lineIndex: Int, visibleEnd: Boolean): Int {
        val metrics = lineMetrics.getOrNull(lineIndex) ?: return 0
        return if (visibleEnd) {
            // workarounds for https://bugs.chromium.org/p/skia/issues/detail?id=11321 :(
            // we are waiting for fixes
            if (lineIndex > 0 && metrics.startIndex < lineMetrics[lineIndex - 1].endIndex) {
                metrics.endIndex
            } else if (
                metrics.startIndex < text.length &&
                text[metrics.startIndex] == '\n'
            ) {
                metrics.startIndex
            } else {
                metrics.endExcludingWhitespaces
            }
        } else {
            metrics.endIndex
        }
    }

    override fun isLineEllipsized(lineIndex: Int) = false

    override fun getLineForOffset(offset: Int) =
        lineMetricsForOffset(offset)?.lineNumber ?: 0

    override fun getLineForVerticalPosition(vertical: Float): Int {
        return getLineMetricsForVerticalPosition(vertical)?.lineNumber ?: 0
    }

    private fun getLineMetricsForVerticalPosition(vertical: Float): LineMetrics? {
        return lineMetrics.firstOrNull { vertical < it.baseline + it.descent }
    }

    override fun getHorizontalPosition(offset: Int, usePrimaryDirection: Boolean): Float {
        val prevBox = getBoxBackwardByOffset(offset)
        val nextBox = getBoxForwardByOffset(offset)
        val isRtl = paragraphIntrinsics.textDirection == ResolvedTextDirection.Rtl
        val isLtr = !isRtl
        return when {
            prevBox == null && nextBox == null -> if (isRtl) width else 0f
            prevBox == null -> nextBox!!.cursorHorizontalPosition(true)
            nextBox == null -> prevBox.cursorHorizontalPosition()
            nextBox.direction == prevBox.direction -> nextBox.cursorHorizontalPosition(true)
            isLtr && prevBox.direction == Direction.LTR -> nextBox.cursorHorizontalPosition(opposite = true)
            isRtl && prevBox.direction == Direction.RTL -> nextBox.cursorHorizontalPosition(opposite = true)
            // BiDi transition offset, we need to resolve ambiguity with usePrimaryDirection
            // for details see comment for MultiParagraph.getHorizontalPosition
            usePrimaryDirection -> prevBox.cursorHorizontalPosition()
            else -> nextBox.cursorHorizontalPosition(true)
        }
    }

    // workaround for https://bugs.chromium.org/p/skia/issues/detail?id=11321 :(
    private val lineMetrics: Array
        get() = if (text == "") {
            val metrics = layouter.defaultFont.metrics
            val ascent = -metrics.ascent.toDouble()
            val descent = metrics.descent.toDouble()
            val baseline = paragraph.alphabeticBaseline.toDouble()
            val height = with(layouter.paragraphStyle.strutStyle) {
                if (isEnabled && !isHeightForced && isHeightOverridden && fontSize > 0.0f) {
                    (height * fontSize).toDouble()
                } else {
                    ascent + descent
                }
            }

            arrayOf(
                LineMetrics(
                    0, 0, 0, 0, true,
                    ascent, descent, ascent, height, 0.0, 0.0, baseline, 0
                )
            )
        } else {
            @Suppress("UNCHECKED_CAST", "USELESS_CAST")
            paragraph.lineMetrics as Array
        }

    private fun getBoxForwardByOffset(offset: Int): TextBox? {
        checkOffsetIsValid(offset)
        var to = offset + 1 // TODO: Use unicode code points (CodePoint.charCount() instead of +1)
        while (to <= text.length) {
            val box = paragraph.getRectsForRange(
                offset, to,
                RectHeightMode.STRUT, RectWidthMode.TIGHT
            ).firstOrNull()
            if (box != null) {
                return box
            }
            to += 1 // TODO: Use unicode code points (CodePoint.charCount() instead of +1)
        }
        return null
    }

    private fun getBoxBackwardByOffset(offset: Int, end: Int = offset): TextBox? {
        checkOffsetIsValid(offset)
        var from = offset - 1
        val isRtl = paragraphIntrinsics.textDirection == ResolvedTextDirection.Rtl
        while (from >= 0) {
            val box = paragraph.getRectsForRange(
                from, end,
                RectHeightMode.STRUT, RectWidthMode.TIGHT
            ).firstOrNull()
            when {
                (box == null) -> from -= 1
                (text[from] == '\n') -> {
                    return if (!isRtl) {
                        val bottom = box.rect.bottom + box.rect.bottom - box.rect.top
                        val rect = SkRect(0f, box.rect.bottom, 0f, bottom)
                        return TextBox(rect, box.direction)
                    } else {
                        // For RTL:
                        // When cursor changes its position across lines, we apply the following rules:

                        // if '\n' is the last character, then the box should be aligned to the right:
                        // _________________abc   <- '\n' new line here
                        // ___________________|   <- cursor is in the end of the next line

                        // if '\n' is not the last, then the box should be be aligned to the left of the following box:
                        // _________________abc   <- '\n' new line here
                        // _________________|qw   <- cursor is before the box ('q') following the new line

                        if (from == text.lastIndex) {
                            val bottom = box.rect.bottom + box.rect.bottom - box.rect.top
                            val rect = SkRect(width, box.rect.bottom, width, bottom)
                            TextBox(rect, box.direction)
                        } else {
                            // TODO: Use unicode code points (CodePoint.charCount() instead of +1)
                            val nextBox =  paragraph.getRectsForRange(
                                offset, offset + 1,
                                RectHeightMode.STRUT, RectWidthMode.TIGHT
                            ).first()
                            val rect = SkRect(
                                nextBox.rect.left, nextBox.rect.top,
                                nextBox.rect.left, nextBox.rect.bottom
                            )
                            TextBox(rect, nextBox.direction)
                        }
                    }
                }
                else -> return box
            }
        }
        return null
    }

    override fun getParagraphDirection(offset: Int): ResolvedTextDirection =
        // TODO: It should be position based (direction isolate for example)
        paragraphIntrinsics.textDirection

    override fun getBidiRunDirection(offset: Int): ResolvedTextDirection =
        when (getBoxForwardByOffset(offset)?.direction) {
            Direction.RTL -> ResolvedTextDirection.Rtl
            Direction.LTR -> ResolvedTextDirection.Ltr
            null -> ResolvedTextDirection.Ltr
        }

    override fun getOffsetForPosition(position: Offset): Int {
        val glyphPosition = paragraph.getGlyphPositionAtCoordinate(position.x, position.y).position

        // Below we apply a workaround for skiko/skia issue:
        //
        // It's expected that this method should return the glyph position that lays on the line at `position.y`.
        // When the `position` is not within the text line, glyphPosition will reference a wrong glyph (for example, the first glyph on a next line).
        // This will make the cursor go to the wrong position, not according to the coordinates of a click.
        //
        // When position.x lays beyond the left or right side of a text line,
        // `getGlyphPositionAtCoordinate` returns a wrong value.
        // This happens:
        // - in multiline text when a text block has an opposite direction than the primary paragraph direction
        // - in text with line-breaks, when clicking to the right of a text line
        //
        // Therefore, when the position.x is not within the line's left or right side,
        // we call getGlyphPositionAtCoordinate with `x` value closest to the corresponding side.
        //
        // TODO: consider fixing it in skiko

        // expectedLine is the line which lays at position.y
        val expectedLine = getLineMetricsForVerticalPosition(position.y) ?: return glyphPosition
        val isNotEmptyLine = expectedLine.startIndex < expectedLine.endIndex // a line with only whitespaces considered to be not empty

        // No need to apply the workaround if the clicked position is within the line bounds (but doesn't include whitespaces)
        if (position.x > expectedLine.left && position.x < expectedLine.right) {
            return glyphPosition
        }

        val rects = if (isNotEmptyLine) {
            // expectedLine width doesn't include whitespaces. Therefore we look at the Rectangle representing the line
            paragraph.getRectsForRange(
                start = expectedLine.startIndex,
                end = if (expectedLine.isHardBreak) expectedLine.endIndex else expectedLine.endIndex - 1,
                rectHeightMode = RectHeightMode.STRUT,
                rectWidthMode = RectWidthMode.TIGHT
            )
        } else { // the array of rects should be empty for an empty line, so no need to call `getRectsForRange`
            null
        }

        val leftX = rects?.firstOrNull()?.rect?.left ?: expectedLine.left.toFloat()
        val rightX = rects?.lastOrNull()?.rect?.right ?: expectedLine.right.toFloat()

        if (leftX == rightX) {
            return glyphPosition
        }

        var correctedGlyphPosition = glyphPosition

        if (position.x <= leftX) { // when clicked to the left of a text line
            correctedGlyphPosition = paragraph.getGlyphPositionAtCoordinate(leftX + 1f, position.y).position
        } else if (position.x >= rightX) { // when clicked to the right of a text line
            correctedGlyphPosition = paragraph.getGlyphPositionAtCoordinate(rightX - 1f, position.y).position
            val isNeutralChar = if (correctedGlyphPosition in text.indices) {
                text.codePointAt(correctedGlyphPosition).isNeutralDirection()
            } else false

            // For RTL blocks, the position is still not correct, so we have to subtract 1 from the returned result
            if (!isNeutralChar && getBoxBackwardByOffset(correctedGlyphPosition)?.direction == Direction.RTL) {
                correctedGlyphPosition -= 1 // TODO Check if it should be CodePoint.charCount()
            }
        }

        return correctedGlyphPosition
    }

    override fun getBoundingBox(offset: Int): Rect {
        val box = getBoxForwardByOffset(offset) ?: getBoxBackwardByOffset(offset, text.length)!!
        return box.rect.toComposeRect()
    }

    override fun getWordBoundary(offset: Int): TextRange {
        checkOffsetIsValid(offset)

        // To match with Android implementation it should return:
        // - empty range if offset is between spaces
        // - previous word if offset right after that
        // - TODO: punctuation doesn't divide words
        //   NOTE: Android punctuation handling has some issues at the moment, so it doesn't clear
        //         what expected behavior is.

        // Android uses `isLetterOrDigit` for codepoints, but we have it only for chars.
        // Using `Char.isWhitespace` instead because whitespaces are not supplementary code units.
        // TODO: Replace chars to code units to make this code future proof.
        if (offset < text.length && text[offset].isWhitespace() || offset == text.length) {
            // If it's whitespace, we're sure that it's not surrogate.
            return if (offset > 0 && !text[offset - 1].isWhitespace()) {
                paragraph.getWordBoundary(offset - 1).toTextRange()
            } else TextRange(offset, offset)
        }

        // Skia paragraph should handle unicode correctly, but it doesn't match Android behavior.
        // It uses ICU's BreakIterator under the hood, so we can use
        // `BreakIterator.makeWordInstance()` without calling skia's `getWordBoundary` at all.
        return paragraph.getWordBoundary(offset).toTextRange()
    }

    override fun paint(
        canvas: Canvas,
        color: Color,
        shadow: Shadow?,
        textDecoration: TextDecoration?
    ) {
        paragraph = with(layouter) {
            setTextStyle(
                color = color,
                shadow = shadow,
                textDecoration = textDecoration
            )
            layoutParagraph(
                width = width
            )
        }
        paragraph.paint(canvas.nativeCanvas, 0.0f, 0.0f)
    }

    @ExperimentalTextApi
    override fun paint(
        canvas: Canvas,
        color: Color,
        shadow: Shadow?,
        textDecoration: TextDecoration?,
        drawStyle: DrawStyle?,
        blendMode: BlendMode
    ) {
        paragraph = with(layouter) {
            setTextStyle(
                color = color,
                shadow = shadow,
                textDecoration = textDecoration
            )
            setDrawStyle(drawStyle)
            setBlendMode(blendMode)
            layoutParagraph(
                width = width
            )
        }
        paragraph.paint(canvas.nativeCanvas, 0.0f, 0.0f)
    }

    @ExperimentalTextApi
    override fun paint(
        canvas: Canvas,
        brush: Brush,
        alpha: Float,
        shadow: Shadow?,
        textDecoration: TextDecoration?,
        drawStyle: DrawStyle?,
        blendMode: BlendMode
    ) {
        paragraph = with(layouter) {
            setTextStyle(
                brush = brush,
                brushSize = Size(width, height),
                alpha = alpha,
                shadow = shadow,
                textDecoration = textDecoration
            )
            setDrawStyle(drawStyle)
            setBlendMode(blendMode)
            layoutParagraph(
                width = width
            )
        }
        paragraph.paint(canvas.nativeCanvas, 0.0f, 0.0f)
    }

    /**
     * Check if the given offset is in the given range.
     */
    private inline fun checkOffsetIsValid(offset: Int) {
        require(offset in 0..text.length) {
            ("Invalid offset: $offset. Valid range is [0, ${text.length}]")
        }
    }
}

private fun IRange.toTextRange() = TextRange(start, end)




© 2015 - 2025 Weber Informatics LLC | Privacy Policy