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

net.peanuuutz.fork.ui.foundation.text.TextMeasurer.kt Maven / Gradle / Ivy

The newest version!
package net.peanuuutz.fork.ui.foundation.text

import androidx.compose.runtime.Stable
import com.ibm.icu.text.BreakIterator
import net.peanuuutz.fork.render.text.TextVisitor
import net.peanuuutz.fork.ui.foundation.text.MeasuredParagraph.Line
import net.peanuuutz.fork.ui.foundation.text.MeasuredParagraph.Line.Section
import net.peanuuutz.fork.ui.ui.draw.text.Paragraph
import net.peanuuutz.fork.ui.ui.draw.text.ParagraphStyle
import net.peanuuutz.fork.ui.ui.draw.text.SpanStyle
import net.peanuuutz.fork.ui.ui.draw.text.StyledTextMeasurer
import net.peanuuutz.fork.ui.ui.draw.text.TextRange
import net.peanuuutz.fork.ui.ui.draw.text.TextStyle
import net.peanuuutz.fork.ui.ui.draw.text.fontSizeOrDefault
import net.peanuuutz.fork.ui.ui.draw.text.rangeIntersects
import net.peanuuutz.fork.ui.ui.unit.FloatSize
import net.peanuuutz.fork.util.common.fastFold
import net.peanuuutz.fork.util.common.fastSumOf
import kotlin.math.max

interface TextMeasurer {
    @Stable
    fun measure(
        maxWidth: Float,
        paragraph: Paragraph,
        textStyle: TextStyle = TextStyle.Default,
        ellipsis: Boolean = false,
        maxLines: Int = Int.MAX_VALUE
    ): MeasuredParagraph
}

fun TextMeasurer(): TextMeasurer {
    return DefaultTextMeasurer()
}

// ======== Internal ========

private class DefaultTextMeasurer : TextMeasurer {
    // -------- Input --------

    private var maxWidth: Float = 0.0f

    private var inputParagraph: Paragraph = Paragraph.Empty

    private var textStyle: TextStyle = TextStyle.Default

    private lateinit var defaultSpanStyle: SpanStyle

    private lateinit var paragraphStyle: ParagraphStyle

    private var ellipsis: Boolean = false

    private var maxLines: Int = Int.MAX_VALUE

    // -------- Output --------

    private var cachedResult: MeasuredParagraph = MeasuredParagraph.Empty

    private lateinit var outputParagraph: Paragraph

    private lateinit var lines: MutableList

    private var lineCount: Int = 1

    private val exceedMaxLines: Boolean
        get() = lineCount > maxLines

    private val measuredWidth: Float
        get() {
            return lines.fastFold(0.0f) { acc, line ->
                max(acc, line.measuredSize.width)
            }
        }

    private val measuredHeight: Float
        get() {
            val contentTotalHeight = lines.fastSumOf { it.measuredSize.height }
            val lineSpace = paragraphStyle.lineSpace ?: return contentTotalHeight
            val actualLineCount = lines.size
            return if (actualLineCount >= 2) {
                contentTotalHeight + (actualLineCount - 1) * lineSpace
            } else {
                contentTotalHeight
            }
        }

    // -------- Line --------

    private var lineStartIndex: Int = 0

