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