1 /*
2  * Copyright 2018 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 package androidx.compose.ui.text.android
17 
18 import android.graphics.Canvas
19 import android.graphics.Paint.FontMetricsInt
20 import android.graphics.Path
21 import android.graphics.Rect
22 import android.graphics.RectF
23 import android.os.Build
24 import android.os.Trace
25 import android.text.BoringLayout
26 import android.text.GraphemeClusterSegmentFinder
27 import android.text.Layout
28 import android.text.SpannableString
29 import android.text.Spanned
30 import android.text.StaticLayout
31 import android.text.TextDirectionHeuristic
32 import android.text.TextDirectionHeuristics
33 import android.text.TextPaint
34 import android.text.TextUtils
35 import androidx.annotation.Px
36 import androidx.annotation.RequiresApi
37 import androidx.annotation.VisibleForTesting
38 import androidx.compose.ui.text.android.LayoutCompat.ALIGN_CENTER
39 import androidx.compose.ui.text.android.LayoutCompat.ALIGN_LEFT
40 import androidx.compose.ui.text.android.LayoutCompat.ALIGN_NORMAL
41 import androidx.compose.ui.text.android.LayoutCompat.ALIGN_OPPOSITE
42 import androidx.compose.ui.text.android.LayoutCompat.ALIGN_RIGHT
43 import androidx.compose.ui.text.android.LayoutCompat.BreakStrategy
44 import androidx.compose.ui.text.android.LayoutCompat.DEFAULT_ALIGNMENT
45 import androidx.compose.ui.text.android.LayoutCompat.DEFAULT_BREAK_STRATEGY
46 import androidx.compose.ui.text.android.LayoutCompat.DEFAULT_HYPHENATION_FREQUENCY
47 import androidx.compose.ui.text.android.LayoutCompat.DEFAULT_INCLUDE_PADDING
48 import androidx.compose.ui.text.android.LayoutCompat.DEFAULT_JUSTIFICATION_MODE
49 import androidx.compose.ui.text.android.LayoutCompat.DEFAULT_LINESPACING_EXTRA
50 import androidx.compose.ui.text.android.LayoutCompat.DEFAULT_LINESPACING_MULTIPLIER
51 import androidx.compose.ui.text.android.LayoutCompat.DEFAULT_LINE_BREAK_STYLE
52 import androidx.compose.ui.text.android.LayoutCompat.DEFAULT_LINE_BREAK_WORD_STYLE
53 import androidx.compose.ui.text.android.LayoutCompat.DEFAULT_TEXT_DIRECTION
54 import androidx.compose.ui.text.android.LayoutCompat.HyphenationFrequency
55 import androidx.compose.ui.text.android.LayoutCompat.JustificationMode
56 import androidx.compose.ui.text.android.LayoutCompat.LineBreakStyle
57 import androidx.compose.ui.text.android.LayoutCompat.LineBreakWordStyle
58 import androidx.compose.ui.text.android.LayoutCompat.TEXT_DIRECTION_ANY_RTL_LTR
59 import androidx.compose.ui.text.android.LayoutCompat.TEXT_DIRECTION_FIRST_STRONG_LTR
60 import androidx.compose.ui.text.android.LayoutCompat.TEXT_DIRECTION_FIRST_STRONG_RTL
61 import androidx.compose.ui.text.android.LayoutCompat.TEXT_DIRECTION_LOCALE
62 import androidx.compose.ui.text.android.LayoutCompat.TEXT_DIRECTION_LTR
63 import androidx.compose.ui.text.android.LayoutCompat.TEXT_DIRECTION_RTL
64 import androidx.compose.ui.text.android.LayoutCompat.TEXT_GRANULARITY_WORD
65 import androidx.compose.ui.text.android.LayoutCompat.TextDirection
66 import androidx.compose.ui.text.android.LayoutCompat.TextGranularity
67 import androidx.compose.ui.text.android.LayoutCompat.TextLayoutAlignment
68 import androidx.compose.ui.text.android.selection.Api34SegmentFinder.toAndroidSegmentFinder
69 import androidx.compose.ui.text.android.selection.WordIterator
70 import androidx.compose.ui.text.android.selection.WordSegmentFinder
71 import androidx.compose.ui.text.android.style.BaselineShiftSpan
72 import androidx.compose.ui.text.android.style.LineHeightStyleSpan
73 import androidx.compose.ui.text.android.style.getEllipsizedLeftPadding
74 import androidx.compose.ui.text.android.style.getEllipsizedRightPadding
75 import androidx.compose.ui.text.internal.requirePrecondition
76 import kotlin.math.abs
77 import kotlin.math.ceil
78 import kotlin.math.max
79 import kotlin.math.min
80 
81 /** We swap canvas delegates, and can share the wrapper. */
82 private val SharedTextAndroidCanvas: TextAndroidCanvas = TextAndroidCanvas()
83 
84 /**
85  * Wrapper for Static Text Layout classes.
86  *
87  * @param charSequence text to be laid out.
88  * @param width the maximum width for the text
89  * @param textPaint base paint used for text layout
90  * @param alignment text alignment for the text layout. One of [TextLayoutAlignment].
91  * @param ellipsize whether the text needs to be ellipsized. If the maxLines is set and text cannot
92  *   fit in the provided number of lines.
93  * @param textDirectionHeuristic the heuristics to be applied while deciding on the text direction.
94  * @param lineSpacingMultiplier the multiplier to be applied to each line of the text.
95  * @param lineSpacingExtra the extra height to be added to each line of the text.
96  * @param includePadding defines whether the extra space to be applied beyond font ascent and
97  *   descent
98  * @param fallbackLineSpacing Sets Android TextView#setFallbackLineSpacing. This value should be set
99  *   to true in most cases and it is the default on platform; otherwise tall scripts such as Burmese
100  *   or Tibetan result in clippings on top and bottom sometimes making the text not-readable.
101  * @param maxLines the maximum number of lines to be laid out.
102  * @param breakStrategy the strategy to be used for line breaking
103  * @param hyphenationFrequency set the frequency to control the amount of automatic hyphenation
104  *   applied.
105  * @param justificationMode whether to justify the text.
106  * @param leftIndents the indents to be applied to the left of the text as pixel values. Each
107  *   element in the array is applied to the corresponding line. For lines past the last element in
108  *   array, the last element repeats.
109  * @param rightIndents the indents to be applied to the right of the text as pixel values. Each
110  *   element in the array is applied to the corresponding line. For lines past the last element in
111  *   array, the last element repeats.
112  * @param layoutIntrinsics previously calculated [LayoutIntrinsics] for this text
113  * @see StaticLayoutFactory
114  * @see BoringLayoutFactory
115  */
116 @OptIn(InternalPlatformTextApi::class)
117 internal class TextLayout
118 constructor(
119     charSequence: CharSequence,
120     width: Float,
121     val textPaint: TextPaint,
122     @TextLayoutAlignment alignment: Int = DEFAULT_ALIGNMENT,
123     private val ellipsize: TextUtils.TruncateAt? = null,
124     @TextDirection textDirectionHeuristic: Int = DEFAULT_TEXT_DIRECTION,
125     lineSpacingMultiplier: Float = DEFAULT_LINESPACING_MULTIPLIER,
126     @Px lineSpacingExtra: Float = DEFAULT_LINESPACING_EXTRA,
127     val includePadding: Boolean = DEFAULT_INCLUDE_PADDING,
128     val fallbackLineSpacing: Boolean = true,
129     maxLines: Int = Int.MAX_VALUE,
130     @BreakStrategy breakStrategy: Int = DEFAULT_BREAK_STRATEGY,
131     @LineBreakStyle lineBreakStyle: Int = DEFAULT_LINE_BREAK_STYLE,
132     @LineBreakWordStyle lineBreakWordStyle: Int = DEFAULT_LINE_BREAK_WORD_STYLE,
133     @HyphenationFrequency hyphenationFrequency: Int = DEFAULT_HYPHENATION_FREQUENCY,
134     @JustificationMode justificationMode: Int = DEFAULT_JUSTIFICATION_MODE,
135     leftIndents: IntArray? = null,
136     rightIndents: IntArray? = null,
137     val layoutIntrinsics: LayoutIntrinsics =
138         LayoutIntrinsics(charSequence, textPaint, textDirectionHeuristic)
139 ) {
140     val maxIntrinsicWidth: Float
141         get() = layoutIntrinsics.maxIntrinsicWidth
142 
143     val minIntrinsicWidth: Float
144         get() = layoutIntrinsics.minIntrinsicWidth
145 
146     val didExceedMaxLines: Boolean
147 
148     private var backingWordIterator: WordIterator? = null
149     val wordIterator: WordIterator
150         get() {
151             val finalWordIterator = backingWordIterator
152             if (finalWordIterator != null) return finalWordIterator
<lambda>null153             return WordIterator(layout.text, 0, layout.text.length, textPaint.textLocale).also {
154                 backingWordIterator = it
155             }
156         }
157 
158     /** Please do not access this object directly from runtime code. */
159     @VisibleForTesting val layout: Layout
160 
161     /**
162      * Resolved line count. If maxLines smaller than the real number of lines in the text, this
163      * property will return the minimum between the two
164      */
165     val lineCount: Int
166 
167     /**
168      * Top padding is added for backporting fallbackLineSpacing behavior. If a tall script is being
169      * laid out, topPadding might be non zero (based on Android version and support in StaticLayout
170      * and BoringLayout). When top padding is non-zero, the height of the TextLayout will increase
171      * with top padding to prevent clipping of the top of the first line.
172      */
173     @VisibleForTesting internal val topPadding: Int
174 
175     /**
176      * Bottom padding is added for backporting fallbackLineSpacing behavior. If a tall script is
177      * being laid out, bottomPadding might be non zero (based on Android version and support in
178      * StaticLayout and BoringLayout). When bottom padding is non-zero, the height of the TextLayout
179      * will increase with bottom padding to prevent clipping of the bottom if the last line.
180      */
181     @VisibleForTesting internal val bottomPadding: Int
182 
183     /**
184      * When letter spacing, align and ellipsize applied to text, the ellipsized line is indented
185      * wrong. For example for an LTR text, the last line is indented in a way where the beginning of
186      * the line is less than 0 and the text is cut at the beginning.
187      *
188      * This attribute is used to fix the line left pixel positions accordingly.
189      */
190     private val leftPadding: Float
191 
192     /**
193      * When letter spacing, align and ellipsize applied to text, the ellipsized line is indented
194      * wrong. For example for an RTL text, the last line is indented in a way where the beginning of
195      * the line is more than layout width and the text is cut at the beginning.
196      *
197      * This attribute is used to fix the line right pixel positions accordingly.
198      */
199     private val rightPadding: Float
200 
201     /** When true the wrapped layout that was created is a BoringLayout. */
202     private val isBoringLayout: Boolean
203 
204     /**
205      * When the last line of the text is empty, ParagraphStyle's are not applied. This becomes
206      * visible during edit operations when the text field is empty or user inputs an new line
207      * character. This layout contains the text layout that would be applied if the last line was
208      * not empty.
209      */
210     private val lastLineFontMetrics: FontMetricsInt?
211 
212     /**
213      * Holds the difference in line height for the lastLineFontMetrics and the wrapped text layout.
214      */
215     private val lastLineExtra: Int
216 
217     private val lineHeightSpans: Array<LineHeightStyleSpan>?
218 
219     private val rect: Rect = Rect()
220 
221     init {
222         val end = charSequence.length
223         val frameworkTextDir = getTextDirectionHeuristic(textDirectionHeuristic)
224         val frameworkAlignment = TextAlignmentAdapter.get(alignment)
225 
226         // BoringLayout won't adjust line height for baselineShift,
227         // use StaticLayout for those spans.
228         val hasBaselineShiftSpans =
229             if (charSequence is Spanned) {
230                 // nextSpanTransition returns limit if there isn't any span.
231                 charSequence.nextSpanTransition(-1, end, BaselineShiftSpan::class.java) < end
232             } else {
233                 false
234             }
235 
236         Trace.beginSection("TextLayout:initLayout")
237         try {
238             val boringMetrics = layoutIntrinsics.boringMetrics
239 
240             val widthInt = ceil(width).toInt()
241             layout =
242                 if (
243                     boringMetrics != null &&
244                         layoutIntrinsics.maxIntrinsicWidth <= width &&
245                         !hasBaselineShiftSpans
246                 ) {
247                     isBoringLayout = true
248                     BoringLayoutFactory.create(
249                         text = charSequence,
250                         paint = textPaint,
251                         width = widthInt,
252                         metrics = boringMetrics,
253                         alignment = frameworkAlignment,
254                         includePadding = includePadding,
255                         useFallbackLineSpacing = fallbackLineSpacing,
256                         ellipsize = ellipsize,
257                         ellipsizedWidth = widthInt
258                     )
259                 } else {
260                     isBoringLayout = false
261                     StaticLayoutFactory.create(
262                         text = charSequence,
263                         paint = textPaint,
264                         width = widthInt,
265                         start = 0,
266                         end = charSequence.length,
267                         textDir = frameworkTextDir,
268                         alignment = frameworkAlignment,
269                         maxLines = maxLines,
270                         ellipsize = ellipsize,
271                         ellipsizedWidth = ceil(width).toInt(),
272                         lineSpacingMultiplier = lineSpacingMultiplier,
273                         lineSpacingExtra = lineSpacingExtra,
274                         justificationMode = justificationMode,
275                         includePadding = includePadding,
276                         useFallbackLineSpacing = fallbackLineSpacing,
277                         breakStrategy = breakStrategy,
278                         lineBreakStyle = lineBreakStyle,
279                         lineBreakWordStyle = lineBreakWordStyle,
280                         hyphenationFrequency = hyphenationFrequency,
281                         leftIndents = leftIndents,
282                         rightIndents = rightIndents
283                     )
284                 }
285         } finally {
286             Trace.endSection()
287         }
288 
289         /* When ellipsis is false:
290          1. Before API 25(include 25), if the number of the actual text lines in the layout is
291          greater than the maxLines, layout.lineCount will be set to the maxLines.
292          2. After API 25(exclude 25), the layout.lineCount will be the actual number of the text
293          lines in the layout even if layout.lineCount > maxLines.
294          When ellipsis is true:
295          If the number of the actual text lines in the layout is greater than maxLines,
296          layout.lineCount will be set to the maxLines.
297          To unify the behavior of lineCount, no matter ellipsis is on or off, when the number of
298          the actual text lines in the layout is greater than the maxLines, the maxLines is
299          always returned.
300         */
301         lineCount = min(layout.lineCount, maxLines)
302         val lastLine = lineCount - 1
303 
304         didExceedMaxLines =
305             /* When lineCount is less than maxLines, actual line count is guaranteed not to exceed
306             the maxLines.
307             But when lineCount == maxLines, the actual line count may exceeds the maxLines in the
308             following two scenarios:
309             1. Ellipsis is on and the actual line count exceeds maxLines.
310             2. It's under API 25(include 25), ellipsis is off and the actual line count exceeds
311             the maxLines.
312             */
313             if (lineCount < maxLines) {
314                 false
315             } else {
316                 /* When maxLines exceeds
317                  1. if ellipsis is applied, ellipsisCount of lastLine is greater than 0. It works
318                  for all ellipsis position because start/middle ellipsis only supported for a single
319                  line text.
320                  2. if ellipsis is not applies, lineEnd of the last line is unequals to
321                  charSequence.length.
322                  On certain cases, even though ellipsize is set, text overflow might still be
323                  handled by truncating.
324                  So we have to check both cases, no matter what ellipsis parameter is passed.
325                 */
326                 layout.getEllipsisCount(lastLine) > 0 ||
327                     layout.getLineEnd(lastLine) != charSequence.length
328             }
329 
330         val verticalPaddings = getVerticalPaddings()
331 
332         lineHeightSpans = getLineHeightSpans()
333         val lineHeightPaddings = lineHeightSpans?.getLineHeightPaddings() ?: ZeroVerticalPadding
334         topPadding = max(verticalPaddings.topPadding, lineHeightPaddings.topPadding)
335         bottomPadding = max(verticalPaddings.bottomPadding, lineHeightPaddings.bottomPadding)
336 
337         val fontMetrics = getLastLineMetrics(textPaint, frameworkTextDir, lineHeightSpans)
338         lastLineExtra =
339             if (fontMetrics != null) {
340                 fontMetrics.bottom - getLineHeight(lastLine).toInt()
341             } else {
342                 0
343             }
344         // Set lastLineFontMetrics after calling getLineHeight() above, as the metrics
345         // are different when lastLineFontMetrics is null
346         lastLineFontMetrics = fontMetrics
347 
348         leftPadding = layout.getEllipsizedLeftPadding(lastLine)
349         rightPadding = layout.getEllipsizedRightPadding(lastLine)
350     }
351 
352     private var backingLayoutHelper: LayoutHelper? = null
353     private val layoutHelper: LayoutHelper
354         get() {
355             if (backingLayoutHelper == null) {
<lambda>null356                 return LayoutHelper(layout).also { backingLayoutHelper = it }
357             }
358             return backingLayoutHelper!!
359         }
360 
361     val text: CharSequence
362         get() = layout.text
363 
364     val height: Int
365         get() =
366             if (didExceedMaxLines) {
367                 layout.getLineBottom(lineCount - 1)
368             } else {
369                 layout.height
370             } + topPadding + bottomPadding + lastLineExtra
371 
getHorizontalPaddingnull372     private fun getHorizontalPadding(line: Int): Float {
373         return if (line == lineCount - 1) {
374             leftPadding + rightPadding
375         } else {
376             0f
377         }
378     }
379 
getLineLeftnull380     fun getLineLeft(lineIndex: Int): Float =
381         layout.getLineLeft(lineIndex) + if (lineIndex == lineCount - 1) leftPadding else 0f
382 
383     /** Return the horizontal leftmost position of the line in pixels. */
384     fun getLineRight(lineIndex: Int): Float =
385         layout.getLineRight(lineIndex) + if (lineIndex == lineCount - 1) rightPadding else 0f
386 
387     /**
388      * Return the vertical position of the top of the line in pixels. If the line is equal to the
389      * line count, returns the bottom of the last line.
390      */
391     fun getLineTop(line: Int): Float {
392         val top = layout.getLineTop(line).toFloat()
393         return top + if (line == 0) 0 else topPadding
394     }
395 
396     /** Return the vertical position of the bottom of the line in pixels. */
getLineBottomnull397     fun getLineBottom(line: Int): Float {
398         if (line == lineCount - 1 && lastLineFontMetrics != null) {
399             return layout.getLineBottom(line - 1).toFloat() + lastLineFontMetrics.bottom
400         }
401 
402         return topPadding +
403             layout.getLineBottom(line).toFloat() +
404             if (line == lineCount - 1) bottomPadding else 0
405     }
406 
407     /**
408      * Returns the ascent of the line in the line coordinates. Baseline is considered to be 0,
409      * therefore ascent is generally a negative value. The unit for values are pixels.
410      *
411      * @param line the line index starting from 0
412      */
getLineAscentnull413     fun getLineAscent(line: Int): Float {
414         return if (line == lineCount - 1 && lastLineFontMetrics != null) {
415             lastLineFontMetrics.ascent.toFloat()
416         } else {
417             layout.getLineAscent(line).toFloat()
418         }
419     }
420 
421     /** Return the vertical position of the baseline of the line in pixels. */
getLineBaselinenull422     fun getLineBaseline(line: Int): Float {
423         return topPadding +
424             if (line == lineCount - 1 && lastLineFontMetrics != null) {
425                 getLineTop(line) - lastLineFontMetrics.ascent
426             } else {
427                 layout.getLineBaseline(line).toFloat()
428             }
429     }
430 
431     /**
432      * Returns the descent of the line in the line coordinates. Baseline is considered to be 0,
433      * therefore descent is generally a positive value. The unit for values are pixels.
434      *
435      * @param line the line index starting from 0
436      */
getLineDescentnull437     fun getLineDescent(line: Int): Float {
438         return if (line == lineCount - 1 && lastLineFontMetrics != null) {
439             lastLineFontMetrics.descent.toFloat()
440         } else {
441             layout.getLineDescent(line).toFloat()
442         }
443     }
444 
getLineHeightnull445     fun getLineHeight(lineIndex: Int): Float = getLineBottom(lineIndex) - getLineTop(lineIndex)
446 
447     /** Return the width of the line in pixels. */
448     fun getLineWidth(lineIndex: Int): Float = layout.getLineWidth(lineIndex)
449 
450     /**
451      * Return the text offset at the beginning of the line. If the line is equal to the line count,
452      * returns the length of the text.
453      */
454     fun getLineStart(lineIndex: Int): Int = layout.getLineStart(lineIndex)
455 
456     /**
457      * Return the text offset at the end of the line. If the line is equal to the line count,
458      * returns the length of the text.
459      */
460     fun getLineEnd(lineIndex: Int): Int =
461         if (layout.isLineEllipsized(lineIndex) && ellipsize == TextUtils.TruncateAt.END) {
462             // Layout#getLineEnd usually gets the end of text for the last line even if ellipsis
463             // happens. However, if LF character is included in the ellipsized region, getLineEnd
464             // returns LF character offset. So, use end of text for line end here.
465             layout.text.length
466         } else {
467             layout.getLineEnd(lineIndex)
468         }
469 
470     /**
471      * Return the text offset after the last visible character on the specified line. For example
472      * whitespaces are not counted as visible characters.
473      */
getLineVisibleEndnull474     fun getLineVisibleEnd(lineIndex: Int): Int =
475         if (layout.isLineEllipsized(lineIndex) && ellipsize == TextUtils.TruncateAt.END) {
476             layout.getLineStart(lineIndex) + layout.getEllipsisStart(lineIndex)
477         } else {
478             layoutHelper.getLineVisibleEnd(lineIndex)
479         }
480 
isLineEllipsizednull481     fun isLineEllipsized(lineIndex: Int) = layout.isLineEllipsized(lineIndex)
482 
483     fun getLineEllipsisOffset(lineIndex: Int): Int = layout.getEllipsisStart(lineIndex)
484 
485     fun getLineEllipsisCount(lineIndex: Int): Int = layout.getEllipsisCount(lineIndex)
486 
487     fun getLineForVertical(vertical: Int): Int = layout.getLineForVertical(vertical - topPadding)
488 
489     fun getOffsetForHorizontal(line: Int, horizontal: Float): Int {
490         return layout.getOffsetForHorizontal(line, horizontal + -1 * getHorizontalPadding(line))
491     }
492 
493     /**
494      * Returns horizontal position for an offset from the drawing origin of a new character would be
495      * inserted at that offset.
496      *
497      * *primary* means that the inserting character's direction will be resolved to the *same*
498      * direction to the paragraph direction. For example, the insertion position for an LTR
499      * character in an LTR paragraph or RTL character in an RTL paragraph.
500      *
501      * The location that is being queried can also be different based on line breaks. Consider the
502      * following example:
503      * <pre>
504      *    aa
505      *    bb
506      * <pre/>
507      *
508      *
509      * In the example above, if offset is the end of the first line then it is required to know if
510      * the position to be returned is the end of the first line ("aa") or the beginning of the next
511      * line ("bb").
512      *
513      * When the end of line is needed [upstream] should be set to true; when the beginning of next
514      * line is needed [upstream] should be set to false (therefore it is downstream).
515      *
516      * @param offset offset the character index
517      * @param upstream to return the end of the line for offsets that are at the end
518      * of a line. false returns the beginning of the next line
519      *
520      * @return the horizontal position of an offset from the drawing origin
521      */
getPrimaryHorizontalnull522     fun getPrimaryHorizontal(offset: Int, upstream: Boolean = false): Float {
523         return layoutHelper.getHorizontalPosition(
524             offset,
525             usePrimaryDirection = true,
526             upstream = upstream
527         ) + getHorizontalPadding(getLineForOffset(offset))
528     }
529 
530     /**
531      * Returns horizontal position for an offset from the drawing origin of a new character would be
532      * inserted at that offset.
533      *
534      * *secondary* means that the inserting character's direction will be resolved to the *opposite*
535      * direction to the paragraph direction. For example, the insertion position for an RTL
536      * character in an LTR paragraph or LTR character in an RTL paragraph.
537      *
538      * The location that is being queried can also be different based on line breaks. Consider the
539      * following example:
540      * <pre>
541      *    aa
542      *    bb
543      * <pre/>
544      *
545      *
546      * In the example above, if offset is the end of the first line then it is required to know if
547      * the position to be returned is the end of the first line ("aa") or the beginning of the next
548      * line ("bb").
549      *
550      * When the end of line is needed [upstream] should be set to true; when the beginning of next
551      * line is needed [upstream] should be set to false (therefore it is downstream).
552      *
553      * @param offset offset the character index
554      * @param upstream true to return the end of the line for offsets that are at the end
555      * of a line. false returns the beginning of the next line.
556      *
557      * @return the horizontal position of an offset from the drawing origin
558      */
getSecondaryHorizontalnull559     fun getSecondaryHorizontal(offset: Int, upstream: Boolean = false): Float {
560         return layoutHelper.getHorizontalPosition(
561             offset,
562             usePrimaryDirection = false,
563             upstream = upstream
564         ) + getHorizontalPadding(getLineForOffset(offset))
565     }
566 
getLineForOffsetnull567     fun getLineForOffset(offset: Int): Int = layout.getLineForOffset(offset)
568 
569     fun isRtlCharAt(offset: Int): Boolean = layout.isRtlCharAt(offset)
570 
571     fun getParagraphDirection(line: Int): Int = layout.getParagraphDirection(line)
572 
573     fun getSelectionPath(start: Int, end: Int, dest: Path) {
574         layout.getSelectionPath(start, end, dest)
575         if (topPadding != 0 && !dest.isEmpty) {
576             dest.offset(0f /* dx */, topPadding.toFloat() /* dy */)
577         }
578     }
579 
getRangeForRectnull580     fun getRangeForRect(
581         rect: RectF,
582         @TextGranularity granularity: Int,
583         inclusionStrategy: (RectF, RectF) -> Boolean
584     ): IntArray? {
585         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
586             return AndroidLayoutApi34.getRangeForRect(this, rect, granularity, inclusionStrategy)
587         }
588         return getRangeForRect(layout, layoutHelper, rect, granularity, inclusionStrategy)
589     }
590 
591     /**
592      * A lightweight version of fillBoundingBoxes that only fills the horizontal bounds of
593      * characters in the line referred by the given [lineIndex]. The result will be filled into the
594      * given [array] where the left or right bounds i-th character in the line is stored as the (i *
595      * 2)-th or (i * 2 + 1)-th element respectively.
596      */
fillLineHorizontalBoundsnull597     internal fun fillLineHorizontalBounds(
598         lineIndex: Int,
599         array: FloatArray,
600     ) {
601         val lineStartOffset = getLineStart(lineIndex)
602         val lineEndOffset = getLineEnd(lineIndex)
603 
604         val range = lineEndOffset - lineStartOffset
605         val minArraySize = range * 2
606 
607         requirePrecondition(array.size >= minArraySize) {
608             "array.size - arrayStart must be greater or equal than (endOffset - startOffset) * 2"
609         }
610 
611         val cache = HorizontalPositionCache(this)
612 
613         val isLtrLine = getParagraphDirection(lineIndex) == Layout.DIR_LEFT_TO_RIGHT
614 
615         var arrayOffset = 0
616         for (offset in lineStartOffset until lineEndOffset) {
617             val isRtlChar = isRtlCharAt(offset)
618 
619             val left: Float
620             val right: Float
621 
622             when {
623                 isLtrLine && !isRtlChar -> {
624                     left = cache.getPrimaryDownstream(offset)
625                     right = cache.getPrimaryUpstream(offset + 1)
626                 }
627                 isLtrLine && isRtlChar -> {
628                     right = cache.getSecondaryDownstream(offset)
629                     left = cache.getSecondaryUpstream(offset + 1)
630                 }
631                 isRtlChar -> {
632                     right = cache.getPrimaryDownstream(offset)
633                     left = cache.getPrimaryUpstream(offset + 1)
634                 }
635                 else -> {
636                     left = cache.getSecondaryDownstream(offset)
637                     right = cache.getSecondaryUpstream(offset + 1)
638                 }
639             }
640             array[arrayOffset] = left
641             array[arrayOffset + 1] = right
642             arrayOffset += 2
643         }
644     }
645 
646     /**
647      * Fills the bounding boxes for characters within the [startOffset] (inclusive) and [endOffset]
648      * (exclusive). The array is filled starting from [arrayStart] (inclusive). The coordinates are
649      * in local text layout coordinates.
650      *
651      * The returned information consists of left/right of a character; line top and bottom for the
652      * same character.
653      *
654      * For the grapheme consists of multiple code points, e.g. ligatures, combining marks, the first
655      * character has the total width and the remaining are returned as zero-width.
656      *
657      * The array divided into segments of four where each index in that segment represents left,
658      * top, right, bottom of the character.
659      *
660      * The size of the provided [array] should be greater or equal than fours times the range
661      * provided with [startOffset] and [endOffset].
662      *
663      * The final order of characters in the [array] is from [startOffset] to [endOffset].
664      *
665      * @param startOffset inclusive startOffset, must be smaller than [endOffset]
666      * @param endOffset exclusive end offset, must be greater than [startOffset]
667      * @param array the array to fill in the values. The array divided into segments of four where
668      *   each index in that segment represents left, top, right, bottom of the character.
669      * @param arrayStart the inclusive start index in the array where the function will start
670      *   filling in the values from
671      */
fillBoundingBoxesnull672     fun fillBoundingBoxes(startOffset: Int, endOffset: Int, array: FloatArray, arrayStart: Int) {
673         val textLength = text.length
674         requirePrecondition(startOffset >= 0) { "startOffset must be > 0" }
675         requirePrecondition(startOffset < textLength) {
676             "startOffset must be less than text length"
677         }
678         requirePrecondition(endOffset > startOffset) {
679             "endOffset must be greater than startOffset"
680         }
681         requirePrecondition(endOffset <= textLength) {
682             "endOffset must be smaller or equal to text length"
683         }
684 
685         val range = endOffset - startOffset
686         val minArraySize = range * 4
687 
688         requirePrecondition((array.size - arrayStart) >= minArraySize) {
689             "array.size - arrayStart must be greater or equal than (endOffset - startOffset) * 4"
690         }
691 
692         val firstLine = getLineForOffset(startOffset)
693         val lastLine = getLineForOffset(endOffset - 1)
694 
695         val cache = HorizontalPositionCache(this)
696 
697         var arrayOffset = arrayStart
698         for (line in firstLine..lastLine) {
699             val lineStartOffset = getLineStart(line)
700             val lineEndOffset = getLineEnd(line)
701             val actualStartOffset = max(startOffset, lineStartOffset)
702             val actualEndOffset = min(endOffset, lineEndOffset)
703 
704             val lineTop = getLineTop(line)
705             val lineBottom = getLineBottom(line)
706 
707             val isLtrLine = getParagraphDirection(line) == Layout.DIR_LEFT_TO_RIGHT
708             val isRtlLine = !isLtrLine
709 
710             for (offset in actualStartOffset until actualEndOffset) {
711                 val isRtlChar = isRtlCharAt(offset)
712 
713                 val left: Float
714                 val right: Float
715                 when {
716                     isLtrLine && !isRtlChar -> {
717                         left = cache.getPrimaryDownstream(offset)
718                         right = cache.getPrimaryUpstream(offset + 1)
719                     }
720                     isLtrLine && isRtlChar -> {
721                         right = cache.getSecondaryDownstream(offset)
722                         left = cache.getSecondaryUpstream(offset + 1)
723                     }
724                     isRtlLine && isRtlChar -> {
725                         right = cache.getPrimaryDownstream(offset)
726                         left = cache.getPrimaryUpstream(offset + 1)
727                     }
728                     else -> {
729                         left = cache.getSecondaryDownstream(offset)
730                         right = cache.getSecondaryUpstream(offset + 1)
731                     }
732                 }
733                 array[arrayOffset] = left
734                 array[arrayOffset + 1] = lineTop
735                 array[arrayOffset + 2] = right
736                 array[arrayOffset + 3] = lineBottom
737                 arrayOffset += 4
738             }
739         }
740     }
741 
742     /** Returns the bounding box as Rect of the character for given character offset. */
getBoundingBoxnull743     fun getBoundingBox(offset: Int): RectF {
744         // Although this function shares its core logic with [fillBoundingBoxes], there is no
745         // need to use a [HorizontalPositionCache]. Hence, [getBoundingBox] runs the same algorithm
746         // without using the cache. Any core logic change here or in [fillBoundingBoxes] should
747         // be reflected on the other.
748         val line = getLineForOffset(offset)
749         val lineTop = getLineTop(line)
750         val lineBottom = getLineBottom(line)
751 
752         val isLtrLine = getParagraphDirection(line) == Layout.DIR_LEFT_TO_RIGHT
753         val isRtlChar = layout.isRtlCharAt(offset)
754 
755         val left: Float
756         val right: Float
757         when {
758             isLtrLine && !isRtlChar -> {
759                 left = getPrimaryHorizontal(offset, upstream = false)
760                 right = getPrimaryHorizontal(offset + 1, upstream = true)
761             }
762             isLtrLine && isRtlChar -> {
763                 right = getSecondaryHorizontal(offset, upstream = false)
764                 left = getSecondaryHorizontal(offset + 1, upstream = true)
765             }
766             isRtlChar -> {
767                 right = getPrimaryHorizontal(offset, upstream = false)
768                 left = getPrimaryHorizontal(offset + 1, upstream = true)
769             }
770             else -> {
771                 left = getSecondaryHorizontal(offset, upstream = false)
772                 right = getSecondaryHorizontal(offset + 1, upstream = true)
773             }
774         }
775         return RectF(left, lineTop, right, lineBottom)
776     }
777 
paintnull778     fun paint(canvas: Canvas) {
779         // Fix "mDirect" optimization in BoringLayout that directly draws text when it's simple
780         // in the case of an empty canvas, we don't need to do anything (which would typically be
781         // done in Layout.draw), so this skips all work when canvas clips to empty - matching the
782         // behavior in Layout.kt
783         if (!canvas.getClipBounds(rect)) {
784             // this is a pure "no-work" optimization for avoiding work when text is simple enough
785             // to hit BoringLayout mDirect optimization and canvas clips to empty
786 
787             // this avoids calling Canvas.drawText on an empty canvas
788             return
789         }
790 
791         if (topPadding != 0) {
792             canvas.translate(0f, topPadding.toFloat())
793         }
794 
795         with(SharedTextAndroidCanvas) {
796             setCanvas(canvas)
797             layout.draw(this)
798         }
799 
800         if (topPadding != 0) {
801             canvas.translate(0f, -1 * topPadding.toFloat())
802         }
803     }
804 
isFallbackLinespacingAppliednull805     internal fun isFallbackLinespacingApplied(): Boolean {
806         return if (isBoringLayout) {
807             BoringLayoutFactory.isFallbackLineSpacingEnabled(layout as BoringLayout)
808         } else {
809             StaticLayoutFactory.isFallbackLineSpacingEnabled(
810                 layout as StaticLayout,
811                 fallbackLineSpacing
812             )
813         }
814     }
815 }
816 
817 /**
818  * This class is intended to be used *only* by [TextLayout.fillBoundingBoxes]. It is tightly coupled
819  * to the code in callee. Do not use.
820  *
821  * Assumes that downstream calls always called with offset followed by offset+1 in upstream case.
822  * Therefore it does not add the downstream calls to the result to the cache but check if it already
823  * exists in the cache for early return.
824  *
825  * On the other hand upstream calls will be cached, since the same offset+1 might be needed on the
826  * next character.
827  */
828 @OptIn(InternalPlatformTextApi::class)
829 private class HorizontalPositionCache(val layout: TextLayout) {
830     private var cachedKey: Int = -1
831     private var cachedValue: Float = 0f
832 
getPrimaryDownstreamnull833     fun getPrimaryDownstream(offset: Int): Float {
834         // downstream results are not cached
835         return get(offset, primary = true, upstream = false, cache = false)
836     }
837 
getPrimaryUpstreamnull838     fun getPrimaryUpstream(offset: Int): Float {
839         // upstream results are cached
840         return get(offset, primary = true, upstream = true, cache = true)
841     }
842 
getSecondaryDownstreamnull843     fun getSecondaryDownstream(offset: Int): Float {
844         // downstream results are not cached
845         return get(offset, primary = false, upstream = false, cache = false)
846     }
847 
getSecondaryUpstreamnull848     fun getSecondaryUpstream(offset: Int): Float {
849         // upstream results are cached
850         return get(offset, primary = false, upstream = true, cache = true)
851     }
852 
853     /**
854      * Returns the primary/secondary horizontal position for upstream or downstream. Very tightly
855      * coupled to how get is called from the [TextLayout.fillBoundingBoxes] function.
856      *
857      * Everytime that function calls either with offset or offset+1. While calling offset, it will
858      * set the cache param to false, while calling with offset+1 it will set the cache param to
859      * true.
860      *
861      * For the noncached version, the cache is checked to see if the value exists and returned if
862      * so.
863      *
864      * For the cached version, the cache is populated if the value has not been calculated.
865      */
getnull866     private fun get(offset: Int, upstream: Boolean, cache: Boolean, primary: Boolean): Float {
867         // even if upstream is requested, if the character is not on a line start/end upstream
868         // and downstream results will be the same
869         val upstreamFinal =
870             if (upstream) {
871                 val lineNo = layout.layout.getLineForOffset(offset, upstream)
872                 val lineStart = layout.getLineStart(lineNo)
873                 val lineEnd = layout.getLineEnd(lineNo)
874                 offset == lineStart || offset == lineEnd
875             } else {
876                 false
877             }
878 
879         // key for the current request
880         val tmpKey =
881             (offset) * 4 +
882                 if (primary) {
883                     if (upstreamFinal) 0 else 1
884                 } else {
885                     if (upstreamFinal) 2 else 3
886                 }
887 
888         if (cachedKey == tmpKey) return cachedValue
889 
890         val result =
891             if (primary) {
892                 layout.getPrimaryHorizontal(offset, upstream = upstream)
893             } else {
894                 layout.getSecondaryHorizontal(offset, upstream = upstream)
895             }
896 
897         if (cache) {
898             cachedKey = tmpKey
899             cachedValue = result
900         }
901 
902         return result
903     }
904 }
905 
906 @OptIn(InternalPlatformTextApi::class)
getTextDirectionHeuristicnull907 internal fun getTextDirectionHeuristic(
908     @TextDirection textDirectionHeuristic: Int
909 ): TextDirectionHeuristic {
910     return when (textDirectionHeuristic) {
911         TEXT_DIRECTION_LTR -> TextDirectionHeuristics.LTR
912         TEXT_DIRECTION_LOCALE -> TextDirectionHeuristics.LOCALE
913         TEXT_DIRECTION_RTL -> TextDirectionHeuristics.RTL
914         TEXT_DIRECTION_FIRST_STRONG_RTL -> TextDirectionHeuristics.FIRSTSTRONG_RTL
915         TEXT_DIRECTION_ANY_RTL_LTR -> TextDirectionHeuristics.ANYRTL_LTR
916         TEXT_DIRECTION_FIRST_STRONG_LTR -> TextDirectionHeuristics.FIRSTSTRONG_LTR
917         else -> TextDirectionHeuristics.FIRSTSTRONG_LTR
918     }
919 }
920 
921 @OptIn(InternalPlatformTextApi::class)
922 internal object TextAlignmentAdapter {
923     private val ALIGN_LEFT_FRAMEWORK: Layout.Alignment
924     private val ALIGN_RIGHT_FRAMEWORK: Layout.Alignment
925 
926     init {
927         val values = Layout.Alignment.values()
928         var alignLeft = Layout.Alignment.ALIGN_NORMAL
929         var alignRight = Layout.Alignment.ALIGN_NORMAL
930         for (value in values) {
931             if (value.name == "ALIGN_LEFT") {
932                 alignLeft = value
933                 continue
934             }
935 
936             if (value.name == "ALIGN_RIGHT") {
937                 alignRight = value
938                 continue
939             }
940         }
941 
942         ALIGN_LEFT_FRAMEWORK = alignLeft
943         ALIGN_RIGHT_FRAMEWORK = alignRight
944     }
945 
getnull946     fun get(@TextLayoutAlignment value: Int): Layout.Alignment {
947         return when (value) {
948             ALIGN_LEFT -> ALIGN_LEFT_FRAMEWORK
949             ALIGN_RIGHT -> ALIGN_RIGHT_FRAMEWORK
950             ALIGN_CENTER -> Layout.Alignment.ALIGN_CENTER
951             ALIGN_OPPOSITE -> Layout.Alignment.ALIGN_OPPOSITE
952             ALIGN_NORMAL -> Layout.Alignment.ALIGN_NORMAL
953             else -> Layout.Alignment.ALIGN_NORMAL
954         }
955     }
956 }
957 
VerticalPaddingsnull958 internal fun VerticalPaddings(topPadding: Int, bottomPadding: Int) =
959     VerticalPaddings(packInts(topPadding, bottomPadding))
960 
961 @kotlin.jvm.JvmInline
962 internal value class VerticalPaddings internal constructor(internal val packedValue: Long) {
963 
964     val topPadding: Int
965         get() = unpackInt1(packedValue)
966 
967     val bottomPadding: Int
968         get() = unpackInt2(packedValue)
969 }
970 
971 @OptIn(InternalPlatformTextApi::class)
getVerticalPaddingsnull972 private fun TextLayout.getVerticalPaddings(): VerticalPaddings {
973     if (includePadding || isFallbackLinespacingApplied()) return ZeroVerticalPadding
974 
975     val paint = layout.paint
976     val text = layout.text
977 
978     val firstLineTextBounds =
979         paint.getCharSequenceBounds(text, layout.getLineStart(0), layout.getLineEnd(0))
980     val ascent = layout.getLineAscent(0)
981 
982     // when textBounds.top is "higher" than ascent, we need to add the difference into account
983     // since includeFontPadding is false, ascent is at the top of Layout
984     val topPadding =
985         if (firstLineTextBounds.top < ascent) {
986             ascent - firstLineTextBounds.top
987         } else {
988             layout.topPadding
989         }
990 
991     val lastLineTextBounds =
992         if (lineCount == 1) {
993             // reuse the existing rect since there is single line
994             firstLineTextBounds
995         } else {
996             val line = lineCount - 1
997             paint.getCharSequenceBounds(text, layout.getLineStart(line), layout.getLineEnd(line))
998         }
999     val descent = layout.getLineDescent(lineCount - 1)
1000 
1001     // when textBounds.bottom is "lower" than descent, we need to add the difference into account
1002     // since includeFontPadding is false, descent is at the bottom of Layout
1003     val bottomPadding =
1004         if (lastLineTextBounds.bottom > descent) {
1005             lastLineTextBounds.bottom - descent
1006         } else {
1007             layout.bottomPadding
1008         }
1009 
1010     return if (topPadding == 0 && bottomPadding == 0) {
1011         ZeroVerticalPadding
1012     } else {
1013         VerticalPaddings(topPadding, bottomPadding)
1014     }
1015 }
1016 
1017 private val ZeroVerticalPadding = VerticalPaddings(0, 0)
1018 
1019 @OptIn(InternalPlatformTextApi::class)
Arraynull1020 private fun Array<LineHeightStyleSpan>.getLineHeightPaddings(): VerticalPaddings {
1021     var firstAscentDiff = 0
1022     var lastDescentDiff = 0
1023 
1024     for (span in this) {
1025         if (span.firstAscentDiff < 0) {
1026             firstAscentDiff = max(firstAscentDiff, abs(span.firstAscentDiff))
1027         }
1028         if (span.lastDescentDiff < 0) {
1029             lastDescentDiff = max(firstAscentDiff, abs(span.lastDescentDiff))
1030         }
1031     }
1032 
1033     return if (firstAscentDiff == 0 && lastDescentDiff == 0) {
1034         ZeroVerticalPadding
1035     } else {
1036         VerticalPaddings(firstAscentDiff, lastDescentDiff)
1037     }
1038 }
1039 
1040 @OptIn(InternalPlatformTextApi::class)
getLastLineMetricsnull1041 private fun TextLayout.getLastLineMetrics(
1042     textPaint: TextPaint,
1043     frameworkTextDir: TextDirectionHeuristic,
1044     lineHeightSpans: Array<LineHeightStyleSpan>?
1045 ): FontMetricsInt? {
1046     val lastLine = lineCount - 1
1047     // did not check for "\n" since the last line might include zero width characters
1048     if (
1049         layout.getLineStart(lastLine) == layout.getLineEnd(lastLine) &&
1050             !lineHeightSpans.isNullOrEmpty()
1051     ) {
1052         val emptyText = SpannableString("\u200B")
1053         val lineHeightSpan = lineHeightSpans.first()
1054         val newLineHeightSpan =
1055             lineHeightSpan.copy(
1056                 startIndex = 0,
1057                 endIndex = emptyText.length,
1058                 trimFirstLineTop =
1059                     if (lastLine != 0 && lineHeightSpan.trimLastLineBottom) {
1060                         false
1061                     } else {
1062                         lineHeightSpan.trimLastLineBottom
1063                     }
1064             )
1065 
1066         emptyText.setSpan(newLineHeightSpan, 0, emptyText.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
1067 
1068         val tmpLayout =
1069             StaticLayoutFactory.create(
1070                 text = emptyText,
1071                 paint = textPaint,
1072                 width = Int.MAX_VALUE,
1073                 start = 0,
1074                 end = emptyText.length,
1075                 textDir = frameworkTextDir,
1076                 includePadding = includePadding,
1077                 useFallbackLineSpacing = fallbackLineSpacing
1078             )
1079 
1080         val lastLineFontMetrics =
1081             FontMetricsInt().apply {
1082                 ascent = tmpLayout.getLineAscent(0)
1083                 descent = tmpLayout.getLineDescent(0)
1084                 top = tmpLayout.getLineTop(0)
1085                 bottom = tmpLayout.getLineBottom(0)
1086             }
1087 
1088         return lastLineFontMetrics
1089     }
1090     return null
1091 }
1092 
1093 @OptIn(InternalPlatformTextApi::class)
getLineHeightSpansnull1094 private fun TextLayout.getLineHeightSpans(): Array<LineHeightStyleSpan>? {
1095     if (text !is Spanned) return null
1096     // text can be empty but still include a LineHeightStyleSpan. In that case hasSpan returns false
1097     // because nextSpanTransition ends up being 0 == text.length
1098     if (!(text as Spanned).hasSpan(LineHeightStyleSpan::class.java) && text.isNotEmpty()) {
1099         return null
1100     }
1101     val lineHeightStyleSpans =
1102         (text as Spanned).getSpans(0, text.length, LineHeightStyleSpan::class.java)
1103     return lineHeightStyleSpans
1104 }
1105 
isLineEllipsizednull1106 internal fun Layout.isLineEllipsized(lineIndex: Int) = this.getEllipsisCount(lineIndex) > 0
1107 
1108 @RequiresApi(34)
1109 internal object AndroidLayoutApi34 {
1110     internal fun getRangeForRect(
1111         layout: TextLayout,
1112         rectF: RectF,
1113         @TextGranularity granularity: Int,
1114         inclusionStrategy: (RectF, RectF) -> Boolean
1115     ): IntArray? {
1116 
1117         val segmentFinder =
1118             when (granularity) {
1119                 TEXT_GRANULARITY_WORD ->
1120                     WordSegmentFinder(layout.text, layout.wordIterator).toAndroidSegmentFinder()
1121                 else -> GraphemeClusterSegmentFinder(layout.text, layout.textPaint)
1122             }
1123 
1124         return layout.layout.getRangeForRect(rectF, segmentFinder, inclusionStrategy)
1125     }
1126 }
1127