![JAR search and dependency download from the Maven repository](/logo.png)
net.peanuuutz.fork.ui.foundation.text.TextMeasurer.kt Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of fork-ui Show documentation
Show all versions of fork-ui Show documentation
Comprehensive API designed for Minecraft modders
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