    private lateinit var lineSections: MutableList
private var lineMeasuredWidth: Float = 0.0f private val lineMeasuredHeight: Float get() { return lineSections.fastFold(0.0f) { acc, section -> max(acc, section.measuredSize.height) } } // -------- Section -------- private var sectionStartIndex: Int = 0 private lateinit var sectionSpanStyle: SpanStyle private var sectionMeasuredWidth: Float = 0.0f private val sectionMeasuredHeight: Float get() = sectionSpanStyle.fontSizeOrDefault.toFloat() // -------- Line Break Iterator -------- private val lineBreakIterator: BreakIterator = BreakIterator.getLineInstance() // -------- Measurement -------- override fun measure( maxWidth: Float, paragraph: Paragraph, textStyle: TextStyle, ellipsis: Boolean, maxLines: Int ): MeasuredParagraph { if (this.maxWidth == maxWidth && this.inputParagraph == paragraph && this.textStyle == textStyle && this.ellipsis == ellipsis && this.maxLines == maxLines ) { return cachedResult } this.maxWidth = maxWidth this.inputParagraph = paragraph this.textStyle = textStyle this.ellipsis = ellipsis this.maxLines = maxLines val measuredParagraph = if (maxLines < 1) { MeasuredParagraph.Empty } else { lineBreakIterator.setText(paragraph.plainText) defaultSpanStyle = textStyle.toSpanStyle() paragraphStyle = textStyle.toParagraphStyle().merge(paragraph.paragraphStyle) reset() iterate() appendLastSection() build() } cachedResult = measuredParagraph return measuredParagraph } private fun reset() { cachedResult = MeasuredParagraph.Empty outputParagraph = Paragraph( plainText = inputParagraph.plainText, spanStyles = inputParagraph.spanStyles, paragraphStyle = paragraphStyle, info = inputParagraph.info ) lines = mutableListOf() lineCount = 1 lineStartIndex = 0 lineSections = mutableListOf() lineMeasuredWidth = 0.0f sectionStartIndex = 0 sectionSpanStyle = defaultSpanStyle sectionMeasuredWidth = 0.0f } private fun iterate() { TextVisitor.charSequenceForward(outputParagraph.plainText) { index, codePoint -> consumeChar(index, codePoint) !exceedMaxLines } } // Actually it should be consumeCodePoint, but most of the time it's a single char private fun consumeChar( index: Int, codePoint: Int ) { val charSpanStyle = outputParagraph.spanStyles.fastFold(defaultSpanStyle) { acc, rangedSpanStyle -> if (rangeIntersects(index, index + 1, rangedSpanStyle.startIndex, rangedSpanStyle.endIndex)) { acc.merge(rangedSpanStyle.item) } else { acc } } if (sectionSpanStyle != charSpanStyle) { if (lineStartIndex != index) { val previousSection = Section( range = TextRange(sectionStartIndex, index), spanStyle = sectionSpanStyle, measuredSize = FloatSize(sectionMeasuredWidth, sectionMeasuredHeight) ) lineSections.add(previousSection) } sectionStartIndex = index sectionSpanStyle = charSpanStyle sectionMeasuredWidth = 0.0f } if (codePoint == LineBreakCodePoint) { newLine(index, isFromLineBreak = true) lineStartIndex = index + 1 sectionStartIndex = index + 1 sectionMeasuredWidth = 0.0f return } val charWidth = StyledTextMeasurer.getWidth(codePoint, charSpanStyle) if (lineMeasuredWidth + charWidth <= maxWidth) { lineMeasuredWidth += charWidth sectionMeasuredWidth += charWidth return } if (lineBreakIterator.isBoundary(index)) { newLine(index, isFromLineBreak = false) lineStartIndex = index lineMeasuredWidth = charWidth sectionStartIndex = index sectionMeasuredWidth = charWidth return } val closestLineBreakIndex = lineBreakIterator.preceding(index) if (closestLineBreakIndex > sectionStartIndex) { val choppedWidth = StyledTextMeasurer.getWidth( charSequence = outputParagraph.plainText.substring(closestLineBreakIndex, index), spanStyle = charSpanStyle ) lineMeasuredWidth -= choppedWidth sectionMeasuredWidth -= choppedWidth newLine(closestLineBreakIndex, isFromLineBreak = false) lineStartIndex = closestLineBreakIndex lineMeasuredWidth = choppedWidth + charWidth sectionStartIndex = closestLineBreakIndex sectionMeasuredWidth = choppedWidth + charWidth return } if (closestLineBreakIndex > lineStartIndex) { var sectionIndex = -1 val currentLineSectionCount = lineSections.size while (++sectionIndex < currentLineSectionCount) { if (lineSections[sectionIndex].range.endExclusive > closestLineBreakIndex) { break } } val section = lineSections[sectionIndex] val choppedRange = section.range val choppedSpanStyle = section.spanStyle val choppedMeasuredSize = section.measuredSize val secondSectionWidth = StyledTextMeasurer.getWidth( charSequence = outputParagraph.plainText.substring(closestLineBreakIndex, choppedRange.endExclusive), spanStyle = choppedSpanStyle ) val secondSection = Section( range = TextRange(closestLineBreakIndex, choppedRange.endExclusive), spanStyle = choppedSpanStyle, measuredSize = FloatSize(secondSectionWidth, choppedMeasuredSize.height) ) val currentLineSectionsCopy = lineSections.toMutableList() val choppedSections = buildList { while (++sectionIndex < currentLineSectionCount) { add(lineSections.removeLast()) } add(secondSection) reverse() } val accumulatedSectionWidth = sectionMeasuredWidth val nextLineInitialWidth = choppedSections.fastSumOf { it.measuredSize.width } + accumulatedSectionWidth if (nextLineInitialWidth + charWidth <= maxWidth) { val firstSectionWidth = choppedMeasuredSize.width - secondSectionWidth lineSections.removeLast() lineMeasuredWidth = lineSections.fastSumOf { it.measuredSize.width } + firstSectionWidth val currentSectionStartIndex = sectionStartIndex sectionStartIndex = choppedRange.start sectionSpanStyle = choppedSpanStyle sectionMeasuredWidth = firstSectionWidth newLine(closestLineBreakIndex, isFromLineBreak = false) lineStartIndex = closestLineBreakIndex lineSections.addAll(choppedSections) lineMeasuredWidth = nextLineInitialWidth + charWidth sectionStartIndex = currentSectionStartIndex sectionSpanStyle = charSpanStyle sectionMeasuredWidth = accumulatedSectionWidth + charWidth return } lineSections = currentLineSectionsCopy // Continue to chop word } val wordChopIndex = if (lineMeasuredWidth != 0.0f) index else index + 1 newLine(wordChopIndex, isFromLineBreak = false) lineStartIndex = wordChopIndex lineMeasuredWidth = charWidth sectionStartIndex = wordChopIndex sectionMeasuredWidth = charWidth } private fun newLine( candidateBreakIndex: Int, isFromLineBreak: Boolean ) { var modifiedBreakIndex = candidateBreakIndex lineCount++ if (exceedMaxLines) { if (ellipsis) { val ellipsisWidth = StyledTextMeasurer.getWidth(EllipsisCodePoint, sectionSpanStyle) when { lineMeasuredWidth + ellipsisWidth <= maxWidth -> { outputParagraph = outputParagraph.subSequence(0, candidateBreakIndex) + Ellipsis lineMeasuredWidth += ellipsisWidth sectionMeasuredWidth += ellipsisWidth modifiedBreakIndex = candidateBreakIndex + 1 } sectionMeasuredWidth >= ellipsisWidth -> { val localEllipsisIndex = StyledTextMeasurer.getIndexByWidthReversed( width = ellipsisWidth, charSequence = outputParagraph.plainText.substring(sectionStartIndex, candidateBreakIndex), spanStyle = sectionSpanStyle )[0] val ellipsisIndex = sectionStartIndex + localEllipsisIndex val candidateWidth = StyledTextMeasurer.getWidth( charSequence = outputParagraph.plainText.substring(ellipsisIndex, candidateBreakIndex), spanStyle = sectionSpanStyle ) val choppedWidth = candidateWidth - ellipsisWidth outputParagraph = outputParagraph.subSequence(0, ellipsisIndex) + Ellipsis lineMeasuredWidth -= choppedWidth sectionMeasuredWidth -= choppedWidth modifiedBreakIndex = ellipsisIndex + 1 } else -> { // FIXME Properly implement multi-section chopping val extraWidth = ellipsisWidth - sectionMeasuredWidth outputParagraph = outputParagraph.subSequence(0, sectionStartIndex) + Ellipsis lineMeasuredWidth += extraWidth sectionMeasuredWidth += extraWidth modifiedBreakIndex = sectionStartIndex + 1 } } } else { outputParagraph = outputParagraph.subSequence(0, candidateBreakIndex) } } val lineLastSection = Section( range = TextRange(sectionStartIndex, modifiedBreakIndex), spanStyle = sectionSpanStyle, measuredSize = FloatSize(sectionMeasuredWidth, sectionMeasuredHeight) ) lineSections.add(lineLastSection) val line = Line( range = TextRange(lineStartIndex, modifiedBreakIndex), sections = lineSections, measuredSize = FloatSize(lineMeasuredWidth, lineMeasuredHeight), hasLineBreak = isFromLineBreak ) lines.add(line) lineSections = mutableListOf() lineMeasuredWidth = 0.0f } private fun appendLastSection() { if (exceedMaxLines) { return } val lastSection = Section( range = TextRange(sectionStartIndex, outputParagraph.length), spanStyle = sectionSpanStyle, measuredSize = FloatSize(sectionMeasuredWidth, sectionMeasuredHeight) ) lineSections.add(lastSection) val line = Line( range = TextRange(lineStartIndex, outputParagraph.length), sections = lineSections, measuredSize = FloatSize(lineMeasuredWidth, lineMeasuredHeight), hasLineBreak = false ) lines.add(line) } private fun build(): MeasuredParagraph { return MeasuredParagraph( displayParagraph = outputParagraph, lines = lines, exceedMaxLines = exceedMaxLines, measuredSize = FloatSize(measuredWidth, measuredHeight) ) } } private const val EllipsisCodePoint: Int = '\u2026'.code private const val LineBreakCodePoint: Int = '\n'.code private const val Ellipsis: String = "\u2026"




© 2015 - 2025 Weber Informatics LLC | Privacy Policy