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