1 /*
<lambda>null2  * 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.internal.requirePreconditionNotNull
20 import androidx.compose.foundation.text.DefaultMinLines
21 import androidx.compose.ui.Modifier
22 import androidx.compose.ui.graphics.Color
23 import androidx.compose.ui.graphics.ColorProducer
24 import androidx.compose.ui.graphics.Shadow
25 import androidx.compose.ui.graphics.drawscope.ContentDrawScope
26 import androidx.compose.ui.graphics.drawscope.Fill
27 import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
28 import androidx.compose.ui.graphics.isSpecified
29 import androidx.compose.ui.layout.AlignmentLine
30 import androidx.compose.ui.layout.FirstBaseline
31 import androidx.compose.ui.layout.IntrinsicMeasurable
32 import androidx.compose.ui.layout.IntrinsicMeasureScope
33 import androidx.compose.ui.layout.LastBaseline
34 import androidx.compose.ui.layout.Measurable
35 import androidx.compose.ui.layout.MeasureResult
36 import androidx.compose.ui.layout.MeasureScope
37 import androidx.compose.ui.node.DrawModifierNode
38 import androidx.compose.ui.node.LayoutModifierNode
39 import androidx.compose.ui.node.SemanticsModifierNode
40 import androidx.compose.ui.node.invalidateDraw
41 import androidx.compose.ui.node.invalidateLayer
42 import androidx.compose.ui.node.invalidateMeasurement
43 import androidx.compose.ui.node.invalidateSemantics
44 import androidx.compose.ui.semantics.SemanticsPropertyReceiver
45 import androidx.compose.ui.semantics.clearTextSubstitution
46 import androidx.compose.ui.semantics.getTextLayoutResult
47 import androidx.compose.ui.semantics.isShowingTextSubstitution
48 import androidx.compose.ui.semantics.setTextSubstitution
49 import androidx.compose.ui.semantics.showTextSubstitution
50 import androidx.compose.ui.semantics.text
51 import androidx.compose.ui.semantics.textSubstitution
52 import androidx.compose.ui.text.AnnotatedString
53 import androidx.compose.ui.text.TextLayoutResult
54 import androidx.compose.ui.text.TextStyle
55 import androidx.compose.ui.text.font.FontFamily
56 import androidx.compose.ui.text.style.TextDecoration
57 import androidx.compose.ui.text.style.TextOverflow
58 import androidx.compose.ui.unit.Constraints
59 import androidx.compose.ui.unit.Constraints.Companion.fitPrioritizingWidth
60 import androidx.compose.ui.util.fastRoundToInt
61 import androidx.compose.ui.util.trace
62 import kotlin.jvm.JvmName
63 
64 /**
65  * Node that implements Text for [String].
66  *
67  * It has reduced functionality, and as a result gains in performance.
68  *
69  * Note that this Node never calculates [TextLayoutResult] unless needed by semantics.
70  */
71 internal class TextStringSimpleNode(
72     private var text: String,
73     private var style: TextStyle,
74     private var fontFamilyResolver: FontFamily.Resolver,
75     private var overflow: TextOverflow = TextOverflow.Clip,
76     private var softWrap: Boolean = true,
77     private var maxLines: Int = Int.MAX_VALUE,
78     private var minLines: Int = DefaultMinLines,
79     private var overrideColor: ColorProducer? = null
80 ) : Modifier.Node(), LayoutModifierNode, DrawModifierNode, SemanticsModifierNode {
81     override val shouldAutoInvalidate: Boolean
82         get() = false
83 
84     @Suppress("PrimitiveInCollection") // Map required for use in public API.
85     // Usages of this collection are so few that the gains of using
86     // MutableObjectIntMap<AlignmentLine> and then converting to a Map<AlignmentLine, Int>
87     // as needed for the public API is not worth the performance benefit.
88     private var baselineCache: MutableMap<AlignmentLine, Int>? = null
89 
90     private var _layoutCache: ParagraphLayoutCache? = null
91     private val layoutCache: ParagraphLayoutCache
92         get() {
93             if (_layoutCache == null) {
94                 _layoutCache =
95                     ParagraphLayoutCache(
96                         text,
97                         style,
98                         fontFamilyResolver,
99                         overflow,
100                         softWrap,
101                         maxLines,
102                         minLines
103                     )
104             }
105             return _layoutCache!!
106         }
107 
108     /**
109      * Get the layout cache for the current state of the node during layout.
110      *
111      * If text substitution is active, this will return the layout cache for the substitution.
112      * Otherwise, it will return the layout cache for the original text.
113      *
114      * @receiver Current measure scope that requests the layout cache. This scope is used to update
115      *   the density value of the returned cache.
116      */
117     private fun IntrinsicMeasureScope.getLayoutCacheForMeasure(): ParagraphLayoutCache {
118         val activeCache = getLayoutCache()
119         activeCache.density = this@getLayoutCacheForMeasure
120         return activeCache
121     }
122 
123     /**
124      * Get the layout cache for the current state of the node without updating the density.
125      *
126      * Warning; DO NOT USE this function from a MeasureScope. Instead please use
127      * [getLayoutCacheForMeasure].
128      *
129      * The reason this function does not update the density value is because the density should not
130      * change between layout and draw phases. This is a micro optimization to skip the unnecessary
131      * density comparison.
132      *
133      * If text substitution is active, this will return the layout cache for the substitution.
134      * Otherwise, it will return the layout cache for the original text.
135      */
136     @JvmName("getLayoutCacheOrSubstitute")
137     private fun getLayoutCache(): ParagraphLayoutCache {
138         return textSubstitution?.takeIf { it.isShowingSubstitution }?.layoutCache ?: layoutCache
139     }
140 
141     fun updateDraw(color: ColorProducer?, style: TextStyle): Boolean {
142         var changed = false
143         if (color != this.overrideColor) {
144             changed = true
145         }
146         overrideColor = color
147         changed = changed || !style.hasSameDrawAffectingAttributes(this.style)
148         return changed
149     }
150 
151     /** Element has text params to update */
152     fun updateText(text: String): Boolean {
153         if (this.text == text) return false
154         this.text = text
155         clearSubstitution()
156         return true
157     }
158 
159     /** Element has layout related params to update */
160     fun updateLayoutRelatedArgs(
161         style: TextStyle,
162         minLines: Int,
163         maxLines: Int,
164         softWrap: Boolean,
165         fontFamilyResolver: FontFamily.Resolver,
166         overflow: TextOverflow
167     ): Boolean {
168         var changed: Boolean
169 
170         changed = !this.style.hasSameLayoutAffectingAttributes(style)
171         this.style = style
172 
173         if (this.minLines != minLines) {
174             this.minLines = minLines
175             changed = true
176         }
177 
178         if (this.maxLines != maxLines) {
179             this.maxLines = maxLines
180             changed = true
181         }
182 
183         if (this.softWrap != softWrap) {
184             this.softWrap = softWrap
185             changed = true
186         }
187 
188         if (this.fontFamilyResolver != fontFamilyResolver) {
189             this.fontFamilyResolver = fontFamilyResolver
190             changed = true
191         }
192 
193         if (this.overflow != overflow) {
194             this.overflow = overflow
195             changed = true
196         }
197 
198         return changed
199     }
200 
201     /** request invalidate based on the results of [updateText] and [updateLayoutRelatedArgs] */
202     fun doInvalidations(drawChanged: Boolean, textChanged: Boolean, layoutChanged: Boolean) {
203         // bring caches up to date even if the node is detached in case it is used again later
204         if (textChanged || layoutChanged) {
205             layoutCache.update(
206                 text = text,
207                 style = style,
208                 fontFamilyResolver = fontFamilyResolver,
209                 overflow = overflow,
210                 softWrap = softWrap,
211                 maxLines = maxLines,
212                 minLines = minLines
213             )
214         }
215 
216         if (!isAttached) {
217             // no-up for !isAttached. The node will invalidate when attaching again.
218             return
219         }
220         if (textChanged || (drawChanged && semanticsTextLayoutResult != null)) {
221             invalidateSemantics()
222         }
223 
224         if (textChanged || layoutChanged) {
225             invalidateMeasurement()
226             invalidateDraw()
227         }
228         if (drawChanged) {
229             invalidateDraw()
230         }
231     }
232 
233     private var semanticsTextLayoutResult: ((MutableList<TextLayoutResult>) -> Boolean)? = null
234 
235     data class TextSubstitutionValue(
236         val original: String,
237         var substitution: String,
238         var isShowingSubstitution: Boolean = false,
239         var layoutCache: ParagraphLayoutCache? = null,
240         // TODO(b/283944749): add animation
241 
242     ) {
243         // don't emit any user strings in toString
244         override fun toString(): String =
245             "TextSubstitution(" +
246                 "layoutCache=$layoutCache, isShowingSubstitution=$isShowingSubstitution" +
247                 ")"
248     }
249 
250     private var textSubstitution: TextSubstitutionValue? = null
251 
252     private fun setSubstitution(updatedText: String): Boolean {
253         val currentTextSubstitution = textSubstitution
254         if (currentTextSubstitution != null) {
255             if (updatedText == currentTextSubstitution.substitution) {
256                 return false
257             }
258             currentTextSubstitution.substitution = updatedText
259             currentTextSubstitution.layoutCache?.update(
260                 updatedText,
261                 style,
262                 fontFamilyResolver,
263                 overflow,
264                 softWrap,
265                 maxLines,
266                 minLines
267             ) ?: return false
268         } else {
269             val newTextSubstitution = TextSubstitutionValue(text, updatedText)
270             val substitutionLayoutCache =
271                 ParagraphLayoutCache(
272                     updatedText,
273                     style,
274                     fontFamilyResolver,
275                     overflow,
276                     softWrap,
277                     maxLines,
278                     minLines
279                 )
280             substitutionLayoutCache.density = layoutCache.density
281             newTextSubstitution.layoutCache = substitutionLayoutCache
282             textSubstitution = newTextSubstitution
283         }
284         return true
285     }
286 
287     private fun clearSubstitution() {
288         textSubstitution = null
289     }
290 
291     override fun SemanticsPropertyReceiver.applySemantics() {
292         var localSemanticsTextLayoutResult = semanticsTextLayoutResult
293         if (localSemanticsTextLayoutResult == null) {
294             localSemanticsTextLayoutResult = { textLayoutResult ->
295                 val layout =
296                     layoutCache
297                         .slowCreateTextLayoutResultOrNull(
298                             style =
299                                 style.merge(color = overrideColor?.invoke() ?: Color.Unspecified)
300                         )
301                         ?.also { textLayoutResult.add(it) }
302                 layout != null
303             }
304             semanticsTextLayoutResult = localSemanticsTextLayoutResult
305         }
306 
307         text = AnnotatedString(this@TextStringSimpleNode.text)
308         val currentTextSubstitution = this@TextStringSimpleNode.textSubstitution
309         if (currentTextSubstitution != null) {
310             isShowingTextSubstitution = currentTextSubstitution.isShowingSubstitution
311             textSubstitution = AnnotatedString(currentTextSubstitution.substitution)
312         }
313 
314         setTextSubstitution { updatedText ->
315             setSubstitution(updatedText.text)
316 
317             invalidateForTranslate()
318 
319             true
320         }
321         showTextSubstitution {
322             if (this@TextStringSimpleNode.textSubstitution == null) {
323                 return@showTextSubstitution false
324             }
325 
326             this@TextStringSimpleNode.textSubstitution?.isShowingSubstitution = it
327 
328             invalidateForTranslate()
329 
330             true
331         }
332         clearTextSubstitution {
333             clearSubstitution()
334 
335             invalidateForTranslate()
336 
337             true
338         }
339         getTextLayoutResult(action = localSemanticsTextLayoutResult)
340     }
341 
342     /** Call whenever text substitution changes state */
343     private fun invalidateForTranslate() {
344         invalidateSemantics()
345         invalidateMeasurement()
346         invalidateDraw()
347     }
348 
349     /** Text layout happens here */
350     override fun MeasureScope.measure(
351         measurable: Measurable,
352         constraints: Constraints
353     ): MeasureResult {
354         trace("TextStringSimpleNode::measure") {
355             val layoutCache = getLayoutCacheForMeasure()
356 
357             val didChangeLayout = layoutCache.layoutWithConstraints(constraints, layoutDirection)
358             // ensure measure restarts when hasStaleResolvedFonts by reading in measure
359             layoutCache.observeFontChanges
360             val paragraph = layoutCache.paragraph!!
361             val layoutSize = layoutCache.layoutSize
362 
363             if (didChangeLayout) {
364                 invalidateLayer()
365                 // Map<AlignmentLine, Int> required for use in public API `layout` below
366                 @Suppress("PrimitiveInCollection") var cache = baselineCache
367                 if (cache == null) {
368                     cache = HashMap(2)
369                     baselineCache = cache
370                 }
371                 cache[FirstBaseline] = paragraph.firstBaseline.fastRoundToInt()
372                 cache[LastBaseline] = paragraph.lastBaseline.fastRoundToInt()
373             }
374 
375             // then allow children to measure _inside_ our final box, with the above placeholders
376             val placeable =
377                 measurable.measure(
378                     fitPrioritizingWidth(
379                         minWidth = layoutSize.width,
380                         maxWidth = layoutSize.width,
381                         minHeight = layoutSize.height,
382                         maxHeight = layoutSize.height
383                     )
384                 )
385 
386             return layout(layoutSize.width, layoutSize.height, baselineCache!!) {
387                 placeable.place(0, 0)
388             }
389         }
390     }
391 
392     override fun IntrinsicMeasureScope.minIntrinsicWidth(
393         measurable: IntrinsicMeasurable,
394         height: Int
395     ): Int {
396         return getLayoutCacheForMeasure().minIntrinsicWidth(layoutDirection)
397     }
398 
399     override fun IntrinsicMeasureScope.minIntrinsicHeight(
400         measurable: IntrinsicMeasurable,
401         width: Int
402     ): Int = getLayoutCacheForMeasure().intrinsicHeight(width, layoutDirection)
403 
404     override fun IntrinsicMeasureScope.maxIntrinsicWidth(
405         measurable: IntrinsicMeasurable,
406         height: Int
407     ): Int = getLayoutCacheForMeasure().maxIntrinsicWidth(layoutDirection)
408 
409     override fun IntrinsicMeasureScope.maxIntrinsicHeight(
410         measurable: IntrinsicMeasurable,
411         width: Int
412     ): Int = getLayoutCacheForMeasure().intrinsicHeight(width, layoutDirection)
413 
414     /** Optimized Text draw. */
415     override fun ContentDrawScope.draw() {
416         if (!isAttached) {
417             // no-up for !isAttached. The node will invalidate when attaching again.
418             return
419         }
420 
421         val layoutCache = getLayoutCache()
422         val localParagraph =
423             requirePreconditionNotNull(layoutCache.paragraph) {
424                 "no paragraph (layoutCache=$_layoutCache, textSubstitution=$textSubstitution)"
425             }
426 
427         drawIntoCanvas { canvas ->
428             val willClip = layoutCache.didOverflow
429             if (willClip) {
430                 val width = layoutCache.layoutSize.width.toFloat()
431                 val height = layoutCache.layoutSize.height.toFloat()
432                 canvas.save()
433                 canvas.clipRect(left = 0f, top = 0f, right = width, bottom = height)
434             }
435             try {
436                 val textDecoration = style.textDecoration ?: TextDecoration.None
437                 val shadow = style.shadow ?: Shadow.None
438                 val drawStyle = style.drawStyle ?: Fill
439                 val brush = style.brush
440                 if (brush != null) {
441                     val alpha = style.alpha
442                     localParagraph.paint(
443                         canvas = canvas,
444                         brush = brush,
445                         alpha = alpha,
446                         shadow = shadow,
447                         drawStyle = drawStyle,
448                         textDecoration = textDecoration
449                     )
450                 } else {
451                     val overrideColorVal = overrideColor?.invoke() ?: Color.Unspecified
452                     val color =
453                         if (overrideColorVal.isSpecified) {
454                             overrideColorVal
455                         } else if (style.color.isSpecified) {
456                             style.color
457                         } else {
458                             Color.Black
459                         }
460                     localParagraph.paint(
461                         canvas = canvas,
462                         color = color,
463                         shadow = shadow,
464                         drawStyle = drawStyle,
465                         textDecoration = textDecoration
466                     )
467                 }
468             } finally {
469                 if (willClip) {
470                     canvas.restore()
471                 }
472             }
473         }
474     }
475 }
476