1 /*
2  * Copyright 2023 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.foundation.text.modifiers
18 
19 import androidx.compose.foundation.text.DefaultMinLines
20 import androidx.compose.foundation.text.ceilToIntPx
21 import androidx.compose.ui.text.AnnotatedString
22 import androidx.compose.ui.text.MultiParagraph
23 import androidx.compose.ui.text.MultiParagraphIntrinsics
24 import androidx.compose.ui.text.Paragraph
25 import androidx.compose.ui.text.ParagraphIntrinsics
26 import androidx.compose.ui.text.TextLayoutInput
27 import androidx.compose.ui.text.TextLayoutResult
28 import androidx.compose.ui.text.TextStyle
29 import androidx.compose.ui.text.font.FontFamily
30 import androidx.compose.ui.text.resolveDefaults
31 import androidx.compose.ui.text.style.TextOverflow
32 import androidx.compose.ui.unit.Constraints
33 import androidx.compose.ui.unit.Density
34 import androidx.compose.ui.unit.IntSize
35 import androidx.compose.ui.unit.LayoutDirection
36 import androidx.compose.ui.unit.constrain
37 import kotlin.math.min
38 
39 /**
40  * Performs text layout using [Paragraph].
41  *
42  * Results are cached whenever possible, for example when only constraints change in a way that
43  * cannot reflow text.
44  *
45  * All measurements are cached.
46  */
47 internal class ParagraphLayoutCache(
48     private var text: String,
49     private var style: TextStyle,
50     private var fontFamilyResolver: FontFamily.Resolver,
51     private var overflow: TextOverflow = TextOverflow.Clip,
52     private var softWrap: Boolean = true,
53     private var maxLines: Int = Int.MAX_VALUE,
54     private var minLines: Int = DefaultMinLines
55 ) {
56 
57     /**
58      * Density is an interface which makes it behave like a provider, rather than a final class.
59      * Whenever Density changes, the object itself may remain the same, making the below density
60      * variable mutate internally. This value holds the last seen density whenever Compose sends us
61      * a Density may have changed notification via layout or draw phase.
62      */
63     private var lastDensity: InlineDensity = InlineDensity.Unspecified
64 
65     /** Density that text layout is performed in */
66     internal var density: Density? = null
67         set(value) {
68             val localField = field
<lambda>null69             val newDensity = value?.let { InlineDensity(it) } ?: InlineDensity.Unspecified
70             if (localField == null) {
71                 field = value
72                 lastDensity = newDensity
73                 return
74             }
75 
76             if (value == null || lastDensity != newDensity) {
77                 field = value
78                 lastDensity = newDensity
79                 markDirty()
80             }
81         }
82 
83     /** Read to set up a snapshot observer observe changes to fonts. */
84     internal val observeFontChanges: Unit
85         get() {
86             paragraphIntrinsics?.hasStaleResolvedFonts
87         }
88 
89     /** The last computed paragraph */
90     internal var paragraph: Paragraph? = null
91 
92     /** The text did overflow */
93     internal var didOverflow: Boolean = false
94 
95     /** The last computed layout size (as would have been reported in TextLayoutResult) */
96     internal var layoutSize: IntSize = IntSize(0, 0)
97 
98     /** Convert min max lines into actual constraints */
99     private var mMinLinesConstrainer: MinLinesConstrainer? = null
100 
101     /** [ParagraphIntrinsics] will be initialized lazily */
102     private var paragraphIntrinsics: ParagraphIntrinsics? = null
103 
104     /** [LayoutDirection] used to compute [ParagraphIntrinsics] */
105     private var intrinsicsLayoutDirection: LayoutDirection? = null
106 
107     /** Constraints passed to last layout. */
108     private var prevConstraints: Constraints = Constraints.fixed(0, 0)
109 
110     /** Input width for the last call to [intrinsicHeight] */
111     private var cachedIntrinsicHeightInputWidth: Int = -1
112 
113     /** Output height for last call to [intrinsicHeight] at [cachedIntrinsicHeightInputWidth] */
114     private var cachedIntrinsicHeight: Int = -1
115 
116     /**
117      * Update layout constraints for this text
118      *
119      * @return true if constraints caused a text layout invalidation
120      */
layoutWithConstraintsnull121     fun layoutWithConstraints(constraints: Constraints, layoutDirection: LayoutDirection): Boolean {
122         val finalConstraints =
123             if (minLines > 1) {
124                 useMinLinesConstrainer(constraints, layoutDirection)
125             } else {
126                 constraints
127             }
128 
129         if (!newLayoutWillBeDifferent(finalConstraints, layoutDirection)) {
130             if (finalConstraints != prevConstraints) {
131                 // ensure size and overflow is still accurate
132                 val localParagraph = paragraph!!
133                 val layoutWidth = min(localParagraph.maxIntrinsicWidth, localParagraph.width)
134                 val localSize =
135                     finalConstraints.constrain(
136                         IntSize(layoutWidth.ceilToIntPx(), localParagraph.height.ceilToIntPx())
137                     )
138                 layoutSize = localSize
139                 didOverflow =
140                     overflow != TextOverflow.Visible &&
141                         (localSize.width < localParagraph.width ||
142                             localSize.height < localParagraph.height)
143                 prevConstraints = finalConstraints
144             }
145             return false
146         }
147 
148         paragraph =
149             layoutText(finalConstraints, layoutDirection).also {
150                 prevConstraints = finalConstraints
151                 val localSize =
152                     finalConstraints.constrain(
153                         IntSize(it.width.ceilToIntPx(), it.height.ceilToIntPx())
154                     )
155                 layoutSize = localSize
156                 didOverflow =
157                     overflow != TextOverflow.Visible &&
158                         (localSize.width < it.width || localSize.height < it.height)
159             }
160         return true
161     }
162 
useMinLinesConstrainernull163     private fun useMinLinesConstrainer(
164         constraints: Constraints,
165         layoutDirection: LayoutDirection,
166         style: TextStyle = this.style
167     ): Constraints {
168         val localMin =
169             MinLinesConstrainer.from(
170                     mMinLinesConstrainer,
171                     layoutDirection,
172                     style,
173                     density!!,
174                     fontFamilyResolver
175                 )
176                 .also { mMinLinesConstrainer = it }
177         return localMin.coerceMinLines(inConstraints = constraints, minLines = minLines)
178     }
179 
180     /** The natural height of text at [width] in [layoutDirection] */
intrinsicHeightnull181     fun intrinsicHeight(width: Int, layoutDirection: LayoutDirection): Int {
182         val localWidth = cachedIntrinsicHeightInputWidth
183         val localHeght = cachedIntrinsicHeight
184         if (width == localWidth && localWidth != -1) return localHeght
185         val constraints = Constraints(0, width, 0, Constraints.Infinity)
186         val finalConstraints =
187             if (minLines > 1) {
188                 useMinLinesConstrainer(constraints, layoutDirection)
189             } else {
190                 constraints
191             }
192         val result =
193             layoutText(finalConstraints, layoutDirection)
194                 .height
195                 .ceilToIntPx()
196                 .coerceAtLeast(finalConstraints.minHeight)
197 
198         cachedIntrinsicHeightInputWidth = width
199         cachedIntrinsicHeight = result
200         return result
201     }
202 
203     /** Call when any parameters change, invalidation is a result of calling this method. */
updatenull204     fun update(
205         text: String,
206         style: TextStyle,
207         fontFamilyResolver: FontFamily.Resolver,
208         overflow: TextOverflow,
209         softWrap: Boolean,
210         maxLines: Int,
211         minLines: Int
212     ) {
213         this.text = text
214         this.style = style
215         this.fontFamilyResolver = fontFamilyResolver
216         this.overflow = overflow
217         this.softWrap = softWrap
218         this.maxLines = maxLines
219         this.minLines = minLines
220         markDirty()
221     }
222 
223     /**
224      * Minimum information required to compute [MultiParagraphIntrinsics].
225      *
226      * After calling paragraphIntrinsics is cached.
227      */
setLayoutDirectionnull228     private fun setLayoutDirection(layoutDirection: LayoutDirection): ParagraphIntrinsics {
229         val localIntrinsics = paragraphIntrinsics
230         val intrinsics =
231             if (
232                 localIntrinsics == null ||
233                     layoutDirection != intrinsicsLayoutDirection ||
234                     localIntrinsics.hasStaleResolvedFonts
235             ) {
236                 intrinsicsLayoutDirection = layoutDirection
237                 ParagraphIntrinsics(
238                     text = text,
239                     style = resolveDefaults(style, layoutDirection),
240                     annotations = listOf(),
241                     density = density!!,
242                     fontFamilyResolver = fontFamilyResolver,
243                     placeholders = listOf()
244                 )
245             } else {
246                 localIntrinsics
247             }
248         paragraphIntrinsics = intrinsics
249         return intrinsics
250     }
251 
252     /**
253      * Computes the visual position of the glyphs for painting the text.
254      *
255      * The text will layout with a width that's as close to its max intrinsic width as possible
256      * while still being greater than or equal to `minWidth` and less than or equal to `maxWidth`.
257      */
layoutTextnull258     internal fun layoutText(constraints: Constraints, layoutDirection: LayoutDirection): Paragraph {
259         val localParagraphIntrinsics = setLayoutDirection(layoutDirection)
260 
261         return Paragraph(
262             paragraphIntrinsics = localParagraphIntrinsics,
263             constraints =
264                 finalConstraints(
265                     constraints,
266                     softWrap,
267                     overflow,
268                     localParagraphIntrinsics.maxIntrinsicWidth
269                 ),
270             maxLines = finalMaxLines(softWrap, overflow, maxLines),
271             overflow = overflow
272         )
273     }
274 
275     /**
276      * Attempt to compute if the new layout will be the same for the given constraints and
277      * layoutDirection.
278      */
newLayoutWillBeDifferentnull279     private fun newLayoutWillBeDifferent(
280         constraints: Constraints,
281         layoutDirection: LayoutDirection
282     ): Boolean {
283         // paragraph and paragraphIntrinsics are from previous run
284         val localParagraph = paragraph ?: return true
285         val localParagraphIntrinsics = paragraphIntrinsics ?: return true
286         // no layout yet
287 
288         // async typeface changes
289         if (localParagraphIntrinsics.hasStaleResolvedFonts) return true
290 
291         // layout direction changed
292         if (layoutDirection != intrinsicsLayoutDirection) return true
293 
294         // if we were passed identical constraints just skip more work
295         if (constraints == prevConstraints) return false
296 
297         if (constraints.maxWidth != prevConstraints.maxWidth) return true
298         if (constraints.minWidth != prevConstraints.minWidth) return true
299 
300         // if we get here width won't change, height may be clipped
301         if (constraints.maxHeight < localParagraph.height || localParagraph.didExceedMaxLines) {
302             // vertical clip changes
303             return true
304         }
305 
306         // breaks can't change, height can't change
307         return false
308     }
309 
markDirtynull310     private fun markDirty() {
311         paragraph = null
312         paragraphIntrinsics = null
313         intrinsicsLayoutDirection = null
314         cachedIntrinsicHeightInputWidth = -1
315         cachedIntrinsicHeight = -1
316         prevConstraints = Constraints.fixed(0, 0)
317         layoutSize = IntSize(0, 0)
318         didOverflow = false
319     }
320 
321     /**
322      * Compute a [TextLayoutResult] for the current Layout values.
323      *
324      * This does an entire Text layout to produce the result, it is slow.
325      *
326      * Exposed for semantics GetTextLayoutResult
327      */
slowCreateTextLayoutResultOrNullnull328     fun slowCreateTextLayoutResultOrNull(style: TextStyle): TextLayoutResult? {
329         // make sure we're in a valid place
330         val localLayoutDirection = intrinsicsLayoutDirection ?: return null
331         val localDensity = density ?: return null
332         val annotatedString = AnnotatedString(text)
333         paragraph ?: return null
334         paragraphIntrinsics ?: return null
335         val finalConstraints = prevConstraints.copyMaxDimensions()
336 
337         // and redo layout with MultiParagraph
338         return TextLayoutResult(
339             TextLayoutInput(
340                 annotatedString,
341                 style,
342                 emptyList(),
343                 maxLines,
344                 softWrap,
345                 overflow,
346                 localDensity,
347                 localLayoutDirection,
348                 fontFamilyResolver,
349                 finalConstraints
350             ),
351             MultiParagraph(
352                 MultiParagraphIntrinsics(
353                     annotatedString = annotatedString,
354                     style = style,
355                     placeholders = emptyList(),
356                     density = localDensity,
357                     fontFamilyResolver = fontFamilyResolver
358                 ),
359                 finalConstraints,
360                 maxLines,
361                 overflow
362             ),
363             layoutSize
364         )
365     }
366 
367     /** The width for text if all soft wrap opportunities were taken. */
minIntrinsicWidthnull368     fun minIntrinsicWidth(layoutDirection: LayoutDirection): Int {
369         return setLayoutDirection(layoutDirection).minIntrinsicWidth.ceilToIntPx()
370     }
371 
372     /** The width at which increasing the width of the text no lonfger decreases the height. */
maxIntrinsicWidthnull373     fun maxIntrinsicWidth(layoutDirection: LayoutDirection): Int {
374         return setLayoutDirection(layoutDirection).maxIntrinsicWidth.ceilToIntPx()
375     }
376 
toStringnull377     override fun toString(): String =
378         "ParagraphLayoutCache(paragraph=${if (paragraph != null) "<paragraph>" else "null"}, " +
379             "lastDensity=$lastDensity)"
380 }
381