1 /*
<lambda>null2 * Copyright 2019 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package androidx.compose.ui.text
18
19 import androidx.annotation.IntRange
20 import androidx.compose.ui.geometry.Offset
21 import androidx.compose.ui.geometry.Rect
22 import androidx.compose.ui.graphics.BlendMode
23 import androidx.compose.ui.graphics.Brush
24 import androidx.compose.ui.graphics.Canvas
25 import androidx.compose.ui.graphics.Color
26 import androidx.compose.ui.graphics.Path
27 import androidx.compose.ui.graphics.Shadow
28 import androidx.compose.ui.graphics.drawscope.DrawScope
29 import androidx.compose.ui.graphics.drawscope.DrawStyle
30 import androidx.compose.ui.text.font.Font
31 import androidx.compose.ui.text.font.FontFamily
32 import androidx.compose.ui.text.font.createFontFamilyResolver
33 import androidx.compose.ui.text.internal.requirePrecondition
34 import androidx.compose.ui.text.platform.drawMultiParagraph
35 import androidx.compose.ui.text.style.ResolvedTextDirection
36 import androidx.compose.ui.text.style.TextDecoration
37 import androidx.compose.ui.text.style.TextOverflow
38 import androidx.compose.ui.unit.Constraints
39 import androidx.compose.ui.unit.Density
40 import androidx.compose.ui.util.fastFlatMap
41 import androidx.compose.ui.util.fastForEach
42 import androidx.compose.ui.util.fastJoinToString
43 import androidx.compose.ui.util.fastMap
44
45 /**
46 * Lays out and renders multiple paragraphs at once. Unlike [Paragraph], supports multiple
47 * [ParagraphStyle]s in a given text.
48 *
49 * @param intrinsics previously calculated text intrinsics
50 * @param constraints how wide and tall the text is allowed to be. [Constraints.maxWidth] will
51 * define the width of the MultiParagraph. [Constraints.maxHeight] helps defining the number of
52 * lines that fit with ellipsis is true. Minimum components of the [Constraints] object are no-op.
53 * @param maxLines the maximum number of lines that the text can have
54 * @param overflow configures how visual overflow is handled. Ellipsis is applied only when
55 * [maxLines] is set
56 */
57 class MultiParagraph(
58 val intrinsics: MultiParagraphIntrinsics,
59 constraints: Constraints,
60 val maxLines: Int = DefaultMaxLines,
61 overflow: TextOverflow = TextOverflow.Clip,
62 ) {
63
64 /**
65 * Lays out and renders multiple paragraphs at once. Unlike [Paragraph], supports multiple
66 * [ParagraphStyle]s in a given text.
67 *
68 * @param intrinsics previously calculated text intrinsics
69 * @param constraints how wide and tall the text is allowed to be. [Constraints.maxWidth] will
70 * define the width of the MultiParagraph. [Constraints.maxHeight] helps defining the number
71 * of lines that fit with ellipsis is true. Minimum components of the [Constraints] object are
72 * no-op.
73 * @param maxLines the maximum number of lines that the text can have
74 * @param ellipsis whether to ellipsize text, applied only when [maxLines] is set
75 */
76 @Deprecated(
77 "Constructor with `ellipsis: Boolean` is deprecated, pass TextOverflow instead",
78 level = DeprecationLevel.HIDDEN
79 )
80 constructor(
81 intrinsics: MultiParagraphIntrinsics,
82 constraints: Constraints,
83 maxLines: Int = DefaultMaxLines,
84 ellipsis: Boolean = false,
85 ) : this(
86 intrinsics = intrinsics,
87 constraints = constraints,
88 maxLines = maxLines,
89 overflow = if (ellipsis) TextOverflow.Ellipsis else TextOverflow.Clip
90 )
91
92 /**
93 * Lays out and renders multiple paragraphs at once. Unlike [Paragraph], supports multiple
94 * [ParagraphStyle]s in a given text.
95 *
96 * @param intrinsics previously calculated text intrinsics
97 * @param maxLines the maximum number of lines that the text can have
98 * @param ellipsis whether to ellipsize text, applied only when [maxLines] is set
99 * @param width how wide the text is allowed to be
100 */
101 @Deprecated(
102 "MultiParagraph that takes maximum allowed width is deprecated, pass constraints instead.",
103 ReplaceWith(
104 "MultiParagraph(intrinsics, Constraints(maxWidth = ceil(width).toInt()), " +
105 "maxLines, ellipsis)",
106 "kotlin.math.ceil",
107 "androidx.compose.ui.unit.Constraints"
108 )
109 )
110 constructor(
111 intrinsics: MultiParagraphIntrinsics,
112 maxLines: Int = DefaultMaxLines,
113 ellipsis: Boolean = false,
114 width: Float
115 ) : this(
116 intrinsics,
117 Constraints(maxWidth = width.ceilToInt()),
118 maxLines,
119 if (ellipsis) TextOverflow.Ellipsis else TextOverflow.Clip
120 )
121
122 /**
123 * Lays out a given [annotatedString] with the given constraints. Unlike a [Paragraph],
124 * [MultiParagraph] can handle a text what has multiple paragraph styles.
125 *
126 * @param annotatedString the text to be laid out
127 * @param style the [TextStyle] to be applied to the whole text
128 * @param placeholders a list of [Placeholder]s that specify ranges of text which will be
129 * skipped during layout and replaced with [Placeholder]. It's required that the range of each
130 * [Placeholder] doesn't cross paragraph boundary, otherwise [IllegalArgumentException] is
131 * thrown.
132 * @param maxLines the maximum number of lines that the text can have
133 * @param ellipsis whether to ellipsize text, applied only when [maxLines] is set
134 * @param width how wide the text is allowed to be
135 * @param density density of the device
136 * @param resourceLoader [Font.ResourceLoader] to be used to load the font given in [SpanStyle]s
137 * @throws IllegalArgumentException if [ParagraphStyle.textDirection] is not set, or any of the
138 * [placeholders] crosses paragraph boundary.
139 * @see Placeholder
140 */
141 @Suppress("DEPRECATION")
142 @Deprecated(
143 "Font.ResourceLoader is deprecated, use fontFamilyResolver instead",
144 replaceWith =
145 ReplaceWith(
146 "MultiParagraph(annotatedString, style, " +
147 "placeholders, maxLines, ellipsis, width, density, fontFamilyResolver)"
148 )
149 )
150 constructor(
151 annotatedString: AnnotatedString,
152 style: TextStyle,
153 placeholders: List<AnnotatedString.Range<Placeholder>> = listOf(),
154 maxLines: Int = Int.MAX_VALUE,
155 ellipsis: Boolean = false,
156 width: Float,
157 density: Density,
158 resourceLoader: Font.ResourceLoader
159 ) : this(
160 intrinsics =
161 MultiParagraphIntrinsics(
162 annotatedString = annotatedString,
163 style = style,
164 placeholders = placeholders,
165 density = density,
166 fontFamilyResolver = createFontFamilyResolver(resourceLoader)
167 ),
168 maxLines = maxLines,
169 overflow = if (ellipsis) TextOverflow.Ellipsis else TextOverflow.Clip,
170 constraints = Constraints(maxWidth = width.ceilToInt())
171 )
172
173 /**
174 * Lays out a given [annotatedString] with the given constraints. Unlike a [Paragraph],
175 * [MultiParagraph] can handle a text what has multiple paragraph styles.
176 *
177 * @param annotatedString the text to be laid out
178 * @param style the [TextStyle] to be applied to the whole text
179 * @param width how wide the text is allowed to be
180 * @param density density of the device
181 * @param fontFamilyResolver to be used to load the font given in [SpanStyle]s
182 * @param placeholders a list of [Placeholder]s that specify ranges of text which will be
183 * skipped during layout and replaced with [Placeholder]. It's required that the range of each
184 * [Placeholder] doesn't cross paragraph boundary, otherwise [IllegalArgumentException] is
185 * thrown.
186 * @param maxLines the maximum number of lines that the text can have
187 * @param ellipsis whether to ellipsize text, applied only when [maxLines] is set
188 * @throws IllegalArgumentException if [ParagraphStyle.textDirection] is not set, or any of the
189 * [placeholders] crosses paragraph boundary.
190 * @see Placeholder
191 */
192 @Deprecated(
193 "MultiParagraph that takes maximum allowed width is deprecated, pass constraints instead.",
194 ReplaceWith(
195 "MultiParagraph(annotatedString, style, Constraints(maxWidth = ceil(width).toInt()), " +
196 "density, fontFamilyResolver, placeholders, maxLines, ellipsis)",
197 "kotlin.math.ceil",
198 "androidx.compose.ui.unit.Constraints"
199 )
200 )
201 constructor(
202 annotatedString: AnnotatedString,
203 style: TextStyle,
204 width: Float,
205 density: Density,
206 fontFamilyResolver: FontFamily.Resolver,
207 placeholders: List<AnnotatedString.Range<Placeholder>> = listOf(),
208 maxLines: Int = Int.MAX_VALUE,
209 ellipsis: Boolean = false
210 ) : this(
211 intrinsics =
212 MultiParagraphIntrinsics(
213 annotatedString = annotatedString,
214 style = style,
215 placeholders = placeholders,
216 density = density,
217 fontFamilyResolver = fontFamilyResolver
218 ),
219 maxLines = maxLines,
220 overflow = if (ellipsis) TextOverflow.Ellipsis else TextOverflow.Clip,
221 constraints = Constraints(maxWidth = width.ceilToInt())
222 )
223
224 /**
225 * Lays out a given [annotatedString] with the given constraints. Unlike a [Paragraph],
226 * [MultiParagraph] can handle a text what has multiple paragraph styles.
227 *
228 * @param annotatedString the text to be laid out
229 * @param style the [TextStyle] to be applied to the whole text
230 * @param constraints how wide and tall the text is allowed to be. [Constraints.maxWidth] will
231 * define the width of the MultiParagraph. [Constraints.maxHeight] helps defining the number
232 * of lines that fit with ellipsis is true. Minimum components of the [Constraints] object are
233 * no-op.
234 * @param density density of the device
235 * @param fontFamilyResolver to be used to load the font given in [SpanStyle]s
236 * @param placeholders a list of [Placeholder]s that specify ranges of text which will be
237 * skipped during layout and replaced with [Placeholder]. It's required that the range of each
238 * [Placeholder] doesn't cross paragraph boundary, otherwise [IllegalArgumentException] is
239 * thrown.
240 * @param maxLines the maximum number of lines that the text can have
241 * @param ellipsis whether to ellipsize text, applied only when [maxLines] is set
242 * @throws IllegalArgumentException if [ParagraphStyle.textDirection] is not set, or any of the
243 * [placeholders] crosses paragraph boundary.
244 * @see Placeholder
245 */
246 @Deprecated(
247 "Constructor with `ellipsis: Boolean` is deprecated, pass TextOverflow instead",
248 level = DeprecationLevel.HIDDEN
249 )
250 constructor(
251 annotatedString: AnnotatedString,
252 style: TextStyle,
253 constraints: Constraints,
254 density: Density,
255 fontFamilyResolver: FontFamily.Resolver,
256 placeholders: List<AnnotatedString.Range<Placeholder>> = listOf(),
257 maxLines: Int = Int.MAX_VALUE,
258 ellipsis: Boolean = false
259 ) : this(
260 intrinsics =
261 MultiParagraphIntrinsics(
262 annotatedString = annotatedString,
263 style = style,
264 placeholders = placeholders,
265 density = density,
266 fontFamilyResolver = fontFamilyResolver
267 ),
268 maxLines = maxLines,
269 overflow = if (ellipsis) TextOverflow.Ellipsis else TextOverflow.Clip,
270 constraints = constraints
271 )
272
273 /**
274 * Lays out a given [annotatedString] with the given constraints. Unlike a [Paragraph],
275 * [MultiParagraph] can handle a text what has multiple paragraph styles.
276 *
277 * @param annotatedString the text to be laid out
278 * @param style the [TextStyle] to be applied to the whole text
279 * @param constraints how wide and tall the text is allowed to be. [Constraints.maxWidth] will
280 * define the width of the MultiParagraph. [Constraints.maxHeight] helps defining the number
281 * of lines that fit with ellipsis is true. Minimum components of the [Constraints] object are
282 * no-op.
283 * @param density density of the device
284 * @param fontFamilyResolver to be used to load the font given in [SpanStyle]s
285 * @param placeholders a list of [Placeholder]s that specify ranges of text which will be
286 * skipped during layout and replaced with [Placeholder]. It's required that the range of each
287 * [Placeholder] doesn't cross paragraph boundary, otherwise [IllegalArgumentException] is
288 * thrown.
289 * @param maxLines the maximum number of lines that the text can have
290 * @param overflow configures how visual overflow is handled. Ellipsis is applied only when
291 * [maxLines] is set
292 * @throws IllegalArgumentException if [ParagraphStyle.textDirection] is not set, or any of the
293 * [placeholders] crosses paragraph boundary.
294 * @see Placeholder
295 */
296 constructor(
297 annotatedString: AnnotatedString,
298 style: TextStyle,
299 constraints: Constraints,
300 density: Density,
301 fontFamilyResolver: FontFamily.Resolver,
302 placeholders: List<AnnotatedString.Range<Placeholder>> = listOf(),
303 maxLines: Int = Int.MAX_VALUE,
304 overflow: TextOverflow = TextOverflow.Clip
305 ) : this(
306 intrinsics =
307 MultiParagraphIntrinsics(
308 annotatedString = annotatedString,
309 style = style,
310 placeholders = placeholders,
311 density = density,
312 fontFamilyResolver = fontFamilyResolver
313 ),
314 maxLines = maxLines,
315 overflow = overflow,
316 constraints = constraints
317 )
318
319 private val annotatedString
320 get() = intrinsics.annotatedString
321
322 /** The width for text if all soft wrap opportunities were taken. */
323 val minIntrinsicWidth: Float
324 get() = intrinsics.minIntrinsicWidth
325
326 /** Returns the smallest width beyond which increasing the width never decreases the height. */
327 val maxIntrinsicWidth: Float
328 get() = intrinsics.maxIntrinsicWidth
329
330 /**
331 * True if there is more vertical content, but the text was truncated, either because we reached
332 * `maxLines` lines of text or because the `maxLines` was null, `ellipsis` was not null, and one
333 * of the lines exceeded the width constraint.
334 */
335 val didExceedMaxLines: Boolean
336
337 /** The amount of horizontal space this paragraph occupies. */
338 val width: Float
339
340 /**
341 * The amount of vertical space this paragraph occupies.
342 *
343 * Valid only after layout has been called.
344 */
345 val height: Float
346
347 /**
348 * The distance from the top of the paragraph to the alphabetic baseline of the first line, in
349 * logical pixels.
350 */
351 val firstBaseline: Float
352 get() {
353 return if (paragraphInfoList.isEmpty()) {
354 0f
355 } else {
356 paragraphInfoList[0].paragraph.firstBaseline
357 }
358 }
359
360 /**
361 * The distance from the top of the paragraph to the alphabetic baseline of the first line, in
362 * logical pixels.
363 */
364 val lastBaseline: Float
365 get() {
366 return if (paragraphInfoList.isEmpty()) {
367 0f
368 } else {
369 with(paragraphInfoList.last()) { paragraph.lastBaseline.toGlobalYPosition() }
370 }
371 }
372
373 /** The total number of lines in the text. */
374 val lineCount: Int
375
376 /**
377 * The bounding boxes reserved for the input placeholders in this MultiParagraph. Their
378 * locations are relative to this MultiParagraph's coordinate. The order of this list
379 * corresponds to that of input placeholders. Notice that [Rect] in [placeholderRects] is
380 * nullable. When [Rect] is null, it indicates that the corresponding [Placeholder] is
381 * ellipsized.
382 */
383 val placeholderRects: List<Rect?>
384
385 /* This is internal for testing purpose. */
386 internal val paragraphInfoList: List<ParagraphInfo>
387
388 init {
389 requirePrecondition(constraints.minWidth == 0 && constraints.minHeight == 0) {
390 "Setting Constraints.minWidth and Constraints.minHeight is not supported, " +
391 "these should be the default zero values instead."
392 }
393
394 var currentHeight = 0f
395 var currentLineCount = 0
396 var didExceedMaxLines = false
397
398 // create sub paragraphs and layouts
399 val paragraphInfoList = mutableListOf<ParagraphInfo>()
400 val infoList = intrinsics.infoList
401 for (index in infoList.indices) {
402 val paragraphInfo = infoList[index]
403 val paragraph =
404 Paragraph(
405 paragraphInfo.intrinsics,
406 Constraints(
407 maxWidth = constraints.maxWidth,
408 maxHeight =
409 if (constraints.hasBoundedHeight) {
410 (constraints.maxHeight - currentHeight.ceilToInt()).coerceAtLeast(0)
411 } else {
412 constraints.maxHeight
413 }
414 ),
415 maxLines - currentLineCount,
416 overflow,
417 )
418
419 val paragraphTop = currentHeight
420 val paragraphBottom = currentHeight + paragraph.height
421 currentHeight = paragraphBottom
422
423 val startLineIndex = currentLineCount
424 val endLineIndex = startLineIndex + paragraph.lineCount
425 currentLineCount = endLineIndex
426
427 paragraphInfoList.add(
428 ParagraphInfo(
429 paragraph = paragraph,
430 startIndex = paragraphInfo.startIndex,
431 endIndex = paragraphInfo.endIndex,
432 startLineIndex = startLineIndex,
433 endLineIndex = endLineIndex,
434 top = paragraphTop,
435 bottom = paragraphBottom
436 )
437 )
438
439 if (
440 paragraph.didExceedMaxLines ||
441 (endLineIndex == maxLines && index != intrinsics.infoList.lastIndex)
442 ) {
443 didExceedMaxLines = true
444 break
445 }
446 }
447
448 this.height = currentHeight
449 this.lineCount = currentLineCount
450 this.didExceedMaxLines = didExceedMaxLines
451 this.paragraphInfoList = paragraphInfoList
452 this.width = constraints.maxWidth.toFloat()
453 this.placeholderRects =
454 paragraphInfoList
455 .fastFlatMap { paragraphInfo ->
456 with(paragraphInfo) { paragraph.placeholderRects.fastMap { it?.toGlobal() } }
457 }
458 .let {
459 // When paragraphs get ellipsized, the size of this list will be smaller than
460 // the input placeholders. In this case, fill this list with null so that it has
461 // the
462 // same size as the input placeholders.
463 if (it.size < intrinsics.placeholders.size) {
464 it + List(intrinsics.placeholders.size - it.size) { null }
465 } else {
466 it
467 }
468 }
469 }
470
471 /** Paint the paragraphs to canvas. */
472 @Deprecated(
473 "Use the new paint function that takes canvas as the only required parameter.",
474 level = DeprecationLevel.HIDDEN
475 )
476 fun paint(
477 canvas: Canvas,
478 color: Color = Color.Unspecified,
479 shadow: Shadow? = null,
480 decoration: TextDecoration? = null
481 ) {
482 canvas.save()
483 paragraphInfoList.fastForEach {
484 it.paragraph.paint(canvas, color, shadow, decoration)
485 canvas.translate(0f, it.paragraph.height)
486 }
487 canvas.restore()
488 }
489
490 /** Paint the paragraphs to canvas. */
491 fun paint(
492 canvas: Canvas,
493 color: Color = Color.Unspecified,
494 shadow: Shadow? = null,
495 decoration: TextDecoration? = null,
496 drawStyle: DrawStyle? = null,
497 blendMode: BlendMode = DrawScope.DefaultBlendMode
498 ) {
499 canvas.save()
500 paragraphInfoList.fastForEach {
501 it.paragraph.paint(canvas, color, shadow, decoration, drawStyle, blendMode)
502 canvas.translate(0f, it.paragraph.height)
503 }
504 canvas.restore()
505 }
506
507 /** Paint the paragraphs to canvas. */
508 fun paint(
509 canvas: Canvas,
510 brush: Brush,
511 alpha: Float = Float.NaN,
512 shadow: Shadow? = null,
513 decoration: TextDecoration? = null,
514 drawStyle: DrawStyle? = null,
515 blendMode: BlendMode = DrawScope.DefaultBlendMode
516 ) {
517 drawMultiParagraph(canvas, brush, alpha, shadow, decoration, drawStyle, blendMode)
518 }
519
520 /** Returns path that enclose the given text range. */
521 fun getPathForRange(start: Int, end: Int): Path {
522 requirePrecondition(start in 0..end && end <= annotatedString.text.length) {
523 "Start($start) or End($end) is out of range [0..${annotatedString.text.length})," +
524 " or start > end!"
525 }
526
527 if (start == end) return Path()
528
529 val path = Path()
530 findParagraphsByRange(paragraphInfoList, TextRange(start, end)) { paragraphInfo ->
531 with(paragraphInfo) {
532 path.addPath(
533 path =
534 paragraph
535 .getPathForRange(start = start.toLocalIndex(), end = end.toLocalIndex())
536 .toGlobal()
537 )
538 }
539 }
540
541 return path
542 }
543
544 /**
545 * Returns line number closest to the given graphical vertical position. If you ask for a
546 * vertical position before 0, you get 0; if you ask for a vertical position beyond the last
547 * line, you get the last line.
548 */
549 fun getLineForVerticalPosition(vertical: Float): Int {
550 val paragraphIndex = findParagraphByY(paragraphInfoList, vertical)
551 return with(paragraphInfoList[paragraphIndex]) {
552 if (length == 0) {
553 startLineIndex
554 } else {
555 paragraph
556 .getLineForVerticalPosition(vertical.toLocalYPosition())
557 .toGlobalLineIndex()
558 }
559 }
560 }
561
562 /** Returns the character offset closest to the given graphical position. */
563 fun getOffsetForPosition(position: Offset): Int {
564 val paragraphIndex = findParagraphByY(paragraphInfoList, position.y)
565 return with(paragraphInfoList[paragraphIndex]) {
566 if (length == 0) {
567 startIndex
568 } else {
569 paragraph.getOffsetForPosition(position.toLocal()).toGlobalIndex()
570 }
571 }
572 }
573
574 /**
575 * Find the range of text which is inside the specified [rect]. This method will break text into
576 * small text segments based on the given [granularity] such as character or word. It also
577 * support different [inclusionStrategy], which determines when a small text segments is
578 * considered as inside the [rect]. Note that the word/character breaking is both operating
579 * system and language dependent. In the certain cases, the text may be break into smaller
580 * segments than the specified the [granularity]. If a text segment spans multiple lines or
581 * multiple directional runs (e.g. a hyphenated word), the text segment is divided into pieces
582 * at the line and run breaks, then the text segment is considered to be inside the area if any
583 * of its pieces are inside the area.
584 *
585 * @param rect the rectangle area in which the text range will be found.
586 * @param granularity the granularity of the text, it controls how text is segmented.
587 * @param inclusionStrategy the strategy that determines whether a range of text's bounds is
588 * inside the given [rect] or not.
589 * @return the [TextRange] that is inside the given [rect], or [TextRange.Zero] if no text is
590 * found.
591 */
592 fun getRangeForRect(
593 rect: Rect,
594 granularity: TextGranularity,
595 inclusionStrategy: TextInclusionStrategy
596 ): TextRange {
597 var firstParagraph = findParagraphByY(paragraphInfoList, rect.top)
598 // The first paragraph contains the entire rect, return early in this case.
599 if (
600 paragraphInfoList[firstParagraph].bottom >= rect.bottom ||
601 firstParagraph == paragraphInfoList.lastIndex
602 ) {
603 return with(paragraphInfoList[firstParagraph]) {
604 paragraph.getRangeForRect(rect.toLocal(), granularity, inclusionStrategy).toGlobal()
605 }
606 }
607
608 var lastParagraph = findParagraphByY(paragraphInfoList, rect.bottom)
609
610 var startRange: TextRange = TextRange.Zero
611 while (startRange == TextRange.Zero && firstParagraph <= lastParagraph) {
612 startRange =
613 with(paragraphInfoList[firstParagraph]) {
614 paragraph
615 .getRangeForRect(rect.toLocal(), granularity, inclusionStrategy)
616 .toGlobal()
617 }
618 ++firstParagraph
619 }
620
621 if (startRange == TextRange.Zero) {
622 return TextRange.Zero
623 }
624
625 var endRange: TextRange = TextRange.Zero
626 while (endRange == TextRange.Zero && firstParagraph <= lastParagraph) {
627 endRange =
628 with(paragraphInfoList[lastParagraph]) {
629 paragraph
630 .getRangeForRect(rect.toLocal(), granularity, inclusionStrategy)
631 .toGlobal()
632 }
633 --lastParagraph
634 }
635
636 if (endRange == TextRange.Zero) return startRange
637 return TextRange(startRange.start, endRange.end)
638 }
639
640 /**
641 * Returns the bounding box as Rect of the character for given character offset. Rect includes
642 * the top, bottom, left and right of a character.
643 */
644 fun getBoundingBox(offset: Int): Rect {
645 requireIndexInRange(offset)
646
647 val paragraphIndex = findParagraphByIndex(paragraphInfoList, offset)
648 return with(paragraphInfoList[paragraphIndex]) {
649 paragraph.getBoundingBox(offset.toLocalIndex()).toGlobal()
650 }
651 }
652
653 /**
654 * Fills the bounding boxes for characters provided in the [range] into [array]. The array is
655 * filled starting from [arrayStart] (inclusive). The coordinates are in local text layout
656 * coordinates.
657 *
658 * The returned information consists of left/right of a character; line top and bottom for the
659 * same character.
660 *
661 * For the grapheme consists of multiple code points, e.g. ligatures, combining marks, the first
662 * character has the total width and the remaining are returned as zero-width.
663 *
664 * The array divided into segments of four where each index in that segment represents left,
665 * top, right, bottom of the character.
666 *
667 * The size of the provided [array] should be greater or equal than the four times * [TextRange]
668 * length.
669 *
670 * The final order of characters in the [array] is from [TextRange.min] to [TextRange.max].
671 *
672 * @param range the [TextRange] representing the start and end indices in the [Paragraph].
673 * @param array the array to fill in the values. The array divided into segments of four where
674 * each index in that segment represents left, top, right, bottom of the character.
675 * @param arrayStart the inclusive start index in the array where the function will start
676 * filling in the values from
677 */
678 fun fillBoundingBoxes(
679 range: TextRange,
680 array: FloatArray,
681 @IntRange(from = 0) arrayStart: Int
682 ): FloatArray {
683 requireIndexInRange(range.min)
684 requireIndexInRangeInclusiveEnd(range.max)
685
686 var currentArrayStart = arrayStart
687 var currentHeight = 0f
688 findParagraphsByRange(paragraphInfoList, range) { paragraphInfo ->
689 with(paragraphInfo) {
690 val paragraphStart = if (startIndex > range.min) startIndex else range.min
691 val paragraphEnd = if (endIndex < range.max) endIndex else range.max
692 val finalRange =
693 TextRange(paragraphStart.toLocalIndex(), paragraphEnd.toLocalIndex())
694 paragraph.fillBoundingBoxes(finalRange, array, currentArrayStart)
695 val currentArrayEnd = currentArrayStart + finalRange.length * 4
696 var arrayIndex = currentArrayStart
697 while (arrayIndex < currentArrayEnd) {
698 // update top and bottom
699 array[arrayIndex + 1] += currentHeight
700 array[arrayIndex + 3] += currentHeight
701 arrayIndex += 4
702 }
703 currentArrayStart = currentArrayEnd
704 currentHeight += paragraphInfo.paragraph.height
705 }
706 }
707
708 return array
709 }
710
711 /**
712 * Compute the horizontal position where a newly inserted character at [offset] would be.
713 *
714 * If the inserted character at [offset] is within a LTR/RTL run, the returned position will be
715 * the left(right) edge of the character.
716 *
717 * ```
718 * For example:
719 * Paragraph's direction is LTR.
720 * Text in logic order: L0 L1 L2 R3 R4 R5
721 * Text in visual order: L0 L1 L2 R5 R4 R3
722 * position of the offset(2): |
723 * position of the offset(4): |
724 * ```
725 *
726 * However, when the [offset] is at the BiDi transition offset, there will be two possible
727 * visual positions, which depends on the direction of the inserted character.
728 *
729 * ```
730 * For example:
731 * Paragraph's direction is LTR.
732 * Text in logic order: L0 L1 L2 R3 R4 R5
733 * Text in visual order: L0 L1 L2 R5 R4 R3
734 * position of the offset(3): | (The inserted character is LTR)
735 * | (The inserted character is RTL)
736 * ```
737 *
738 * In this case, [usePrimaryDirection] will be used to resolve the ambiguity. If true, the
739 * inserted character's direction is assumed to be the same as Paragraph's direction. Otherwise,
740 * the inserted character's direction is assumed to be the opposite of the Paragraph's
741 * direction.
742 *
743 * ```
744 * For example:
745 * Paragraph's direction is LTR.
746 * Text in logic order: L0 L1 L2 R3 R4 R5
747 * Text in visual order: L0 L1 L2 R5 R4 R3
748 * position of the offset(3): | (usePrimaryDirection is true)
749 * | (usePrimaryDirection is false)
750 * ```
751 *
752 * This method is useful to compute cursor position.
753 *
754 * @param offset the offset of the character, in the range of [0, length].
755 * @param usePrimaryDirection whether the paragraph direction is respected when [offset] points
756 * to a BiDi transition point.
757 * @return a float number representing the horizontal position in the unit of pixel.
758 */
759 fun getHorizontalPosition(offset: Int, usePrimaryDirection: Boolean): Float {
760 requireIndexInRangeInclusiveEnd(offset)
761
762 val paragraphIndex =
763 if (offset == annotatedString.length) {
764 paragraphInfoList.lastIndex
765 } else {
766 findParagraphByIndex(paragraphInfoList, offset)
767 }
768
769 return with(paragraphInfoList[paragraphIndex]) {
770 paragraph.getHorizontalPosition(offset.toLocalIndex(), usePrimaryDirection)
771 }
772 }
773
774 /** Get the text direction of the paragraph containing the given offset. */
775 fun getParagraphDirection(offset: Int): ResolvedTextDirection {
776 requireIndexInRangeInclusiveEnd(offset)
777
778 val paragraphIndex =
779 if (offset == annotatedString.length) {
780 paragraphInfoList.lastIndex
781 } else {
782 findParagraphByIndex(paragraphInfoList, offset)
783 }
784
785 return with(paragraphInfoList[paragraphIndex]) {
786 paragraph.getParagraphDirection(offset.toLocalIndex())
787 }
788 }
789
790 /** Get the text direction of the character at the given offset. */
791 fun getBidiRunDirection(offset: Int): ResolvedTextDirection {
792 requireIndexInRangeInclusiveEnd(offset)
793
794 val paragraphIndex =
795 if (offset == annotatedString.length) {
796 paragraphInfoList.lastIndex
797 } else {
798 findParagraphByIndex(paragraphInfoList, offset)
799 }
800
801 return with(paragraphInfoList[paragraphIndex]) {
802 paragraph.getBidiRunDirection(offset.toLocalIndex())
803 }
804 }
805
806 /**
807 * Returns the TextRange of the word at the given character offset. Characters not part of a
808 * word, such as spaces, symbols, and punctuation, have word breaks on both sides. In such
809 * cases, this method will return TextRange(offset, offset+1). Word boundaries are defined more
810 * precisely in Unicode Standard Annex #29 http://www.unicode.org/reports/tr29/#Word_Boundaries
811 */
812 fun getWordBoundary(offset: Int): TextRange {
813 requireIndexInRangeInclusiveEnd(offset)
814
815 val paragraphIndex =
816 if (offset == annotatedString.length) {
817 paragraphInfoList.lastIndex
818 } else {
819 findParagraphByIndex(paragraphInfoList, offset)
820 }
821
822 return with(paragraphInfoList[paragraphIndex]) {
823 paragraph.getWordBoundary(offset.toLocalIndex()).toGlobal(treatZeroAsNull = false)
824 }
825 }
826
827 /** Returns rectangle of the cursor area. */
828 fun getCursorRect(offset: Int): Rect {
829 requireIndexInRangeInclusiveEnd(offset)
830
831 val paragraphIndex =
832 if (offset == annotatedString.length) {
833 paragraphInfoList.lastIndex
834 } else {
835 findParagraphByIndex(paragraphInfoList, offset)
836 }
837
838 return with(paragraphInfoList[paragraphIndex]) {
839 paragraph.getCursorRect(offset.toLocalIndex()).toGlobal()
840 }
841 }
842
843 /**
844 * Returns the line number on which the specified text offset appears. If you ask for a position
845 * before 0, you get 0; if you ask for a position beyond the end of the text, you get the last
846 * line.
847 */
848 fun getLineForOffset(offset: Int): Int {
849 val paragraphIndex =
850 if (offset >= annotatedString.length) {
851 paragraphInfoList.lastIndex
852 } else if (offset < 0) {
853 0
854 } else {
855 findParagraphByIndex(paragraphInfoList, offset)
856 }
857 return with(paragraphInfoList[paragraphIndex]) {
858 paragraph.getLineForOffset(offset.toLocalIndex()).toGlobalLineIndex()
859 }
860 }
861
862 /** Returns the left x Coordinate of the given line. */
863 fun getLineLeft(lineIndex: Int): Float {
864 requireLineIndexInRange(lineIndex)
865
866 val paragraphIndex = findParagraphByLineIndex(paragraphInfoList, lineIndex)
867
868 return with(paragraphInfoList[paragraphIndex]) {
869 paragraph.getLineLeft(lineIndex.toLocalLineIndex())
870 }
871 }
872
873 /** Returns the right x Coordinate of the given line. */
874 fun getLineRight(lineIndex: Int): Float {
875 requireLineIndexInRange(lineIndex)
876
877 val paragraphIndex = findParagraphByLineIndex(paragraphInfoList, lineIndex)
878
879 return with(paragraphInfoList[paragraphIndex]) {
880 paragraph.getLineRight(lineIndex.toLocalLineIndex())
881 }
882 }
883
884 /** Returns the top y coordinate of the given line. */
885 fun getLineTop(lineIndex: Int): Float {
886 requireLineIndexInRange(lineIndex)
887
888 val paragraphIndex = findParagraphByLineIndex(paragraphInfoList, lineIndex)
889
890 return with(paragraphInfoList[paragraphIndex]) {
891 paragraph.getLineTop(lineIndex.toLocalLineIndex()).toGlobalYPosition()
892 }
893 }
894
895 /**
896 * Returns the distance from the top of the [MultiParagraph] to the alphabetic baseline of the
897 * given line.
898 */
899 fun getLineBaseline(lineIndex: Int): Float {
900 requireLineIndexInRange(lineIndex)
901
902 val paragraphIndex = findParagraphByLineIndex(paragraphInfoList, lineIndex)
903
904 return with(paragraphInfoList[paragraphIndex]) {
905 paragraph.getLineBaseline(lineIndex.toLocalLineIndex()).toGlobalYPosition()
906 }
907 }
908
909 /** Returns the bottom y coordinate of the given line. */
910 fun getLineBottom(lineIndex: Int): Float {
911 requireLineIndexInRange(lineIndex)
912
913 val paragraphIndex = findParagraphByLineIndex(paragraphInfoList, lineIndex)
914
915 return with(paragraphInfoList[paragraphIndex]) {
916 paragraph.getLineBottom(lineIndex.toLocalLineIndex()).toGlobalYPosition()
917 }
918 }
919
920 /** Returns the height of the given line. */
921 fun getLineHeight(lineIndex: Int): Float {
922 requireLineIndexInRange(lineIndex)
923
924 val paragraphIndex = findParagraphByLineIndex(paragraphInfoList, lineIndex)
925
926 return with(paragraphInfoList[paragraphIndex]) {
927 paragraph.getLineHeight(lineIndex.toLocalLineIndex())
928 }
929 }
930
931 /** Returns the width of the given line. */
932 fun getLineWidth(lineIndex: Int): Float {
933 requireLineIndexInRange(lineIndex)
934
935 val paragraphIndex = findParagraphByLineIndex(paragraphInfoList, lineIndex)
936
937 return with(paragraphInfoList[paragraphIndex]) {
938 paragraph.getLineWidth(lineIndex.toLocalLineIndex())
939 }
940 }
941
942 /** Returns the start offset of the given line, inclusive. */
943 fun getLineStart(lineIndex: Int): Int {
944 requireLineIndexInRange(lineIndex)
945
946 val paragraphIndex = findParagraphByLineIndex(paragraphInfoList, lineIndex)
947
948 return with(paragraphInfoList[paragraphIndex]) {
949 paragraph.getLineStart(lineIndex.toLocalLineIndex()).toGlobalIndex()
950 }
951 }
952
953 /**
954 * Returns the end offset of the given line
955 *
956 * Characters being ellipsized are treated as invisible characters. So that if visibleEnd is
957 * false, it will return line end including the ellipsized characters and vice verse.
958 *
959 * @param lineIndex the line number
960 * @param visibleEnd if true, the returned line end will not count trailing whitespaces or
961 * linefeed characters. Otherwise, this function will return the logical line end. By default
962 * it's false.
963 * @return an exclusive end offset of the line.
964 */
965 fun getLineEnd(lineIndex: Int, visibleEnd: Boolean = false): Int {
966 requireLineIndexInRange(lineIndex)
967
968 val paragraphIndex = findParagraphByLineIndex(paragraphInfoList, lineIndex)
969
970 return with(paragraphInfoList[paragraphIndex]) {
971 paragraph.getLineEnd(lineIndex.toLocalLineIndex(), visibleEnd).toGlobalIndex()
972 }
973 }
974
975 /**
976 * Returns true if the given line is ellipsized, otherwise returns false.
977 *
978 * @param lineIndex a 0 based line index
979 * @return true if the given line is ellipsized, otherwise false
980 */
981 fun isLineEllipsized(lineIndex: Int): Boolean {
982 requireLineIndexInRange(lineIndex)
983 val paragraphIndex = findParagraphByLineIndex(paragraphInfoList, lineIndex)
984 return with(paragraphInfoList[paragraphIndex]) { paragraph.isLineEllipsized(lineIndex) }
985 }
986
987 private fun requireIndexInRange(offset: Int) {
988 requirePrecondition(offset in annotatedString.text.indices) {
989 "offset($offset) is out of bounds [0, ${annotatedString.length})"
990 }
991 }
992
993 private fun requireIndexInRangeInclusiveEnd(offset: Int) {
994 requirePrecondition(offset in 0..annotatedString.text.length) {
995 "offset($offset) is out of bounds [0, ${annotatedString.length}]"
996 }
997 }
998
999 private fun requireLineIndexInRange(lineIndex: Int) {
1000 requirePrecondition(lineIndex in 0 until lineCount) {
1001 "lineIndex($lineIndex) is out of bounds [0, $lineCount)"
1002 }
1003 }
1004 }
1005
1006 /**
1007 * Given an character index of [MultiParagraph.annotatedString], find the corresponding
1008 * [ParagraphInfo] which covers the provided index.
1009 *
1010 * @param paragraphInfoList The list of [ParagraphInfo] containing the information of each paragraph
1011 * in the [MultiParagraph].
1012 * @param index The target index in the [MultiParagraph]. It should be in the range of [0,
1013 * text.length)
1014 * @return The index of the target [ParagraphInfo] in [paragraphInfoList].
1015 */
findParagraphByIndexnull1016 internal fun findParagraphByIndex(paragraphInfoList: List<ParagraphInfo>, index: Int): Int {
1017 val lastLineEnd = paragraphInfoList.last().endIndex
1018 requirePrecondition(index <= paragraphInfoList.last().endIndex) {
1019 "Index $index should be less or equal than last line's end $lastLineEnd"
1020 }
1021 val paragraphIndex =
1022 paragraphInfoList.fastBinarySearch { paragraphInfo ->
1023 when {
1024 paragraphInfo.startIndex > index -> 1
1025 paragraphInfo.endIndex <= index -> -1
1026 else -> 0
1027 }
1028 }
1029 requirePrecondition(paragraphIndex in paragraphInfoList.indices) {
1030 "Found paragraph index $paragraphIndex should be in range [0, ${paragraphInfoList.size}).\n" +
1031 "Debug info: index=$index, paragraphs=[${paragraphInfoList.fastJoinToString { "[${it.startIndex}, ${it.endIndex})" }}]"
1032 }
1033 return paragraphIndex
1034 }
1035
1036 /**
1037 * Given the y graphical position relative to this [MultiParagraph], find the index of the
1038 * corresponding [ParagraphInfo] which occupies the provided position.
1039 *
1040 * @param paragraphInfoList The list of [ParagraphInfo] containing the information of each paragraph
1041 * in the [MultiParagraph].
1042 * @param y The y coordinate position relative to the [MultiParagraph].
1043 * @return The index of the target [ParagraphInfo] in [paragraphInfoList].
1044 */
findParagraphByYnull1045 internal fun findParagraphByY(paragraphInfoList: List<ParagraphInfo>, y: Float): Int {
1046 if (y <= 0) return 0
1047 if (y >= paragraphInfoList.last().bottom) return paragraphInfoList.lastIndex
1048 return paragraphInfoList.fastBinarySearch { paragraphInfo ->
1049 when {
1050 paragraphInfo.top > y -> 1
1051 paragraphInfo.bottom <= y -> -1
1052 else -> 0
1053 }
1054 }
1055 }
1056
findParagraphsByRangenull1057 internal fun findParagraphsByRange(
1058 paragraphInfoList: List<ParagraphInfo>,
1059 range: TextRange,
1060 action: (ParagraphInfo) -> Unit
1061 ) {
1062 val paragraphIndex = findParagraphByIndex(paragraphInfoList, range.min)
1063 for (i in paragraphIndex until paragraphInfoList.size) {
1064 val paragraph = paragraphInfoList[i]
1065 if (paragraph.startIndex >= range.max) break
1066 if (paragraph.startIndex == paragraph.endIndex) continue
1067 action(paragraph)
1068 }
1069 }
1070
1071 /**
1072 * Given an line index in [MultiParagraph], find the corresponding [ParagraphInfo] which covers the
1073 * provided line index.
1074 *
1075 * @param paragraphInfoList The list of [ParagraphInfo] containing the information of each paragraph
1076 * in the [MultiParagraph].
1077 * @param lineIndex The target line index in the [MultiParagraph], it should be in the range of
1078 * [0, [MultiParagraph.lineCount])
1079 * @return The index of the target [ParagraphInfo] in [paragraphInfoList].
1080 */
findParagraphByLineIndexnull1081 internal fun findParagraphByLineIndex(paragraphInfoList: List<ParagraphInfo>, lineIndex: Int): Int {
1082 return paragraphInfoList.fastBinarySearch { paragraphInfo ->
1083 when {
1084 paragraphInfo.startLineIndex > lineIndex -> 1
1085 paragraphInfo.endLineIndex <= lineIndex -> -1
1086 else -> 0
1087 }
1088 }
1089 }
1090
fastBinarySearchnull1091 private inline fun <T> List<T>.fastBinarySearch(comparison: (T) -> Int): Int {
1092 var low = 0
1093 var high = size - 1
1094
1095 while (low <= high) {
1096 val mid = (low + high).ushr(1) // safe from overflows
1097 val midVal = get(mid)
1098 val cmp = comparison(midVal)
1099
1100 if (cmp < 0) low = mid + 1 else if (cmp > 0) high = mid - 1 else return mid // key found
1101 }
1102 return -(low + 1) // key not found
1103 }
1104
1105 /**
1106 * This is a helper data structure to store the information of a single [Paragraph] in an
1107 * [MultiParagraph]. It's mainly used to convert a global index, lineNumber and [Offset] to the
1108 * local ones inside the [paragraph], and vice versa.
1109 *
1110 * @param paragraph The [Paragraph] object corresponding to this [ParagraphInfo].
1111 * @param startIndex The start index of this paragraph in the parent [MultiParagraph], inclusive.
1112 * @param endIndex The end index of this paragraph in the parent [MultiParagraph], exclusive.
1113 * @param startLineIndex The start line index of this paragraph in the parent [MultiParagraph],
1114 * inclusive.
1115 * @param endLineIndex The end line index of this paragraph in the parent [MultiParagraph],
1116 * exclusive.
1117 * @param top The top position of the [paragraph] relative to the parent [MultiParagraph].
1118 * @param bottom The bottom position of the [paragraph] relative to the parent [MultiParagraph].
1119 */
1120 internal data class ParagraphInfo(
1121 val paragraph: Paragraph,
1122 val startIndex: Int,
1123 val endIndex: Int,
1124 var startLineIndex: Int = -1,
1125 var endLineIndex: Int = -1,
1126 var top: Float = -1.0f,
1127 var bottom: Float = -1.0f
1128 ) {
1129
1130 /** The length of the text in the covered by this paragraph. */
1131 val length
1132 get() = endIndex - startIndex
1133
1134 /** Convert an index in the parent [MultiParagraph] to the local index in the [paragraph]. */
toLocalIndexnull1135 fun Int.toLocalIndex(): Int {
1136 return this.coerceIn(startIndex, endIndex) - startIndex
1137 }
1138
1139 /**
1140 * Convert a local index in the [paragraph] to the global index in the parent [MultiParagraph].
1141 */
toGlobalIndexnull1142 fun Int.toGlobalIndex(): Int {
1143 return this + startIndex
1144 }
1145
1146 /**
1147 * Convert a line index in the parent [MultiParagraph] to the local line index in the
1148 * [paragraph].
1149 */
toLocalLineIndexnull1150 fun Int.toLocalLineIndex(): Int {
1151 return this - startLineIndex
1152 }
1153
1154 /**
1155 * Convert a local line index in the [paragraph] to the global line index in the parent
1156 * [MultiParagraph].
1157 */
toGlobalLineIndexnull1158 fun Int.toGlobalLineIndex(): Int {
1159 return this + startLineIndex
1160 }
1161
1162 /**
1163 * Convert a local y position relative to [paragraph] to the global y position relative to the
1164 * parent [MultiParagraph].
1165 */
Floatnull1166 fun Float.toGlobalYPosition(): Float {
1167 return this + top
1168 }
1169
1170 /**
1171 * Convert a global y position relative to the parent [MultiParagraph] to a local y position
1172 * relative to [paragraph].
1173 */
Floatnull1174 fun Float.toLocalYPosition(): Float {
1175 return this - top
1176 }
1177
1178 /**
1179 * Convert a [Offset] relative to the parent [MultiParagraph] to the local [Offset] relative to
1180 * the [paragraph].
1181 */
Offsetnull1182 fun Offset.toLocal(): Offset {
1183 return Offset(x, y - top)
1184 }
1185
1186 /**
1187 * Convert a [Rect] relative to the [paragraph] to the [Rect] relative to the parent
1188 * [MultiParagraph].
1189 */
Rectnull1190 fun Rect.toGlobal(): Rect {
1191 return translate(Offset(0f, this@ParagraphInfo.top))
1192 }
1193
1194 /**
1195 * Convert a [Rect] relative to the parent [MultiParagraph] to the local [Rect] relative to this
1196 * [paragraph].
1197 */
Rectnull1198 fun Rect.toLocal(): Rect {
1199 return translate(Offset(0f, -this@ParagraphInfo.top))
1200 }
1201
1202 /**
1203 * Convert a [Path] relative to the [paragraph] to the [Path] relative to the parent
1204 * [MultiParagraph].
1205 *
1206 * Notice that this function changes the input value.
1207 */
Pathnull1208 fun Path.toGlobal(): Path {
1209 translate(Offset(0f, top))
1210 return this
1211 }
1212
1213 /**
1214 * Convert a [TextRange] in to the [paragraph] to the [TextRange] in the parent
1215 * [MultiParagraph].
1216 *
1217 * @param treatZeroAsNull whether [TextRange.Zero] is used represents `null`. When it's true,
1218 * [TextRange.Zero] is not mapped to global index and is returned directly.
1219 */
toGlobalnull1220 fun TextRange.toGlobal(treatZeroAsNull: Boolean = true): TextRange {
1221 if (treatZeroAsNull && this == TextRange.Zero) {
1222 return TextRange.Zero
1223 }
1224 return TextRange(start = start.toGlobalIndex(), end = end.toGlobalIndex())
1225 }
1226 }
1227