1 /*
<lambda>null2  * Copyright 2019 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.compose.ui.text.android
18 
19 import android.text.BoringLayout
20 import android.text.Layout
21 import android.text.SpannableString
22 import android.text.Spanned
23 import android.text.TextPaint
24 import android.text.style.CharacterStyle
25 import android.text.style.MetricAffectingSpan
26 import androidx.compose.ui.text.android.style.LetterSpacingSpanEm
27 import androidx.compose.ui.text.android.style.LetterSpacingSpanPx
28 import java.text.BreakIterator
29 import java.util.PriorityQueue
30 import kotlin.math.ceil
31 
32 /**
33  * Flag for applying the fix for [b/346918500](https://issuetracker.google.com/346918500).
34  *
35  * If true, this will allocate a new [SpannableString] if there are spans that must be removed
36  * before measuring any intrinsic width.
37  */
38 @Suppress("MayBeConstant") // Don't inline so folks can R8 assumevalues it
39 private val stripNonMetricAffectingCharSpans: Boolean = true
40 
41 /** Computes and caches the text layout intrinsic values such as min/max width. */
42 internal class LayoutIntrinsics(
43     private val charSequence: CharSequence,
44     private val textPaint: TextPaint,
45     @LayoutCompat.TextDirection private val textDirectionHeuristic: Int
46 ) {
47 
48     private var _maxIntrinsicWidth: Float = Float.NaN
49     private var _minIntrinsicWidth: Float = Float.NaN
50     private var _boringMetrics: BoringLayout.Metrics? = null
51     private var boringMetricsIsInit: Boolean = false
52 
53     private var _charSequenceForIntrinsicWidth: CharSequence? = null
54     private val charSequenceForIntrinsicWidth: CharSequence
55         get() =
56             if (_charSequenceForIntrinsicWidth == null) {
57                 if (stripNonMetricAffectingCharSpans) {
58                     stripNonMetricAffectingCharacterStyleSpans(charSequence).also {
59                         _charSequenceForIntrinsicWidth = it
60                     }
61                 } else {
62                     charSequence
63                 }
64             } else {
65                 _charSequenceForIntrinsicWidth!!
66             }
67 
68     /**
69      * Compute Android platform BoringLayout metrics. A null value means the provided CharSequence
70      * cannot be laid out using a BoringLayout.
71      */
72     val boringMetrics: BoringLayout.Metrics?
73         get() {
74             if (!boringMetricsIsInit) {
75                 val frameworkTextDir = getTextDirectionHeuristic(textDirectionHeuristic)
76                 _boringMetrics =
77                     BoringLayoutFactory.measure(charSequence, textPaint, frameworkTextDir)
78                 boringMetricsIsInit = true
79             }
80             return _boringMetrics
81         }
82 
83     /**
84      * Calculate minimum intrinsic width of the CharSequence.
85      *
86      * @see androidx.compose.ui.text.android.minIntrinsicWidth
87      */
88     val minIntrinsicWidth: Float
89         get() =
90             if (!_minIntrinsicWidth.isNaN()) {
91                 _minIntrinsicWidth
92             } else {
93                 _minIntrinsicWidth = computeMinIntrinsicWidth()
94                 _minIntrinsicWidth
95             }
96 
97     /**
98      * Returns the word with the longest length. To calculate it in a performant way, it applies a
99      * heuristics where
100      * - it first finds a set of words with the longest length
101      * - finds the word with maximum width in that set
102      */
103     private fun computeMinIntrinsicWidth(): Float {
104         val iterator = BreakIterator.getLineInstance(textPaint.textLocale)
105         iterator.text = CharSequenceCharacterIterator(charSequence, 0, charSequence.length)
106 
107         // 10 is just a random number that limits the size of the candidate list
108         val heapSize = 10
109         // min heap that will hold [heapSize] many words with max length
110         val longestWordCandidates =
111             PriorityQueue(
112                 heapSize,
113                 Comparator<Pair<Int, Int>> { left, right ->
114                     (left.second - left.first) - (right.second - right.first)
115                 }
116             )
117 
118         var start = 0
119         var end = iterator.next()
120         while (end != BreakIterator.DONE) {
121             if (longestWordCandidates.size < heapSize) {
122                 longestWordCandidates.add(Pair(start, end))
123             } else {
124                 longestWordCandidates.peek()?.let { minPair ->
125                     if ((minPair.second - minPair.first) < (end - start)) {
126                         longestWordCandidates.poll()
127                         longestWordCandidates.add(Pair(start, end))
128                     }
129                 }
130             }
131 
132             start = end
133             end = iterator.next()
134         }
135 
136         return if (longestWordCandidates.isEmpty()) 0f
137         else longestWordCandidates.maxOf { (start, end) -> getDesiredWidth(start, end) }
138     }
139 
140     /**
141      * Calculate maximum intrinsic width for the CharSequence. Maximum intrinsic width is the width
142      * of text where no soft line breaks are applied.
143      */
144     val maxIntrinsicWidth: Float
145         get() =
146             if (!_maxIntrinsicWidth.isNaN()) {
147                 _maxIntrinsicWidth
148             } else {
149                 _maxIntrinsicWidth = computeMaxIntrinsicWidth()
150                 _maxIntrinsicWidth
151             }
152 
153     private fun computeMaxIntrinsicWidth(): Float {
154         var desiredWidth = (boringMetrics?.width ?: -1).toFloat()
155 
156         // boring metrics doesn't cover RTL text so we fallback to different calculation
157         // when boring metrics can't be calculated
158         if (desiredWidth < 0) {
159             // b/233856978, apply `ceil` function here to be consistent with the boring
160             // metrics width calculation that does it under the hood, too
161             desiredWidth = ceil(getDesiredWidth())
162         }
163 
164         if (shouldIncreaseMaxIntrinsic(desiredWidth, charSequence, textPaint)) {
165             // b/173574230, increase maxIntrinsicWidth, so that StaticLayout won't form 2
166             // lines for the given maxIntrinsicWidth
167             desiredWidth += 0.5f
168         }
169         return desiredWidth
170     }
171 
172     private fun getDesiredWidth(
173         start: Int = 0,
174         end: Int = charSequenceForIntrinsicWidth.length
175     ): Float = Layout.getDesiredWidth(charSequenceForIntrinsicWidth, start, end, textPaint)
176 }
177 
178 /**
179  * See [b/346918500#comment7](https://issuetracker.google.com/346918500#comment7).
180  *
181  * Remove all character styling spans for measuring intrinsic width. [CharacterStyle] spans may
182  * affect the intrinsic width, even though they aren't supposed to, resulting in a width that
183  * doesn't actually fit the text. This can cause the line to unexpectedly wrap, even if `maxLines`
184  * was set to `1` or `softWrap` was `false`.
185  *
186  * [MetricAffectingSpan] extends [CharacterStyle], but [MetricAffectingSpan]s are allowed to affect
187  * the width, so only remove spans that **do** extend [CharacterStyle] but **don't** extend
188  * [MetricAffectingSpan].
189  */
stripNonMetricAffectingCharacterStyleSpansnull190 private fun stripNonMetricAffectingCharacterStyleSpans(charSequence: CharSequence): CharSequence {
191     if (charSequence !is Spanned || !charSequence.hasSpan(CharacterStyle::class.java)) {
192         return charSequence
193     }
194 
195     val spans = charSequence.getSpans(0, charSequence.length, CharacterStyle::class.java)
196     if (spans.isNullOrEmpty()) return charSequence
197 
198     // Don't allocate a new SpannableString unless we are certain we will be modifying it.
199     var spannableString: SpannableString? = null
200     for (span in spans) {
201         if (span is MetricAffectingSpan) continue
202         if (spannableString == null) spannableString = SpannableString(charSequence)
203         spannableString.removeSpan(span)
204     }
205     return spannableString ?: charSequence
206 }
207 
208 /**
209  * b/173574230 on Android 11 and above, creating a StaticLayout when
210  * - desiredWidth is an Integer,
211  * - letterSpacing is set
212  * - lineHeight is set StaticLayout forms 2 lines for the given desiredWidth.
213  *
214  * This function checks if those conditions are met.
215  */
shouldIncreaseMaxIntrinsicnull216 private fun shouldIncreaseMaxIntrinsic(
217     desiredWidth: Float,
218     charSequence: CharSequence,
219     textPaint: TextPaint
220 ): Boolean {
221     return desiredWidth != 0f &&
222         (charSequence is Spanned &&
223             (charSequence.hasSpan(LetterSpacingSpanPx::class.java) ||
224                 charSequence.hasSpan(LetterSpacingSpanEm::class.java)) ||
225             textPaint.letterSpacing != 0f)
226 }
227