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
18 
19 import androidx.compose.foundation.combinedClickable
20 import androidx.compose.foundation.hoverable
21 import androidx.compose.foundation.interaction.MutableInteractionSource
22 import androidx.compose.foundation.layout.Box
23 import androidx.compose.runtime.Composable
24 import androidx.compose.runtime.DisposableEffect
25 import androidx.compose.runtime.LaunchedEffect
26 import androidx.compose.runtime.getValue
27 import androidx.compose.runtime.mutableStateListOf
28 import androidx.compose.runtime.mutableStateOf
29 import androidx.compose.runtime.remember
30 import androidx.compose.runtime.setValue
31 import androidx.compose.ui.Modifier
32 import androidx.compose.ui.geometry.Offset
33 import androidx.compose.ui.geometry.Size
34 import androidx.compose.ui.graphics.Outline
35 import androidx.compose.ui.graphics.Path
36 import androidx.compose.ui.graphics.Shape
37 import androidx.compose.ui.graphics.graphicsLayer
38 import androidx.compose.ui.input.pointer.PointerIcon
39 import androidx.compose.ui.input.pointer.pointerHoverIcon
40 import androidx.compose.ui.layout.ParentDataModifier
41 import androidx.compose.ui.platform.LocalUriHandler
42 import androidx.compose.ui.platform.UriHandler
43 import androidx.compose.ui.semantics.SemanticsProperties.LinkTestMarker
44 import androidx.compose.ui.semantics.semantics
45 import androidx.compose.ui.text.AnnotatedString
46 import androidx.compose.ui.text.LinkAnnotation
47 import androidx.compose.ui.text.SpanStyle
48 import androidx.compose.ui.text.TextLayoutResult
49 import androidx.compose.ui.text.TextLinkStyles
50 import androidx.compose.ui.unit.Density
51 import androidx.compose.ui.unit.IntOffset
52 import androidx.compose.ui.unit.LayoutDirection
53 import androidx.compose.ui.unit.roundToIntRect
54 import androidx.compose.ui.util.fastForEach
55 import kotlin.math.min
56 
57 internal typealias LinkRange = AnnotatedString.Range<LinkAnnotation>
58 
59 /**
60  * A scope that provides necessary information to attach a hyperlink to the text range.
61  *
62  * This class assumes that links exist and does not perform any additional check inside its methods.
63  * Therefore this class initialisation should be guarded by the `hasLinks` check.
64  */
65 internal class TextLinkScope(internal val initialText: AnnotatedString) {
66     var textLayoutResult: TextLayoutResult? by mutableStateOf(null)
67 
68     /** [initialText] with applied links styling to it from [LinkAnnotation.styles] */
69     internal var text: AnnotatedString
70 
71     init {
72         text =
73             initialText.flatMapAnnotations {
74                 // If link styles don't contain a non-null style for at least one of the states,
75                 // we don't add any additional style to the list of annotations
76                 if (
77                     it.item is LinkAnnotation && !(it.item as LinkAnnotation).styles.isNullOrEmpty()
78                 ) {
79                     arrayListOf(
80                         // original link annotation
81                         it,
82                         // SpanStyle from the link styling object, or default SpanStyle otherwise
83                         AnnotatedString.Range(
84                             (it.item as LinkAnnotation).styles?.style ?: SpanStyle(),
85                             it.start,
86                             it.end
87                         )
88                     )
89                 } else {
90                     arrayListOf(it)
91                 }
92             }
93     }
94 
95     // Additional span style annotations applied to the AnnotatedString. These SpanStyles are coming
96     // from LinkAnnotation's style arguments
97     private val annotators = mutableStateListOf<TextAnnotatorScope.() -> Unit>()
98 
99     // indicates whether the links should be measured or not. The latter needed to handle
100     // case where translated string forces measurement before the recomposition. Recomposition in
101     // this case will dispose the links altogether because translator returns plain text
102     val shouldMeasureLinks: () -> Boolean
103         get() = { text == textLayoutResult?.layoutInput?.text }
104 
105     /**
106      * Causes the modified element to be measured with fixed constraints equal to the bounds of the
107      * text range and placed over that range of text.
108      */
109     private fun Modifier.textRange(link: LinkRange): Modifier {
110         return this.then(
111             TextRangeLayoutModifier {
112                 val layoutResult =
113                     textLayoutResult
114                         ?: return@TextRangeLayoutModifier layout(0, 0) { IntOffset.Zero }
115                 val updatedRange =
116                     calculateVisibleLinkRange(link, layoutResult)
117                         ?: return@TextRangeLayoutModifier layout(0, 0) { IntOffset.Zero }
118                 val bounds =
119                     layoutResult
120                         .getPathForRange(updatedRange.start, updatedRange.end)
121                         .getBounds()
122                         .roundToIntRect()
123                 layout(bounds.width, bounds.height) { bounds.topLeft }
124             }
125         )
126     }
127 
128     /**
129      * Clips the Box representing the link to the path of the text range corresponding to that link
130      */
131     private fun Modifier.clipLink(link: LinkRange): Modifier =
132         this.graphicsLayer {
133             shapeForRange(link)?.let { linkShape ->
134                 shape = linkShape
135                 clip = true
136             }
137         }
138 
139     private fun shapeForRange(link: LinkRange): Shape? =
140         pathForRangeInRangeCoordinates(link)?.let {
141             object : Shape {
142                 override fun createOutline(
143                     size: Size,
144                     layoutDirection: LayoutDirection,
145                     density: Density
146                 ): Outline {
147                     return Outline.Generic(it)
148                 }
149             }
150         }
151 
152     private fun pathForRangeInRangeCoordinates(link: LinkRange): Path? {
153         return if (!shouldMeasureLinks()) null
154         else {
155             textLayoutResult?.let {
156                 val range = calculateVisibleLinkRange(link, it) ?: return null
157                 val path = it.getPathForRange(range.start, range.end)
158 
159                 val firstCharBoundingBox = it.getBoundingBox(range.start)
160                 val lastCharBoundingBox = it.getBoundingBox(range.end - 1)
161 
162                 val rangeStartLine = it.getLineForOffset(range.start)
163                 val rangeEndLine = it.getLineForOffset(range.end - 1)
164 
165                 val xOffset =
166                     if (rangeStartLine == rangeEndLine) {
167                         // if the link occupies a single line, we take the left most position of the
168                         // link's range
169                         minOf(lastCharBoundingBox.left, firstCharBoundingBox.left)
170                     } else {
171                         // if the link occupies more than one line, the left sides of the link node
172                         // and
173                         // text node match so we don't need to do anything
174                         0f
175                     }
176 
177                 // the top of the top-most (first) character
178                 val yOffset = firstCharBoundingBox.top
179 
180                 path.translate(-Offset(xOffset, yOffset))
181                 return path
182             }
183         }
184     }
185 
186     /**
187      * Conditionally updates [link]'s end based on [textLayoutResult] so the resulted link range is
188      * within the visible bounds of text. Returns null if the link is fully outside visible text
189      * bounds.
190      *
191      * Avoid calling this in the composition scope, instead call in layout or draw scope of the
192      * modifier.
193      */
194     private fun calculateVisibleLinkRange(
195         link: LinkRange,
196         textLayoutResult: TextLayoutResult
197     ): LinkRange? {
198         // The paragraph with a link might not be added to the paragraphs list if it exceeds
199         // the maxline. The Box will be measured (0, 0) in that case and some other modifier like
200         // clip won't apply
201         val lastOffset = textLayoutResult.getLineEnd(textLayoutResult.lineCount - 1)
202         return if (link.start < lastOffset) {
203             // The link might be clipped if we reach the maxLines so we adjust its end to be the
204             // last visible offset.
205             link.copy(end = min(link.end, lastOffset))
206         } else null
207     }
208 
209     /**
210      * This composable responsible for creating layout nodes for each link annotation. Since
211      * [TextLinkScope] object created *only* when there are links present in the text, we don't need
212      * to do any additional guarding inside this composable function.
213      */
214     @Composable
215     fun LinksComposables() {
216         val uriHandler = LocalUriHandler.current
217 
218         val links = text.getLinkAnnotations(0, text.length)
219         links.fastForEach { range ->
220             if (range.start != range.end) {
221                 val interactionSource = remember { MutableInteractionSource() }
222 
223                 Box(
224                     Modifier.clipLink(range)
225                         .semantics {
226                             // adding this to identify links in tests, see performFirstLinkClick
227                             this[LinkTestMarker] = Unit
228                         }
229                         .textRange(range)
230                         .hoverable(interactionSource)
231                         .pointerHoverIcon(PointerIcon.Hand)
232                         .combinedClickable(
233                             indication = null,
234                             interactionSource = interactionSource,
235                             onClick = { handleLink(range.item, uriHandler) }
236                         )
237                 )
238 
239                 if (!range.item.styles.isNullOrEmpty()) {
240                     // the interaction source is not hoisted, we create and remember it in the
241                     // code above. Therefore there's no need to pass it as a key to the remember and
242                     // a
243                     // launch effect.
244                     val linkStateObserver = remember {
245                         LinkStateInteractionSourceObserver(interactionSource)
246                     }
247                     LaunchedEffect(Unit) { linkStateObserver.collectInteractionsForLinks() }
248 
249                     StyleAnnotation(
250                         linkStateObserver.isHovered,
251                         linkStateObserver.isFocused,
252                         linkStateObserver.isPressed,
253                         range.item.styles?.style,
254                         range.item.styles?.focusedStyle,
255                         range.item.styles?.hoveredStyle,
256                         range.item.styles?.pressedStyle,
257                     ) {
258                         // we calculate the latest style based on the link state and apply it to the
259                         // initialText's style. This allows us to merge the style with the original
260                         // instead of fully replacing it
261                         val mergedStyle =
262                             range.item.styles
263                                 ?.style
264                                 .mergeOrUse(
265                                     if (linkStateObserver.isFocused) range.item.styles?.focusedStyle
266                                     else null
267                                 )
268                                 .mergeOrUse(
269                                     if (linkStateObserver.isHovered) range.item.styles?.hoveredStyle
270                                     else null
271                                 )
272                                 .mergeOrUse(
273                                     if (linkStateObserver.isPressed) range.item.styles?.pressedStyle
274                                     else null
275                                 )
276                         replaceStyle(range, mergedStyle)
277                     }
278                 }
279             }
280         }
281     }
282 
283     private fun SpanStyle?.mergeOrUse(other: SpanStyle?) = this?.merge(other) ?: other
284 
285     private fun handleLink(link: LinkAnnotation, uriHandler: UriHandler) {
286         when (link) {
287             is LinkAnnotation.Url ->
288                 link.linkInteractionListener?.onClick(link)
289                     ?: try {
290                         uriHandler.openUri(link.url)
291                     } catch (_: IllegalArgumentException) {
292                         // we choose to silently fail when the uri can't be opened to avoid crashes
293                         // for users. This is the case where developer don't provide the link
294                         // handlers themselves and therefore I suspect are less likely to test them
295                         // manually.
296                     }
297             is LinkAnnotation.Clickable -> link.linkInteractionListener?.onClick(link)
298         }
299     }
300 
301     /** Returns [text] with additional styles from [LinkAnnotation] based on link's state */
302     internal fun applyAnnotators(): AnnotatedString {
303         val styledText =
304             if (annotators.isEmpty()) text
305             else {
306                 val scope = TextAnnotatorScope(text)
307                 annotators.fastForEach { it.invoke(scope) }
308                 scope.styledText
309             }
310         text = styledText
311         return styledText
312     }
313 
314     /** Adds style annotations to [text]. */
315     @Composable
316     private fun StyleAnnotation(vararg keys: Any?, block: TextAnnotatorScope.() -> Unit) {
317         DisposableEffect(block, *keys) {
318             annotators += block
319             onDispose { annotators -= block }
320         }
321     }
322 }
323 
isNullOrEmptynull324 private fun TextLinkStyles?.isNullOrEmpty(): Boolean {
325     return this == null ||
326         (style == null && focusedStyle == null && hoveredStyle == null && pressedStyle == null)
327 }
328 
329 /** Interface holding the width, height and positioning logic. */
330 internal class TextRangeLayoutMeasureResult
331 internal constructor(val width: Int, val height: Int, val place: () -> IntOffset)
332 
333 /**
334  * The receiver scope of a text range layout's measure lambda. The return value of the measure
335  * lambda is [TextRangeLayoutMeasureResult], which should be returned by [layout]
336  */
337 internal class TextRangeLayoutMeasureScope {
layoutnull338     fun layout(width: Int, height: Int, place: () -> IntOffset): TextRangeLayoutMeasureResult =
339         TextRangeLayoutMeasureResult(width, height, place)
340 }
341 
342 /** Provides the size and placement for an element inside a [TextLinkScope] */
343 internal fun interface TextRangeScopeMeasurePolicy {
344     fun TextRangeLayoutMeasureScope.measure(): TextRangeLayoutMeasureResult
345 }
346 
347 internal class TextRangeLayoutModifier(val measurePolicy: TextRangeScopeMeasurePolicy) :
348     ParentDataModifier {
modifyParentDatanull349     override fun Density.modifyParentData(parentData: Any?) = this@TextRangeLayoutModifier
350 }
351 
352 /**
353  * Provides methods to update styles of the text inside a [TextLinkScope.StyleAnnotation] function.
354  */
355 private class TextAnnotatorScope(private val initialText: AnnotatedString) {
356     var styledText = initialText
357 
358     fun replaceStyle(linkRange: AnnotatedString.Range<LinkAnnotation>, newStyle: SpanStyle?) {
359         var linkFound = false
360         styledText =
361             initialText.mapAnnotations {
362                 // if we found a link annotation on previous iteration, we need to update the
363                 // SpanStyle
364                 // on this iteration. This SpanStyle with the same range as the link annotation
365                 // coming right after the link annotation corresponds to the link styling
366                 val annotation =
367                     if (
368                         linkFound &&
369                             it.item is SpanStyle &&
370                             it.start == linkRange.start &&
371                             it.end == linkRange.end
372                     ) {
373                         AnnotatedString.Range(newStyle ?: SpanStyle(), it.start, it.end)
374                     } else {
375                         it
376                     }
377                 linkFound = linkRange == it
378                 annotation
379             }
380     }
381 }
382