blob: 8ed20295e79f1233f347b4152a0704bf112be4e9 [file] [log] [blame]
/*
* 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.ui.text
import androidx.ui.core.Density
import androidx.ui.core.Px
import androidx.ui.core.PxPosition
import androidx.ui.core.px
import androidx.ui.engine.geometry.Offset
import androidx.ui.engine.geometry.Rect
import androidx.ui.graphics.Canvas
import androidx.ui.graphics.Path
import androidx.ui.text.font.Font
import androidx.ui.text.style.TextDirection
import kotlin.math.max
/**
* Lays out and renders multiple paragraphs at once. Unlike [Paragraph], supports multiple
* [ParagraphStyle]s in a given text.
*
* @param intrinsics previously calculated text intrinsics
* @param maxLines the maximum number of lines that the text can have
* @param ellipsis whether to ellipsize text, applied only when [maxLines] is set
*/
class MultiParagraph(
val intrinsics: MultiParagraphIntrinsics,
val maxLines: Int? = null,
ellipsis: Boolean? = null,
constraints: ParagraphConstraints
) {
/**
* Lays out a given [annotatedString] with the given constraints. Unlike a [Paragraph],
* [MultiParagraph] can handle a text what has multiple paragraph styles.
*
* @param annotatedString the text to be laid out
* @param textStyle the [TextStyle] to be applied to the whole text
* @param paragraphStyle the [ParagraphStyle] to be applied to the whole text
* @param maxLines the maximum number of lines that the text can have
* @param ellipsis whether to ellipsize text, applied only when [maxLines] is set
* @param constraints how wide the text is allowed to be
* @param density density of the device
* @param resourceLoader [Font.ResourceLoader] to be used to load the font given in [TextStyle]s
*
* @throws IllegalArgumentException if [ParagraphStyle.textDirectionAlgorithm] is not set
*/
constructor(
annotatedString: AnnotatedString,
textStyle: TextStyle,
paragraphStyle: ParagraphStyle,
maxLines: Int? = null,
ellipsis: Boolean? = null,
constraints: ParagraphConstraints,
density: Density,
resourceLoader: Font.ResourceLoader
) : this(
intrinsics = MultiParagraphIntrinsics(
annotatedString = annotatedString,
textStyle = textStyle,
paragraphStyle = paragraphStyle,
density = density,
resourceLoader = resourceLoader
),
maxLines = maxLines,
ellipsis = ellipsis,
constraints = constraints
)
private val annotatedString get() = intrinsics.annotatedString
/**
* The width for text if all soft wrap opportunities were taken.
*/
val minIntrinsicWidth: Float get() = intrinsics.maxIntrinsicWidth
/**
* Returns the smallest width beyond which increasing the width never
* decreases the height.
*/
val maxIntrinsicWidth: Float get() = intrinsics.maxIntrinsicWidth
/**
* True if there is more vertical content, but the text was truncated, either
* because we reached `maxLines` lines of text or because the `maxLines` was
* null, `ellipsis` was not null, and one of the lines exceeded the width
* constraint.
*
* See the discussion of the `maxLines` and `ellipsis` arguments at [ParagraphStyle].
*/
val didExceedMaxLines: Boolean
/**
* The amount of horizontal space this paragraph occupies.
*/
val width: Float
/**
* The amount of vertical space this paragraph occupies.
*
* Valid only after [layout] has been called.
*/
val height: Float
/**
* The distance from the top of the paragraph to the alphabetic
* baseline of the first line, in logical pixels.
*/
val firstBaseline: Float
get() {
return if (paragraphInfoList.isEmpty()) {
0f
} else {
paragraphInfoList[0].paragraph.firstBaseline
}
}
/**
* The distance from the top of the paragraph to the alphabetic
* baseline of the first line, in logical pixels.
*/
val lastBaseline: Float
get() {
return if (paragraphInfoList.isEmpty()) {
0f
} else {
paragraphInfoList.last().paragraph.lastBaseline
}
}
/** The total number of lines in the text. */
val lineCount: Int
private val paragraphInfoList: List<ParagraphInfo>
init {
// create sub paragraphs and layouts
this.paragraphInfoList = intrinsics.infoList.map {
ParagraphInfo(
paragraph = Paragraph(
it.intrinsics,
maxLines,
ellipsis,
constraints
),
startIndex = it.startIndex,
endIndex = it.endIndex
)
}
// final layout
var didExceedMaxLines = false
var currentLineCount = 0
var currentHeight = 0f
for ((index, paragraphInfo) in paragraphInfoList.withIndex()) {
val paragraph = paragraphInfo.paragraph
paragraphInfo.startLineIndex = currentLineCount
paragraphInfo.endLineIndex = currentLineCount + paragraph.lineCount
currentLineCount = paragraphInfo.endLineIndex
paragraphInfo.top = currentHeight.px
paragraphInfo.bottom = (currentHeight + paragraph.height).px
currentHeight += paragraph.height
if (paragraph.didExceedMaxLines ||
(currentLineCount == maxLines && index != this.paragraphInfoList.lastIndex)
) {
didExceedMaxLines = true
break
}
}
this.didExceedMaxLines = didExceedMaxLines
this.lineCount = currentLineCount
this.height = currentHeight
this.width = constraints.width
}
/** Paint the paragraphs to canvas. */
fun paint(canvas: Canvas) {
canvas.save()
paragraphInfoList.forEach {
it.paragraph.paint(canvas)
canvas.translate(0f, it.paragraph.height)
}
canvas.restore()
}
/** Returns path that enclose the given text range. */
fun getPathForRange(start: Int, end: Int): Path {
require(start in 0..end && end <= annotatedString.text.length) {
"Start($start) or End($end) is out of range [0..${annotatedString.text.length})," +
" or start > end!"
}
if (start == end) return Path()
val paragraphIndex = findParagraphByIndex(paragraphInfoList, start)
val path = Path()
paragraphInfoList.drop(paragraphIndex)
.takeWhile { it.startIndex < end }
.filterNot { it.startIndex == it.endIndex }
.forEach {
with(it) {
path.addPath(
path = paragraph.getPathForRange(
start = start.toLocalIndex(),
end = end.toLocalIndex()
).toGlobal()
)
}
}
return path
}
/** Returns the character offset closest to the given graphical position. */
fun getOffsetForPosition(position: PxPosition): Int {
val paragraphIndex = when {
position.y.value <= 0f -> 0
position.y.value >= height -> paragraphInfoList.lastIndex
else -> findParagraphByY(paragraphInfoList, position.y)
}
return with(paragraphInfoList[paragraphIndex]) {
if (length == 0) {
max(0, startIndex - 1)
} else {
paragraph.getOffsetForPosition(position.toLocal()).toGlobalIndex()
}
}
}
/**
* Returns the bounding box as Rect of the character for given character offset. Rect
* includes the top, bottom, left and right of a character.
*/
fun getBoundingBox(offset: Int): Rect {
requireIndexInRange(offset)
val paragraphIndex = findParagraphByIndex(paragraphInfoList, offset)
return with(paragraphInfoList[paragraphIndex]) {
paragraph.getBoundingBox(offset.toLocalIndex()).toGlobal()
}
}
/**
* Compute the horizontal position where a newly inserted character at [offset] would be.
*
* If the inserted character at [offset] is within a LTR/RTL run, the returned position will be
* the left(right) edge of the character.
* ```
* For example:
* Paragraph's direction is LTR.
* Text in logic order: L0 L1 L2 R3 R4 R5
* Text in visual order: L0 L1 L2 R5 R4 R3
* position of the offset(2): |
* position of the offset(4): |
*```
* However, when the [offset] is at the BiDi transition offset, there will be two possible
* visual positions, which depends on the direction of the inserted character.
* ```
* For example:
* Paragraph's direction is LTR.
* Text in logic order: L0 L1 L2 R3 R4 R5
* Text in visual order: L0 L1 L2 R5 R4 R3
* position of the offset(3): | (The inserted character is LTR)
* | (The inserted character is RTL)
*```
* In this case, [usePrimaryDirection] will be used to resolve the ambiguity. If true, the
* inserted character's direction is assumed to be the same as Paragraph's direction.
* Otherwise, the inserted character's direction is assumed to be the opposite of the
* Paragraph's direction.
* ```
* For example:
* Paragraph's direction is LTR.
* Text in logic order: L0 L1 L2 R3 R4 R5
* Text in visual order: L0 L1 L2 R5 R4 R3
* position of the offset(3): | (usePrimaryDirection is true)
* | (usePrimaryDirection is false)
*```
* This method is useful to compute cursor position.
*
* @param offset the offset of the character, in the range of [0, length].
* @param usePrimaryDirection whether the paragraph direction is respected when [offset]
* points to a BiDi transition point.
* @return a float number representing the horizontal position in the unit of pixel.
*/
fun getHorizontalPosition(offset: Int, usePrimaryDirection: Boolean): Float {
requireIndexInRangeInclusiveEnd(offset)
val paragraphIndex = if (offset == annotatedString.text.length) {
paragraphInfoList.lastIndex
} else {
findParagraphByIndex(paragraphInfoList, offset)
}
return with(paragraphInfoList[paragraphIndex]) {
paragraph.getHorizontalPosition(offset.toLocalIndex(), usePrimaryDirection)
}
}
/**
* Get the text direction of the paragraph containing the given offset.
*/
fun getParagraphDirection(offset: Int): TextDirection {
requireIndexInRange(offset)
val paragraphIndex = if (offset == annotatedString.text.length) {
paragraphInfoList.lastIndex
} else {
findParagraphByIndex(paragraphInfoList, offset)
}
return with(paragraphInfoList[paragraphIndex]) {
paragraph.getParagraphDirection(offset.toLocalIndex())
}
}
/**
* Get the text direction of the character at the given offset.
*/
fun getBidiRunDirection(offset: Int): TextDirection {
requireIndexInRange(offset)
val paragraphIndex = if (offset == annotatedString.text.length) {
paragraphInfoList.lastIndex
} else {
findParagraphByIndex(paragraphInfoList, offset)
}
return with(paragraphInfoList[paragraphIndex]) {
paragraph.getBidiRunDirection(offset.toLocalIndex())
}
}
/**
* Returns the TextRange 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 TextRange(offset, offset+1).
* Word boundaries are defined more precisely in Unicode Standard Annex #29
* http://www.unicode.org/reports/tr29/#Word_Boundaries
*/
fun getWordBoundary(offset: Int): TextRange {
requireIndexInRange(offset)
val paragraphIndex = findParagraphByIndex(paragraphInfoList, offset)
return with(paragraphInfoList[paragraphIndex]) {
paragraph.getWordBoundary(offset.toLocalIndex()).toGlobal()
}
}
/** Returns rectangle of the cursor area. */
fun getCursorRect(offset: Int): Rect {
requireIndexInRangeInclusiveEnd(offset)
val paragraphIndex = if (offset == annotatedString.text.length) {
paragraphInfoList.lastIndex
} else {
findParagraphByIndex(paragraphInfoList, offset)
}
return with(paragraphInfoList[paragraphIndex]) {
paragraph.getCursorRect(offset.toLocalIndex()).toGlobal()
}
}
/**
* 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.
*/
fun getLineForOffset(offset: Int): Int {
requireIndexInRange(offset)
val paragraphIndex = findParagraphByIndex(paragraphInfoList, offset)
return with(paragraphInfoList[paragraphIndex]) {
paragraph.getLineForOffset(offset.toLocalIndex()).toGlobalLineIndex()
}
}
/** Returns the left x Coordinate of the given line. */
fun getLineLeft(lineIndex: Int): Float {
requireLineIndexInRange(lineIndex)
val paragraphIndex = findParagraphByLineIndex(paragraphInfoList, lineIndex)
return with(paragraphInfoList[paragraphIndex]) {
paragraph.getLineLeft(lineIndex.toLocalLineIndex())
}
}
/** Returns the right x Coordinate of the given line. */
fun getLineRight(lineIndex: Int): Float {
requireLineIndexInRange(lineIndex)
val paragraphIndex = findParagraphByLineIndex(paragraphInfoList, lineIndex)
return with(paragraphInfoList[paragraphIndex]) {
paragraph.getLineRight(lineIndex.toLocalLineIndex())
}
}
/** Returns the bottom y coordinate of the given line. */
fun getLineBottom(lineIndex: Int): Float {
requireLineIndexInRange(lineIndex)
val paragraphIndex = findParagraphByLineIndex(paragraphInfoList, lineIndex)
return with(paragraphInfoList[paragraphIndex]) {
paragraph.getLineBottom(lineIndex.toLocalLineIndex()).toGlobalYPosition()
}
}
/** Returns the height of the given line. */
fun getLineHeight(lineIndex: Int): Float {
requireLineIndexInRange(lineIndex)
val paragraphIndex = findParagraphByLineIndex(paragraphInfoList, lineIndex)
return with(paragraphInfoList[paragraphIndex]) {
paragraph.getLineHeight(lineIndex.toLocalLineIndex())
}
}
/** Returns the width of the given line. */
fun getLineWidth(lineIndex: Int): Float {
requireLineIndexInRange(lineIndex)
val paragraphIndex = findParagraphByLineIndex(paragraphInfoList, lineIndex)
return with(paragraphInfoList[paragraphIndex]) {
paragraph.getLineWidth(lineIndex.toLocalLineIndex())
}
}
private fun requireIndexInRange(offset: Int) {
require(offset in annotatedString.text.indices) {
"offset($offset) is out of bounds [0, ${annotatedString.text.length})"
}
}
private fun requireIndexInRangeInclusiveEnd(offset: Int) {
require(offset in 0..annotatedString.text.length) {
"offset($offset) is out of bounds [0, ${annotatedString.text.length}]"
}
}
private fun requireLineIndexInRange(lineIndex: Int) {
require(lineIndex in 0 until lineCount) {
"lineIndex($lineIndex) is out of bounds [0, $lineIndex)"
}
}
}
/**
* Given an character index of [MultiParagraph.annotatedString], find the corresponding
* [ParagraphInfo] which covers the provided index.
*
* @param paragraphInfoList The list of [ParagraphInfo] containing the information of each
* paragraph in the [MultiParagraph].
* @param index The target index in the [MultiParagraph]. It should be in the range of
* [0, text.length)
* @return The index of the target [ParagraphInfo] in [paragraphInfoList].
*/
internal fun findParagraphByIndex(paragraphInfoList: List<ParagraphInfo>, index: Int): Int {
return paragraphInfoList.binarySearch { paragraphInfo ->
when {
paragraphInfo.startIndex > index -> 1
paragraphInfo.endIndex <= index -> -1
else -> 0
}
}
}
/**
* Given the y graphical position relative to this [MultiParagraph], find the index of the
* corresponding [ParagraphInfo] which occupies the provided position.
*
* @param paragraphInfoList The list of [ParagraphInfo] containing the information of each
* paragraph in the [MultiParagraph].
* @param y The y coordinate position relative to the [MultiParagraph]. It should be in the range
* of [0, [MultiParagraph.height]].
* @return The index of the target [ParagraphInfo] in [paragraphInfoList].
*/
internal fun findParagraphByY(paragraphInfoList: List<ParagraphInfo>, y: Px): Int {
return paragraphInfoList.binarySearch { paragraphInfo ->
when {
paragraphInfo.top > y -> 1
paragraphInfo.bottom <= y -> -1
else -> 0
}
}
}
/**
* Given an line index in [MultiParagraph], find the corresponding [ParagraphInfo] which
* covers the provided line index.
*
* @param paragraphInfoList The list of [ParagraphInfo] containing the information of each
* paragraph in the [MultiParagraph].
* @param lineIndex The target line index in the [MultiParagraph], it should be in the range of
* [0, [MultiParagraph.lineCount])
* @return The index of the target [ParagraphInfo] in [paragraphInfoList].
*/
internal fun findParagraphByLineIndex(paragraphInfoList: List<ParagraphInfo>, lineIndex: Int): Int {
return paragraphInfoList.binarySearch { paragraphInfo ->
when {
paragraphInfo.startLineIndex > lineIndex -> 1
paragraphInfo.endLineIndex <= lineIndex -> -1
else -> 0
}
}
}
/**
* This is a helper data structure to store the information of a single [Paragraph] in an
* [MultiParagraph]. It's mainly used to convert a global index, lineNumber and [Offset] to the
* local ones inside the [paragraph], and vice versa.
*
* @param paragraph The [Paragraph] object corresponding to this [ParagraphInfo].
* @param startIndex The start index of this paragraph in the parent [MultiParagraph], inclusive.
* @param endIndex The end index of this paragraph in the parent [MultiParagraph], exclusive.
* @param startLineIndex The start line index of this paragraph in the parent [MultiParagraph],
* inclusive.
* @param endLineIndex The end line index of this paragraph in the parent [MultiParagraph],
* exclusive.
* @param top The top position of the [paragraph] relative to the parent [MultiParagraph].
* @param bottom The bottom position of the [paragraph] relative to the parent [MultiParagraph].
*/
internal data class ParagraphInfo(
val paragraph: Paragraph,
val startIndex: Int,
val endIndex: Int,
var startLineIndex: Int = -1,
var endLineIndex: Int = -1,
var top: Px = (-1).px,
var bottom: Px = (-1).px
) {
/**
* The length of the text in the covered by this paragraph.
*/
val length
get() = endIndex - startIndex
/**
* Convert an index in the parent [MultiParagraph] to the local index in the [paragraph].
*/
fun Int.toLocalIndex(): Int {
return this.coerceIn(startIndex, endIndex) - startIndex
}
/**
* Convert a local index in the [paragraph] to the global index in the parent [MultiParagraph].
*/
fun Int.toGlobalIndex(): Int {
return this + startIndex
}
/**
* Convert a line index in the parent [MultiParagraph] to the local line index in the
* [paragraph].
*
*/
fun Int.toLocalLineIndex(): Int {
return this - startLineIndex
}
/**
* Convert a local line index in the [paragraph] to the global line index in the parent
* [MultiParagraph].
*/
fun Int.toGlobalLineIndex(): Int {
return this + startLineIndex
}
/**
* Convert a local y position relative to [paragraph] to the globla y postiion relative to the
* parent [MultiParagraph].
*/
fun Float.toGlobalYPosition(): Float {
return this + top.value
}
/**
* Convert a [PxPosition] relative to the parent [MultiParagraph] to the local [PxPosition]
* relative to the [paragraph].
*/
fun PxPosition.toLocal(): PxPosition {
return PxPosition(x = x, y = y - top)
}
/**
* Convert a [Rect] relative to the [paragraph] to the [Rect] relative to the parent
* [MultiParagraph].
*/
fun Rect.toGlobal(): Rect {
return shift(Offset(dx = 0f, dy = this@ParagraphInfo.top.value))
}
/**
* Convert a [Path] relative to the [paragraph] to the [Path] relative to the parent
* [MultiParagraph].
*
* Notice that this function changes the input value.
*/
fun Path.toGlobal(): Path {
shift(Offset(dx = 0f, dy = top.value))
return this
}
/**
* Convert a [TextRange] in to the [paragraph] to the [TextRange] in the parent
* [MultiParagraph].
*/
fun TextRange.toGlobal(): TextRange {
return TextRange(start = start.toGlobalIndex(), end = end.toGlobalIndex())
}
}