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
18
19 import androidx.collection.mutableIntListOf
20 import androidx.compose.runtime.Immutable
21 import androidx.compose.runtime.Stable
22 import androidx.compose.runtime.saveable.Saver
23 import androidx.compose.ui.text.AnnotatedString.Annotation
24 import androidx.compose.ui.text.AnnotatedString.Builder
25 import androidx.compose.ui.text.AnnotatedString.Range
26 import androidx.compose.ui.text.internal.checkPrecondition
27 import androidx.compose.ui.text.internal.requirePrecondition
28 import androidx.compose.ui.text.intl.LocaleList
29 import androidx.compose.ui.text.style.TextIndent
30 import androidx.compose.ui.unit.TextUnit
31 import androidx.compose.ui.unit.TextUnitType.Companion.Em
32 import androidx.compose.ui.unit.TextUnitType.Companion.Sp
33 import androidx.compose.ui.unit.em
34 import androidx.compose.ui.unit.sp
35 import androidx.compose.ui.util.fastAny
36 import androidx.compose.ui.util.fastCoerceIn
37 import androidx.compose.ui.util.fastFilter
38 import androidx.compose.ui.util.fastFilteredMap
39 import androidx.compose.ui.util.fastFlatMap
40 import androidx.compose.ui.util.fastForEach
41 import androidx.compose.ui.util.fastMap
42 import kotlin.jvm.JvmName
43
44 /**
45 * The basic data structure of text with multiple styles. To construct an [AnnotatedString] you can
46 * use [Builder].
47 */
48 @Immutable
49 class AnnotatedString
50 internal constructor(internal val annotations: List<Range<out Annotation>>?, val text: String) :
51 CharSequence {
52
53 internal val spanStylesOrNull: List<Range<SpanStyle>>?
54 /** All [SpanStyle] that have been applied to a range of this String */
55 val spanStyles: List<Range<SpanStyle>>
56 get() = spanStylesOrNull ?: listOf()
57
58 internal val paragraphStylesOrNull: List<Range<ParagraphStyle>>?
59 /** All [ParagraphStyle] that have been applied to a range of this String */
60 val paragraphStyles: List<Range<ParagraphStyle>>
61 get() = paragraphStylesOrNull ?: listOf()
62
63 /**
64 * The basic data structure of text with multiple styles. To construct an [AnnotatedString] you
65 * can use [Builder].
66 *
67 * If you need to provide other types of [Annotation]s, use an alternative constructor.
68 *
69 * @param text the text to be displayed.
70 * @param spanStyles a list of [Range]s that specifies [SpanStyle]s on certain portion of the
71 * text. These styles will be applied in the order of the list. And the [SpanStyle]s applied
72 * later can override the former styles. Notice that [SpanStyle] attributes which are null or
73 * unspecified won't change the current ones.
74 * @param paragraphStyles a list of [Range]s that specifies [ParagraphStyle]s on certain portion
75 * of the text. Each [ParagraphStyle] with a [Range] defines a paragraph of text. It's
76 * required that [Range]s of paragraphs don't overlap with each other. If there are gaps
77 * between specified paragraph [Range]s, a default paragraph will be created in between.
78 * @throws IllegalArgumentException if [paragraphStyles] contains any two overlapping [Range]s.
79 * @sample androidx.compose.ui.text.samples.AnnotatedStringConstructorSample
80 * @see SpanStyle
81 * @see ParagraphStyle
82 */
83 constructor(
84 text: String,
85 spanStyles: List<Range<SpanStyle>> = listOf(),
86 paragraphStyles: List<Range<ParagraphStyle>> = listOf()
87 ) : this(constructAnnotationsFromSpansAndParagraphs(spanStyles, paragraphStyles), text)
88
89 /**
90 * The basic data structure of text with multiple styles and other annotations. To construct an
91 * [AnnotatedString] you may use a [Builder].
92 *
93 * @param text the text to be displayed.
94 * @param annotations a list of [Range]s that specifies [Annotation]s on certain portion of the
95 * text. These annotations will be applied in the order of the list. There're a few properties
96 * that these annotations have:
97 * - [Annotation]s applied later can override the former annotations. For example, the
98 * attributes of the last applied [SpanStyle] will override similar attributes of the
99 * previously applied [SpanStyle]s.
100 * - [SpanStyle] attributes which are null or Unspecified won't change the styling.
101 * - If there are gaps between specified paragraph [Range]s, a default paragraph will be created
102 * in between.
103 * - The paragraph [Range]s can't partially overlap. They must either not overlap at all, be
104 * nested (when inner paragraph's range is fully within the range of the outer paragraph) or
105 * fully overlap (when ranges of two paragraph are the same). For more details check the
106 * [AnnotatedString.Builder.addStyle] documentation.
107 *
108 * @throws IllegalArgumentException if [ParagraphStyle]s contains any two overlapping [Range]s.
109 * @sample androidx.compose.ui.text.samples.AnnotatedStringMainConstructorSample
110 * @see Annotation
111 */
112 constructor(
113 text: String,
114 annotations: List<Range<out Annotation>> = listOf()
115 ) : this(annotations.ifEmpty { null }, text)
116
117 init {
118 var spanStyles: MutableList<Range<SpanStyle>>? = null
119 var paragraphStyles: MutableList<Range<ParagraphStyle>>? = null
120 @Suppress("UNCHECKED_CAST")
121 annotations?.fastForEach { annotation ->
122 if (annotation.item is SpanStyle) {
123 if (spanStyles == null) {
124 spanStyles = mutableListOf()
125 }
126 spanStyles!!.add(annotation as Range<SpanStyle>)
127 } else if (annotation.item is ParagraphStyle) {
128 if (paragraphStyles == null) {
129 paragraphStyles = mutableListOf()
130 }
131 paragraphStyles!!.add(annotation as Range<ParagraphStyle>)
132 }
133 }
134 spanStylesOrNull = spanStyles
135 paragraphStylesOrNull = paragraphStyles
136
137 @Suppress("ListIterator") val sorted = paragraphStylesOrNull?.sortedBy { it.start }
138 if (!sorted.isNullOrEmpty()) {
139 val previousEnds = mutableIntListOf(sorted.first().end)
140 for (i in 1 until sorted.size) {
141 val current = sorted[i]
142 // [*************************************].....
143 // ..[******]..................................
144 // ................[***************]...........
145 // ..................[******]..................
146 // current can only be one of these relatively to previous (start/end inclusive)
147 // ................... [**]...[**]...[**]..[**]
148 while (previousEnds.isNotEmpty()) {
149 val previousEnd = previousEnds.last()
150 if (current.start >= previousEnd) {
151 previousEnds.removeAt(previousEnds.lastIndex)
152 } else {
153 requirePrecondition(current.end <= previousEnd) {
154 "Paragraph overlap not allowed, end ${current.end} should be less than or equal to $previousEnd"
155 }
156 break
157 }
158 }
159 previousEnds.add(current.end)
160 }
161 }
162 }
163
164 override val length: Int
165 get() = text.length
166
167 override operator fun get(index: Int): Char = text[index]
168
169 /**
170 * Return a substring for the AnnotatedString and include the styles in the range of
171 * [startIndex] (inclusive) and [endIndex] (exclusive).
172 *
173 * @param startIndex the inclusive start offset of the range
174 * @param endIndex the exclusive end offset of the range
175 */
176 override fun subSequence(startIndex: Int, endIndex: Int): AnnotatedString {
177 requirePrecondition(startIndex <= endIndex) {
178 "start ($startIndex) should be less or equal to end ($endIndex)"
179 }
180 if (startIndex == 0 && endIndex == text.length) return this
181 val text = text.substring(startIndex, endIndex)
182 return AnnotatedString(
183 text = text,
184 annotations = filterRanges(annotations, startIndex, endIndex)
185 )
186 }
187
188 /**
189 * Return a substring for the AnnotatedString and include the styles in the given [range].
190 *
191 * @param range the text range
192 * @see subSequence(start: Int, end: Int)
193 */
194 fun subSequence(range: TextRange): AnnotatedString {
195 return subSequence(range.min, range.max)
196 }
197
198 @Stable
199 operator fun plus(other: AnnotatedString): AnnotatedString {
200 return with(Builder(this)) {
201 append(other)
202 toAnnotatedString()
203 }
204 }
205
206 /**
207 * Query the string annotations attached on this AnnotatedString. Annotations are metadata
208 * attached on the AnnotatedString, for example, a URL is a string metadata attached on the a
209 * certain range. Annotations are also store with [Range] like the styles.
210 *
211 * @param tag the tag of the annotations that is being queried. It's used to distinguish the
212 * annotations for different purposes.
213 * @param start the start of the query range, inclusive.
214 * @param end the end of the query range, exclusive.
215 * @return a list of annotations stored in [Range]. Notice that All annotations that intersect
216 * with the range [start, end) will be returned. When [start] is bigger than [end], an empty
217 * list will be returned.
218 */
219 @Suppress("UNCHECKED_CAST", "KotlinRedundantDiagnosticSuppress")
220 fun getStringAnnotations(tag: String, start: Int, end: Int): List<Range<String>> =
221 (annotations?.fastFilteredMap({
222 it.item is StringAnnotation && tag == it.tag && intersect(start, end, it.start, it.end)
223 }) {
224 it.unbox()
225 } ?: listOf())
226
227 /**
228 * Returns true if [getStringAnnotations] with the same parameters would return a non-empty list
229 */
230 fun hasStringAnnotations(tag: String, start: Int, end: Int): Boolean =
231 annotations?.fastAny {
232 it.item is StringAnnotation && tag == it.tag && intersect(start, end, it.start, it.end)
233 } ?: false
234
235 /**
236 * Query all of the string annotations attached on this AnnotatedString.
237 *
238 * @param start the start of the query range, inclusive.
239 * @param end the end of the query range, exclusive.
240 * @return a list of annotations stored in [Range]. Notice that All annotations that intersect
241 * with the range [start, end) will be returned. When [start] is bigger than [end], an empty
242 * list will be returned.
243 */
244 @Suppress("UNCHECKED_CAST", "KotlinRedundantDiagnosticSuppress")
245 fun getStringAnnotations(start: Int, end: Int): List<Range<String>> =
246 annotations?.fastFilteredMap({
247 it.item is StringAnnotation && intersect(start, end, it.start, it.end)
248 }) {
249 it.unbox()
250 } ?: listOf()
251
252 /**
253 * Query all of the [TtsAnnotation]s attached on this [AnnotatedString].
254 *
255 * @param start the start of the query range, inclusive.
256 * @param end the end of the query range, exclusive.
257 * @return a list of annotations stored in [Range]. Notice that All annotations that intersect
258 * with the range [start, end) will be returned. When [start] is bigger than [end], an empty
259 * list will be returned.
260 */
261 @Suppress("UNCHECKED_CAST")
262 fun getTtsAnnotations(start: Int, end: Int): List<Range<TtsAnnotation>> =
263 ((annotations?.fastFilter {
264 it.item is TtsAnnotation && intersect(start, end, it.start, it.end)
265 } ?: listOf())
266 as List<Range<TtsAnnotation>>)
267
268 /**
269 * Query all of the [UrlAnnotation]s attached on this [AnnotatedString].
270 *
271 * @param start the start of the query range, inclusive.
272 * @param end the end of the query range, exclusive.
273 * @return a list of annotations stored in [Range]. Notice that All annotations that intersect
274 * with the range [start, end) will be returned. When [start] is bigger than [end], an empty
275 * list will be returned.
276 */
277 @ExperimentalTextApi
278 @Suppress("UNCHECKED_CAST", "Deprecation")
279 @Deprecated("Use LinkAnnotation API instead", ReplaceWith("getLinkAnnotations(start, end)"))
280 fun getUrlAnnotations(start: Int, end: Int): List<Range<UrlAnnotation>> =
281 ((annotations?.fastFilter {
282 it.item is UrlAnnotation && intersect(start, end, it.start, it.end)
283 } ?: listOf())
284 as List<Range<UrlAnnotation>>)
285
286 /**
287 * Query all of the [LinkAnnotation]s attached on this [AnnotatedString].
288 *
289 * @param start the start of the query range, inclusive.
290 * @param end the end of the query range, exclusive.
291 * @return a list of annotations stored in [Range]. Notice that All annotations that intersect
292 * with the range [start, end) will be returned. When [start] is bigger than [end], an empty
293 * list will be returned.
294 */
295 @Suppress("UNCHECKED_CAST")
296 fun getLinkAnnotations(start: Int, end: Int): List<Range<LinkAnnotation>> =
297 ((annotations?.fastFilter {
298 it.item is LinkAnnotation && intersect(start, end, it.start, it.end)
299 } ?: listOf())
300 as List<Range<LinkAnnotation>>)
301
302 /**
303 * Returns true if [getLinkAnnotations] with the same parameters would return a non-empty list
304 */
305 fun hasLinkAnnotations(start: Int, end: Int): Boolean =
306 annotations?.fastAny {
307 it.item is LinkAnnotation && intersect(start, end, it.start, it.end)
308 } ?: false
309
310 override fun equals(other: Any?): Boolean {
311 if (this === other) return true
312 if (other !is AnnotatedString) return false
313 if (text != other.text) return false
314 if (annotations != other.annotations) return false
315 return true
316 }
317
318 override fun hashCode(): Int {
319 var result = text.hashCode()
320 result = 31 * result + (annotations?.hashCode() ?: 0)
321 return result
322 }
323
324 override fun toString(): String {
325 // AnnotatedString.toString has special value, it converts it into regular String
326 // rather than debug string.
327 return text
328 }
329
330 /**
331 * Compare the annotations between this and another AnnotatedString.
332 *
333 * This may be used for fast partial equality checks.
334 *
335 * Note that this checks all annotations including [spanStyles] and [paragraphStyles], but
336 * [equals] still may be false if [text] is different.
337 *
338 * @param other to compare annotations with
339 * @return true if and only if this compares equal on annotations with other
340 */
341 fun hasEqualAnnotations(other: AnnotatedString): Boolean = this.annotations == other.annotations
342
343 /**
344 * Returns a new [AnnotatedString] where a list of annotations contains the results of applying
345 * the given [transform] function to each element in the original annotations list.
346 *
347 * @sample androidx.compose.ui.text.samples.AnnotatedStringMapAnnotationsSamples
348 */
349 fun mapAnnotations(
350 transform: (Range<out Annotation>) -> Range<out Annotation>
351 ): AnnotatedString {
352 val builder = Builder(this)
353 builder.mapAnnotations(transform)
354 return builder.toAnnotatedString()
355 }
356
357 /**
358 * Returns a new [AnnotatedString] where a list of annotations contains all elements yielded
359 * from results [transform] function being invoked on each element of original annotations list.
360 *
361 * @see mapAnnotations
362 */
363 fun flatMapAnnotations(
364 transform: (Range<out Annotation>) -> List<Range<out Annotation>>
365 ): AnnotatedString {
366 val builder = Builder(this)
367 builder.flatMapAnnotations(transform)
368 return builder.toAnnotatedString()
369 }
370
371 /**
372 * The information attached on the text such as a [SpanStyle].
373 *
374 * @param item The object attached to [AnnotatedString]s.
375 * @param start The start of the range where [item] takes effect. It's inclusive
376 * @param end The end of the range where [item] takes effect. It's exclusive
377 * @param tag The tag used to distinguish the different ranges. It is useful to store custom
378 * data. And [Range]s with same tag can be queried with functions such as
379 * [getStringAnnotations].
380 */
381 @Immutable
382 @Suppress("DataClassDefinition")
383 data class Range<T>(val item: T, val start: Int, val end: Int, val tag: String) {
384 constructor(item: T, start: Int, end: Int) : this(item, start, end, "")
385
386 init {
387 requirePrecondition(start <= end) { "Reversed range is not supported" }
388 }
389 }
390
391 /**
392 * Builder class for AnnotatedString. Enables construction of an [AnnotatedString] using methods
393 * such as [append] and [addStyle].
394 *
395 * @sample androidx.compose.ui.text.samples.AnnotatedStringBuilderSample
396 *
397 * This class implements [Appendable] and can be used with other APIs that don't know about
398 * [AnnotatedString]s:
399 *
400 * @sample androidx.compose.ui.text.samples.AnnotatedStringBuilderAppendableSample
401 * @param capacity initial capacity for the internal char buffer
402 */
403 class Builder(capacity: Int = 16) : Appendable {
404
405 private data class MutableRange<T>(
406 val item: T,
407 val start: Int,
408 var end: Int = Int.MIN_VALUE,
409 val tag: String = ""
410 ) {
411 /**
412 * Create an immutable [Range] object.
413 *
414 * @param defaultEnd if the end is not set yet, it will be set to this value.
415 */
416 fun toRange(defaultEnd: Int = Int.MIN_VALUE): Range<T> {
417 val end = if (end == Int.MIN_VALUE) defaultEnd else end
418 checkPrecondition(end != Int.MIN_VALUE) { "Item.end should be set first" }
419 return Range(item = item, start = start, end = end, tag = tag)
420 }
421
422 /**
423 * Create an immutable [Range] object.
424 *
425 * @param defaultEnd if the end is not set yet, it will be set to this value.
426 */
427 fun <R> toRange(transform: (T) -> R, defaultEnd: Int = Int.MIN_VALUE): Range<R> {
428 val end = if (end == Int.MIN_VALUE) defaultEnd else end
429 checkPrecondition(end != Int.MIN_VALUE) { "Item.end should be set first" }
430 return Range(item = transform(item), start = start, end = end, tag = tag)
431 }
432
433 companion object {
434 fun <T> fromRange(range: Range<T>) =
435 MutableRange(range.item, range.start, range.end, range.tag)
436 }
437 }
438
439 private val text: StringBuilder = StringBuilder(capacity)
440 private val styleStack: MutableList<MutableRange<out Any>> = mutableListOf()
441 /**
442 * Holds all objects of type [AnnotatedString.Annotation] including [SpanStyle]s and
443 * [ParagraphStyle]s in order.
444 */
445 private val annotations = mutableListOf<MutableRange<out Annotation>>()
446
447 /** Create an [Builder] instance using the given [String]. */
448 constructor(text: String) : this() {
449 append(text)
450 }
451
452 /** Create an [Builder] instance using the given [AnnotatedString]. */
453 constructor(text: AnnotatedString) : this() {
454 append(text)
455 }
456
457 /** Returns the length of the [String]. */
458 val length: Int
459 get() = text.length
460
461 /**
462 * Appends the given [String] to this [Builder].
463 *
464 * @param text the text to append
465 */
466 fun append(text: String) {
467 this.text.append(text)
468 }
469
470 @Deprecated(
471 message =
472 "Replaced by the append(Char) method that returns an Appendable. " +
473 "This method must be kept around for binary compatibility.",
474 level = DeprecationLevel.HIDDEN
475 )
476 @Suppress("FunctionName", "unused")
477 // Set the JvmName to preserve compatibility with bytecode that expects a void return type.
478 @JvmName("append")
479 fun deprecated_append_returning_void(char: Char) {
480 append(char)
481 }
482
483 /**
484 * Appends [text] to this [Builder] if non-null, and returns this [Builder].
485 *
486 * If [text] is an [AnnotatedString], all spans and annotations will be copied over as well.
487 * No other subtypes of [CharSequence] will be treated specially. For example, any
488 * platform-specific types, such as `SpannedString` on Android, will only have their text
489 * copied and any other information held in the sequence, such as Android `Span`s, will be
490 * dropped.
491 */
492 @Suppress("BuilderSetStyle", "PARAMETER_NAME_CHANGED_ON_OVERRIDE")
493 override fun append(text: CharSequence?): Builder {
494 if (text is AnnotatedString) {
495 append(text)
496 } else {
497 this.text.append(text)
498 }
499 return this
500 }
501
502 /**
503 * Appends the range of [text] between [start] (inclusive) and [end] (exclusive) to this
504 * [Builder] if non-null, and returns this [Builder].
505 *
506 * If [text] is an [AnnotatedString], all spans and annotations from [text] between [start]
507 * and [end] will be copied over as well. No other subtypes of [CharSequence] will be
508 * treated specially. For example, any platform-specific types, such as `SpannedString` on
509 * Android, will only have their text copied and any other information held in the sequence,
510 * such as Android `Span`s, will be dropped.
511 *
512 * @param start The index of the first character in [text] to copy over (inclusive).
513 * @param end The index after the last character in [text] to copy over (exclusive).
514 */
515 @Suppress("BuilderSetStyle", "PARAMETER_NAME_CHANGED_ON_OVERRIDE")
516 override fun append(text: CharSequence?, start: Int, end: Int): Builder {
517 if (text is AnnotatedString) {
518 append(text, start, end)
519 } else {
520 this.text.append(text, start, end)
521 }
522 return this
523 }
524
525 // Kdoc comes from interface method.
526 @Suppress("PARAMETER_NAME_CHANGED_ON_OVERRIDE")
527 override fun append(char: Char): Builder {
528 this.text.append(char)
529 return this
530 }
531
532 /**
533 * Appends the given [AnnotatedString] to this [Builder].
534 *
535 * @param text the text to append
536 */
537 fun append(text: AnnotatedString) {
538 val start = this.text.length
539 this.text.append(text.text)
540 // offset every annotation with start and add to the builder
541 text.annotations?.fastForEach {
542 annotations.add(MutableRange(it.item, start + it.start, start + it.end, it.tag))
543 }
544 }
545
546 /**
547 * Appends the range of [text] between [start] (inclusive) and [end] (exclusive) to this
548 * [Builder]. All spans and annotations from [text] between [start] and [end] will be copied
549 * over as well.
550 *
551 * @param start The index of the first character in [text] to copy over (inclusive).
552 * @param end The index after the last character in [text] to copy over (exclusive).
553 */
554 @Suppress("BuilderSetStyle")
555 fun append(text: AnnotatedString, start: Int, end: Int) {
556 val insertionStart = this.text.length
557 this.text.append(text.text, start, end)
558 // offset every annotation with insertionStart and add to the builder
559 text.getLocalAnnotations(start, end)?.fastForEach {
560 annotations.add(
561 MutableRange(
562 it.item,
563 insertionStart + it.start,
564 insertionStart + it.end,
565 it.tag
566 )
567 )
568 }
569 }
570
571 /**
572 * Set a [SpanStyle] for the given range defined by [start] and [end].
573 *
574 * @param style [SpanStyle] to be applied
575 * @param start the inclusive starting offset of the range
576 * @param end the exclusive end offset of the range
577 */
578 fun addStyle(style: SpanStyle, start: Int, end: Int) {
579 annotations.add(MutableRange(item = style, start = start, end = end))
580 }
581
582 /**
583 * Set a [ParagraphStyle] for the given range defined by [start] and [end]. When a
584 * [ParagraphStyle] is applied to the [AnnotatedString], it will be rendered as a separate
585 * paragraph.
586 *
587 * **Paragraphs arrangement**
588 *
589 * AnnotatedString only supports a few ways that arrangements can be arranged.
590 *
591 * The () and {} below represent different [ParagraphStyle]s passed in that particular order
592 * to the AnnotatedString.
593 * * **Non-overlapping:** paragraphs don't affect each other. Example: (abc){def} or
594 * abc(def)ghi{jkl}.
595 * * **Nested:** one paragraph is completely inside the other. Example: (abc{def}ghi) or
596 * ({abc}def) or (abd{def}). Note that because () is passed before {} to the
597 * AnnotatedString, these are considered nested.
598 * * **Fully overlapping:** two paragraphs cover the exact same range of text. Example:
599 * ({abc}).
600 * * **Overlapping:** one paragraph partially overlaps the other. Note that this is invalid!
601 * Example: (abc{de)f}.
602 *
603 * The order in which you apply `ParagraphStyle` can affect how the paragraphs are arranged.
604 * For example, when you first add () at range 0..4 and then {} at range 0..2, this
605 * paragraphs arrangement is considered nested. But if you first add a () paragraph at range
606 * 0..2 and then {} at range 0..4, this arrangement is considered overlapping and is
607 * invalid.
608 *
609 * **Styling**
610 *
611 * If you don't pass a paragraph style for any part of the text, a paragraph will be created
612 * anyway with a default style. In case of nested paragraphs, the outer paragraph will be
613 * split on the bounds of inner paragraph when the paragraphs are passed to be measured and
614 * rendered. For example, (abc{def}ghi) will be split into (abc)({def})(ghi). The inner
615 * paragraph, similarly to fully overlapping paragraphs, will have a style that is a
616 * combination of two created using a [ParagraphStyle.merge] method.
617 *
618 * @param style [ParagraphStyle] to be applied
619 * @param start the inclusive starting offset of the range
620 * @param end the exclusive end offset of the range
621 */
622 fun addStyle(style: ParagraphStyle, start: Int, end: Int) {
623 annotations.add(MutableRange(item = style, start = start, end = end))
624 }
625
626 /**
627 * Set an Annotation for the given range defined by [start] and [end].
628 *
629 * @param tag the tag used to distinguish annotations
630 * @param annotation the string annotation that is attached
631 * @param start the inclusive starting offset of the range
632 * @param end the exclusive end offset of the range
633 * @sample androidx.compose.ui.text.samples.AnnotatedStringAddStringAnnotationSample
634 * @see getStringAnnotations
635 */
636 fun addStringAnnotation(tag: String, annotation: String, start: Int, end: Int) {
637 annotations.add(
638 MutableRange(
639 item = StringAnnotation(annotation),
640 start = start,
641 end = end,
642 tag = tag
643 )
644 )
645 }
646
647 /**
648 * Set a [TtsAnnotation] for the given range defined by [start] and [end].
649 *
650 * @param ttsAnnotation an object that stores text to speech metadata that intended for the
651 * TTS engine.
652 * @param start the inclusive starting offset of the range
653 * @param end the exclusive end offset of the range
654 * @sample androidx.compose.ui.text.samples.AnnotatedStringAddStringAnnotationSample
655 * @see getStringAnnotations
656 */
657 @Suppress("SetterReturnsThis")
658 fun addTtsAnnotation(ttsAnnotation: TtsAnnotation, start: Int, end: Int) {
659 annotations.add(MutableRange(ttsAnnotation, start, end))
660 }
661
662 /**
663 * Set a [UrlAnnotation] for the given range defined by [start] and [end]. URLs may be
664 * treated specially by screen readers, including being identified while reading text with
665 * an audio icon or being summarized in a links menu.
666 *
667 * @param urlAnnotation A [UrlAnnotation] object that stores the URL being linked to.
668 * @param start the inclusive starting offset of the range
669 * @param end the exclusive end offset of the range
670 * @sample androidx.compose.ui.text.samples.AnnotatedStringAddStringAnnotationSample
671 * @see getStringAnnotations
672 */
673 @ExperimentalTextApi
674 @Suppress("SetterReturnsThis", "Deprecation")
675 @Deprecated(
676 "Use LinkAnnotation API for links instead",
677 ReplaceWith("addLink(, start, end)")
678 )
679 fun addUrlAnnotation(urlAnnotation: UrlAnnotation, start: Int, end: Int) {
680 annotations.add(MutableRange(urlAnnotation, start, end))
681 }
682
683 /**
684 * Set a [LinkAnnotation.Url] for the given range defined by [start] and [end].
685 *
686 * When clicking on the text in range, the corresponding URL from the [url] annotation will
687 * be opened using [androidx.compose.ui.platform.UriHandler].
688 *
689 * URLs may be treated specially by screen readers, including being identified while reading
690 * text with an audio icon or being summarized in a links menu.
691 *
692 * @param url A [LinkAnnotation.Url] object that stores the URL being linked to.
693 * @param start the inclusive starting offset of the range
694 * @param end the exclusive end offset of the range
695 * @see getStringAnnotations
696 */
697 @Suppress("SetterReturnsThis")
698 fun addLink(url: LinkAnnotation.Url, start: Int, end: Int) {
699 annotations.add(MutableRange(url, start, end))
700 }
701
702 /**
703 * Set a [LinkAnnotation.Clickable] for the given range defined by [start] and [end].
704 *
705 * When clicking on the text in range, a [LinkInteractionListener] will be triggered with
706 * the [clickable] object.
707 *
708 * Clickable link may be treated specially by screen readers, including being identified
709 * while reading text with an audio icon or being summarized in a links menu.
710 *
711 * @param clickable A [LinkAnnotation.Clickable] object that stores the tag being linked to.
712 * @param start the inclusive starting offset of the range
713 * @param end the exclusive end offset of the range
714 * @see getStringAnnotations
715 */
716 @Suppress("SetterReturnsThis")
717 fun addLink(clickable: LinkAnnotation.Clickable, start: Int, end: Int) {
718 annotations.add(MutableRange(clickable, start, end))
719 }
720
721 /**
722 * Adds an annotation to draw a bullet. Unlike another overload, this one doesn't add a
723 * separate [ParagraphStyle]. As so for bullet to be rendered, make sure it starts on a
724 * separate line by adding a newline before or wrapping with a [ParagraphStyle].
725 *
726 * For a convenient API to create a bullet list check [withBulletList].
727 *
728 * @param bullet a bullet to draw before the text
729 * @param start the inclusive starting offset of the range
730 * @param end the exclusive end offset of the range
731 * @see withBulletList
732 */
733 internal fun addBullet(bullet: Bullet, start: Int, end: Int) {
734 annotations.add(MutableRange(item = bullet, start = start, end = end))
735 }
736
737 /**
738 * Adds an annotation to draw a [bullet] together with a paragraph that adds an
739 * [indentation].
740 *
741 * @param bullet a bullet to draw before the text
742 * @param indentation indentation that is added to the paragraph. Note that this indentation
743 * should be large enough to fit a bullet and a padding between the bullet and beginning
744 * of the paragraph
745 * @param start the inclusive starting offset of the range
746 * @param end the exclusive end offset of the range
747 * @see withBulletList
748 */
749 internal fun addBullet(bullet: Bullet, indentation: TextUnit, start: Int, end: Int) {
750 val bulletParStyle = ParagraphStyle(textIndent = TextIndent(indentation, indentation))
751 annotations.add(MutableRange(item = bulletParStyle, start = start, end = end))
752 annotations.add(MutableRange(item = bullet, start = start, end = end))
753 }
754
755 /**
756 * Applies the given [SpanStyle] to any appended text until a corresponding [pop] is called.
757 *
758 * @sample androidx.compose.ui.text.samples.AnnotatedStringBuilderPushSample
759 * @param style SpanStyle to be applied
760 */
761 fun pushStyle(style: SpanStyle): Int {
762 MutableRange(item = style, start = text.length).also {
763 styleStack.add(it)
764 annotations.add(it)
765 }
766 return styleStack.size - 1
767 }
768
769 /**
770 * Applies the given [ParagraphStyle] to any appended text until a corresponding [pop] is
771 * called.
772 *
773 * @sample androidx.compose.ui.text.samples.AnnotatedStringBuilderPushParagraphStyleSample
774 * @param style ParagraphStyle to be applied
775 */
776 fun pushStyle(style: ParagraphStyle): Int {
777 MutableRange(item = style, start = text.length).also {
778 styleStack.add(it)
779 annotations.add(it)
780 }
781 return styleStack.size - 1
782 }
783
784 /**
785 * Applies the given [bullet] annotation to any appended text until a corresponding [pop] is
786 * called. For bullet to be rendered, make sure it starts on a separate line by either
787 * adding a newline before or by wrapping with a [ParagraphStyle].
788 *
789 * For a convenient API to create a bullet list check [withBulletList].
790 *
791 * @see withBulletList
792 */
793 internal fun pushBullet(bullet: Bullet): Int {
794 MutableRange(item = bullet, start = text.length).also {
795 styleStack.add(it)
796 annotations.add(it)
797 }
798 return styleStack.size - 1
799 }
800
801 /** Scope for a bullet list */
802 internal class BulletScope internal constructor(internal val builder: Builder) {
803 internal val bulletListSettingStack = mutableListOf<Pair<TextUnit, Bullet>>()
804 }
805
806 private val bulletScope = BulletScope(this)
807
808 /**
809 * Creates a bullet list which allows to define a common [indentation] and a [bullet] for
810 * evey bullet list item created inside the list.
811 *
812 * Note that when nesting the [withBulletList] calls, the indentation inside the nested list
813 * will be a combination of all indentations in the nested chain. For example,
814 *
815 * withBulletList(10.sp) { withBulletList(15.sp) { // items indentation 25.sp } }
816 */
817 internal fun <R : Any> withBulletList(
818 indentation: TextUnit = DefaultBulletIndentation,
819 bullet: Bullet = DefaultBullet,
820 block: BulletScope.() -> R
821 ): R {
822 val adjustedIndentation =
823 bulletScope.bulletListSettingStack.lastOrNull()?.first?.let {
824 checkPrecondition(it.type == indentation.type) {
825 "Indentation unit types of nested bullet lists must match. Current $it and previous is $indentation"
826 }
827 when (indentation.type) {
828 Sp -> (indentation.value + it.value).sp
829 Em -> (indentation.value + it.value).em
830 else -> indentation
831 }
832 } ?: indentation
833
834 val parIndex =
835 pushStyle(
836 ParagraphStyle(
837 textIndent = TextIndent(adjustedIndentation, adjustedIndentation)
838 )
839 )
840 bulletScope.bulletListSettingStack.add(Pair(adjustedIndentation, bullet))
841 return try {
842 block(bulletScope)
843 } finally {
844 if (bulletScope.bulletListSettingStack.isNotEmpty()) {
845 bulletScope.bulletListSettingStack.removeAt(
846 bulletScope.bulletListSettingStack.lastIndex
847 )
848 }
849 pop(parIndex)
850 }
851 }
852
853 /**
854 * Attach the given [annotation] to any appended text until a corresponding [pop] is called.
855 *
856 * @sample androidx.compose.ui.text.samples.AnnotatedStringBuilderPushStringAnnotationSample
857 * @param tag the tag used to distinguish annotations
858 * @param annotation the string annotation attached on this AnnotatedString
859 * @see getStringAnnotations
860 * @see Range
861 */
862 fun pushStringAnnotation(tag: String, annotation: String): Int {
863 MutableRange(item = StringAnnotation(annotation), start = text.length, tag = tag).also {
864 styleStack.add(it)
865 annotations.add(it)
866 }
867 return styleStack.size - 1
868 }
869
870 /**
871 * Attach the given [ttsAnnotation] to any appended text until a corresponding [pop] is
872 * called.
873 *
874 * @sample androidx.compose.ui.text.samples.AnnotatedStringBuilderPushStringAnnotationSample
875 * @param ttsAnnotation an object that stores text to speech metadata that intended for the
876 * TTS engine.
877 * @see getStringAnnotations
878 * @see Range
879 */
880 fun pushTtsAnnotation(ttsAnnotation: TtsAnnotation): Int {
881 MutableRange(item = ttsAnnotation, start = text.length).also {
882 styleStack.add(it)
883 annotations.add(it)
884 }
885 return styleStack.size - 1
886 }
887
888 /**
889 * Attach the given [UrlAnnotation] to any appended text until a corresponding [pop] is
890 * called.
891 *
892 * @sample androidx.compose.ui.text.samples.AnnotatedStringBuilderPushStringAnnotationSample
893 * @param urlAnnotation A [UrlAnnotation] object that stores the URL being linked to.
894 * @see getStringAnnotations
895 * @see Range
896 */
897 @ExperimentalTextApi
898 @Suppress("BuilderSetStyle", "Deprecation")
899 @Deprecated(
900 "Use LinkAnnotation API for links instead",
901 ReplaceWith("pushLink(, start, end)")
902 )
903 fun pushUrlAnnotation(urlAnnotation: UrlAnnotation): Int {
904 MutableRange(item = urlAnnotation, start = text.length).also {
905 styleStack.add(it)
906 annotations.add(it)
907 }
908 return styleStack.size - 1
909 }
910
911 /**
912 * Attach the given [LinkAnnotation] to any appended text until a corresponding [pop] is
913 * called.
914 *
915 * @param link A [LinkAnnotation] object that stores the URL or clickable tag being linked
916 * to.
917 * @see getStringAnnotations
918 * @see Range
919 */
920 @Suppress("BuilderSetStyle")
921 fun pushLink(link: LinkAnnotation): Int {
922 MutableRange(item = link, start = text.length).also {
923 styleStack.add(it)
924 annotations.add(it)
925 }
926 return styleStack.size - 1
927 }
928
929 /**
930 * Ends the style or annotation that was added via a push operation before.
931 *
932 * @see pushStyle
933 * @see pushStringAnnotation
934 */
935 fun pop() {
936 checkPrecondition(styleStack.isNotEmpty()) { "Nothing to pop." }
937 // pop the last element
938 val item = styleStack.removeAt(styleStack.size - 1)
939 item.end = text.length
940 }
941
942 /**
943 * Ends the styles or annotation up to and `including` the [pushStyle] or
944 * [pushStringAnnotation] that returned the given index.
945 *
946 * @param index the result of the a previous [pushStyle] or [pushStringAnnotation] in order
947 * to pop to
948 * @see pop
949 * @see pushStyle
950 * @see pushStringAnnotation
951 */
952 fun pop(index: Int) {
953 checkPrecondition(index < styleStack.size) {
954 "$index should be less than ${styleStack.size}"
955 }
956 while ((styleStack.size - 1) >= index) {
957 pop()
958 }
959 }
960
961 /** Constructs an [AnnotatedString] based on the configurations applied to the [Builder]. */
962 fun toAnnotatedString(): AnnotatedString {
963 return AnnotatedString(
964 text = text.toString(),
965 annotations = annotations.fastMap { it.toRange(text.length) }
966 )
967 }
968
969 /** @see AnnotatedString.mapAnnotations */
970 internal fun mapAnnotations(transform: (Range<out Annotation>) -> Range<out Annotation>) {
971 for (i in annotations.indices) {
972 val newAnnotation = transform(annotations[i].toRange())
973 annotations[i] = MutableRange.fromRange(newAnnotation)
974 }
975 }
976
977 /** @see AnnotatedString.flatMapAnnotations */
978 internal fun flatMapAnnotations(
979 transform: (Range<out Annotation>) -> List<Range<out Annotation>>
980 ) {
981 val replacedAnnotations =
982 annotations.fastFlatMap { annotation ->
983 transform(annotation.toRange()).fastMap { MutableRange.fromRange(it) }
984 }
985 annotations.clear()
986 annotations.addAll(replacedAnnotations)
987 }
988 }
989
990 /**
991 * Defines annotations that specify additional information to apply to ranges of text within the
992 * given AnnotatedString.
993 *
994 * The AnnotatedString supports annotations that provide different kind of information, such as
995 * * [SpanStyle] specifies character level styling such as color, font, letter spacing etc.
996 * * [ParagraphStyle] for configuring styling on a paragraph level such as line heights, text
997 * aligning, text direction etc.
998 * * [LinkAnnotation] to mark links in the text.
999 * * [TtsAnnotation] provides information to assistive technologies such as screen readers.
1000 * * Custom annotations using the [StringAnnotation].
1001 */
1002 sealed interface Annotation
1003
1004 // Unused private subclass of the marker interface to avoid exhaustive "when" statement
1005 @Suppress("unused") private class ExhaustiveAnnotation : Annotation
1006
1007 companion object {
1008 /**
1009 * The default [Saver] implementation for [AnnotatedString].
1010 *
1011 * Note this Saver doesn't preserve the [LinkInteractionListener] of the links. You should
1012 * handle this case manually if required (check
1013 * https://issuetracker.google.com/issues/332901550 for an example).
1014 */
1015 val Saver: Saver<AnnotatedString, *> = AnnotatedStringSaver
1016 }
1017 }
1018
constructAnnotationsFromSpansAndParagraphsnull1019 private fun constructAnnotationsFromSpansAndParagraphs(
1020 spanStyles: List<Range<SpanStyle>>,
1021 paragraphStyles: List<Range<ParagraphStyle>>
1022 ): List<Range<out Annotation>>? {
1023 return if (spanStyles.isEmpty() && paragraphStyles.isEmpty()) {
1024 null
1025 } else if (paragraphStyles.isEmpty()) {
1026 spanStyles
1027 } else if (spanStyles.isEmpty()) {
1028 paragraphStyles
1029 } else {
1030 ArrayList<Range<out Annotation>>(spanStyles.size + paragraphStyles.size).also { array ->
1031 spanStyles.fastForEach { array.add(it) }
1032 paragraphStyles.fastForEach { array.add(it) }
1033 }
1034 }
1035 }
1036
1037 /**
1038 * A helper function used to determine the paragraph boundaries in [MultiParagraph].
1039 *
1040 * It reads paragraph information from [AnnotatedString.paragraphStyles] where only some parts of
1041 * text has [ParagraphStyle] specified, and unspecified parts(gaps between specified paragraphs) are
1042 * considered as default paragraph with default [ParagraphStyle]. For example, the following string
1043 * "(Hello World)Hi!" with a specified paragraph denoted by () will result in paragraphs "Hello
1044 * World" and "Hi!".
1045 *
1046 * **Algorithm implementation**
1047 * * Keep a stack of paragraphs that to be *fully* processed yet and a pointer to the end of last
1048 * paragraph already added to the result.
1049 * * Iterate through each paragraph.
1050 * * Check if there's a gap between last added paragraph and start of current paragraph. If yes, we
1051 * need to add text covered by it to the result, making sure to check the existing state of the
1052 * stack to merge the styles correctly.
1053 * * Add a paragraph to the stack. Depending on its range, we might need to merge its style with the
1054 * latest one in the stack.
1055 * * Along the way handle special cases like fully overlapped or zero-length paragraphs.
1056 * * After the last iteration, clear the stack by adding additional paragraphs to the result. Also
1057 * move the pointer to the end of the text.
1058 *
1059 * @param defaultParagraphStyle The default [ParagraphStyle]. It's used for both unspecified default
1060 * paragraphs and specified paragraph. When a specified paragraph's [ParagraphStyle] has a null
1061 * attribute, the default one will be used instead.
1062 */
normalizedParagraphStylesnull1063 internal fun AnnotatedString.normalizedParagraphStyles(
1064 defaultParagraphStyle: ParagraphStyle
1065 ): List<Range<ParagraphStyle>> {
1066 @Suppress("ListIterator")
1067 val sortedParagraphs = paragraphStylesOrNull?.sortedBy { it.start } ?: listOf()
1068 val result = mutableListOf<Range<ParagraphStyle>>()
1069
1070 // a pointer to the last character added to the result list, takes values from 0 to text.length
1071 var lastAdded = 0
1072 val stack = ArrayDeque<Range<ParagraphStyle>>()
1073
1074 sortedParagraphs.fastForEach {
1075 val current = it.copy(defaultParagraphStyle.merge(it.item))
1076 while (lastAdded < current.start && stack.isNotEmpty()) {
1077 val lastInStack = stack.last()
1078 // ..withStyle(A) { <-- last in stack....
1079 // ....append............................
1080 // ....withStyle(B) { <-- current........
1081 // ......append..........................
1082 // ....}.................................
1083 // ..}...................................
1084 if (current.start < lastInStack.end) {
1085 result.add(Range(lastInStack.item, lastAdded, current.start))
1086 lastAdded = current.start
1087 } else {
1088 // ..withStyle(A) {......................
1089 // ....append............................
1090 // ....withStyle(B) { <-- last in stack..
1091 // ......append..........................
1092 // ....}.................................
1093 // ..}...................................
1094 // withStyle(C) <-- current
1095 result.add(Range(lastInStack.item, lastAdded, lastInStack.end))
1096 lastAdded = lastInStack.end
1097 // We now need to remove it from the stack but also make sure that we remove other
1098 // stack
1099 // entrances that have the same ends as the lastAdded
1100 while (stack.isNotEmpty() && lastAdded == stack.last().end) {
1101 stack.removeLast()
1102 }
1103 }
1104 }
1105
1106 if (lastAdded < current.start) {
1107 result.add(Range(defaultParagraphStyle, lastAdded, current.start))
1108 lastAdded = current.start
1109 }
1110
1111 val lastInStack = stack.lastOrNull()
1112 if (lastInStack != null) {
1113 if (lastInStack.start == current.start && lastInStack.end == current.end) {
1114 // fully overlapped, we'll merge current with the previous one and remove the
1115 // previous one from the stack
1116 stack.removeLast()
1117 stack.add(Range(lastInStack.item.merge(current.item), current.start, current.end))
1118 } else if (lastInStack.start == lastInStack.end) {
1119 // this is a zero-length paragraph
1120 result.add(Range(lastInStack.item, lastInStack.start, lastInStack.end))
1121 stack.removeLast()
1122 stack.add(Range(current.item, current.start, current.end))
1123 } else if (lastInStack.end < current.end) {
1124 // This is already handled in the init require checks
1125 throw IllegalArgumentException()
1126 } else {
1127 stack.add(Range(lastInStack.item.merge(current.item), current.start, current.end))
1128 }
1129 } else {
1130 stack.add(Range(current.item, current.start, current.end))
1131 }
1132 }
1133
1134 // The paragraph styles finished so we need to empty the stack to add the remaining to the
1135 // result
1136 while (lastAdded <= text.length && stack.isNotEmpty()) {
1137 // ..withStyle(A) {......................
1138 // ....append............................
1139 // ....withStyle(B) { <-- last in stack..
1140 // ......append..........................
1141 // ....}.................................
1142 // ..}...................................
1143 // ....End of AnnotatedString builder....
1144 val lastInStack = stack.last()
1145 result.add(Range(lastInStack.item, lastAdded, lastInStack.end))
1146 lastAdded = lastInStack.end
1147 // We now need to remove it from the stack but also make sure that we remove other stack
1148 // entrances that have the same ends as the lastAdded
1149 while (stack.isNotEmpty() && lastAdded == stack.last().end) {
1150 stack.removeLast()
1151 }
1152 }
1153
1154 // There might be a text left at the end that isn't covered with a paragraph so using a default
1155 if (lastAdded < text.length) {
1156 result.add(Range(defaultParagraphStyle, lastAdded, text.length))
1157 }
1158
1159 // This is a corner case where annotatedString is an empty string without any ParagraphStyle.
1160 // In this case, an empty ParagraphStyle is created.
1161 if (result.isEmpty()) {
1162 result.add(Range(defaultParagraphStyle, 0, 0))
1163 }
1164 return result
1165 }
1166
1167 /**
1168 * Helper function used to find the [ParagraphStyle]s in the given range and also convert the range
1169 * of those styles to the local range.
1170 *
1171 * @param start The start index of the range, inclusive
1172 * @param end The end index of the range, exclusive
1173 */
AnnotatedStringnull1174 private fun AnnotatedString.getLocalParagraphStyles(
1175 start: Int,
1176 end: Int
1177 ): List<Range<ParagraphStyle>>? {
1178 if (start == end) return null
1179 val paragraphStyles = paragraphStylesOrNull ?: return null
1180 // If the given range covers the whole AnnotatedString, return SpanStyles without conversion.
1181 if (start == 0 && end >= this.text.length) {
1182 return paragraphStyles
1183 }
1184 return paragraphStyles.fastFilteredMap({ intersect(start, end, it.start, it.end) }) {
1185 Range(
1186 it.item,
1187 it.start.fastCoerceIn(start, end) - start,
1188 it.end.fastCoerceIn(start, end) - start
1189 )
1190 }
1191 }
1192
1193 /**
1194 * Helper function used to find the annotations in the given range that match the [predicate], and
1195 * also convert the range of those annotations to the local range. Null [predicate] means is similar
1196 * to passing true.
1197 */
AnnotatedStringnull1198 private fun AnnotatedString.getLocalAnnotations(
1199 start: Int,
1200 end: Int,
1201 predicate: ((Annotation) -> Boolean)? = null
1202 ): List<Range<out AnnotatedString.Annotation>>? {
1203 if (start == end) return null
1204 val annotations = annotations ?: return null
1205 // If the given range covers the whole AnnotatedString, return it without conversion.
1206 if (start == 0 && end >= this.text.length) {
1207 return if (predicate == null) {
1208 annotations
1209 } else {
1210 annotations.fastFilter { predicate(it.item) }
1211 }
1212 }
1213 return annotations.fastFilteredMap({
1214 (predicate?.invoke(it.item) ?: true) && intersect(start, end, it.start, it.end)
1215 }) {
1216 Range(
1217 tag = it.tag,
1218 item = it.item,
1219 start = it.start.coerceIn(start, end) - start,
1220 end = it.end.coerceIn(start, end) - start
1221 )
1222 }
1223 }
1224
1225 /**
1226 * Helper function used to return another AnnotatedString that is a substring from [start] to [end].
1227 * This will ignore the [ParagraphStyle]s and the resulting [AnnotatedString] will have no
1228 * [ParagraphStyle]s.
1229 *
1230 * @param start The start index of the paragraph range, inclusive
1231 * @param end The end index of the paragraph range, exclusive
1232 * @return The list of converted [SpanStyle]s in the given paragraph range
1233 */
AnnotatedStringnull1234 private fun AnnotatedString.substringWithoutParagraphStyles(start: Int, end: Int): AnnotatedString {
1235 return AnnotatedString(
1236 text = if (start != end) text.substring(start, end) else "",
1237 annotations = getLocalAnnotations(start, end) { it !is ParagraphStyle } ?: listOf()
1238 )
1239 }
1240
mapEachParagraphStylenull1241 internal inline fun <T> AnnotatedString.mapEachParagraphStyle(
1242 defaultParagraphStyle: ParagraphStyle,
1243 crossinline block:
1244 (annotatedString: AnnotatedString, paragraphStyle: Range<ParagraphStyle>) -> T
1245 ): List<T> {
1246 return normalizedParagraphStyles(defaultParagraphStyle).fastMap { paragraphStyleRange ->
1247 val annotatedString =
1248 substringWithoutParagraphStyles(paragraphStyleRange.start, paragraphStyleRange.end)
1249 block(annotatedString, paragraphStyleRange)
1250 }
1251 }
1252
1253 /**
1254 * Create upper case transformed [AnnotatedString]
1255 *
1256 * The uppercase sometimes maps different number of characters. This function adjusts the text style
1257 * and paragraph style ranges to transformed offset.
1258 *
1259 * Note, if the style's offset is middle of the uppercase mapping context, this function won't
1260 * transform the character, e.g. style starts from between base alphabet character and accent
1261 * character.
1262 *
1263 * @param localeList A locale list used for upper case mapping. Only the first locale is effective.
1264 * If empty locale list is passed, use the current locale instead.
1265 * @return A uppercase transformed string.
1266 */
AnnotatedStringnull1267 fun AnnotatedString.toUpperCase(localeList: LocaleList = LocaleList.current): AnnotatedString {
1268 return transform { str, start, end -> str.substring(start, end).toUpperCase(localeList) }
1269 }
1270
1271 /**
1272 * Create lower case transformed [AnnotatedString]
1273 *
1274 * The lowercase sometimes maps different number of characters. This function adjusts the text style
1275 * and paragraph style ranges to transformed offset.
1276 *
1277 * Note, if the style's offset is middle of the lowercase mapping context, this function won't
1278 * transform the character, e.g. style starts from between base alphabet character and accent
1279 * character.
1280 *
1281 * @param localeList A locale list used for lower case mapping. Only the first locale is effective.
1282 * If empty locale list is passed, use the current locale instead.
1283 * @return A lowercase transformed string.
1284 */
AnnotatedStringnull1285 fun AnnotatedString.toLowerCase(localeList: LocaleList = LocaleList.current): AnnotatedString {
1286 return transform { str, start, end -> str.substring(start, end).toLowerCase(localeList) }
1287 }
1288
1289 /**
1290 * Create capitalized [AnnotatedString]
1291 *
1292 * The capitalization sometimes maps different number of characters. This function adjusts the text
1293 * style and paragraph style ranges to transformed offset.
1294 *
1295 * Note, if the style's offset is middle of the capitalization context, this function won't
1296 * transform the character, e.g. style starts from between base alphabet character and accent
1297 * character.
1298 *
1299 * @param localeList A locale list used for capitalize mapping. Only the first locale is effective.
1300 * If empty locale list is passed, use the current locale instead. Note that, this locale is
1301 * currently ignored since underlying Kotlin method is experimental.
1302 * @return A capitalized string.
1303 */
AnnotatedStringnull1304 fun AnnotatedString.capitalize(localeList: LocaleList = LocaleList.current): AnnotatedString {
1305 return transform { str, start, end ->
1306 if (start == 0) {
1307 str.substring(start, end).capitalize(localeList)
1308 } else {
1309 str.substring(start, end)
1310 }
1311 }
1312 }
1313
1314 /**
1315 * Create capitalized [AnnotatedString]
1316 *
1317 * The decapitalization sometimes maps different number of characters. This function adjusts the
1318 * text style and paragraph style ranges to transformed offset.
1319 *
1320 * Note, if the style's offset is middle of the decapitalization context, this function won't
1321 * transform the character, e.g. style starts from between base alphabet character and accent
1322 * character.
1323 *
1324 * @param localeList A locale list used for decapitalize mapping. Only the first locale is
1325 * effective. If empty locale list is passed, use the current locale instead. Note that, this
1326 * locale is currently ignored since underlying Kotlin method is experimental.
1327 * @return A decapitalized string.
1328 */
AnnotatedStringnull1329 fun AnnotatedString.decapitalize(localeList: LocaleList = LocaleList.current): AnnotatedString {
1330 return transform { str, start, end ->
1331 if (start == 0) {
1332 str.substring(start, end).decapitalize(localeList)
1333 } else {
1334 str.substring(start, end)
1335 }
1336 }
1337 }
1338
1339 /**
1340 * The core function of [AnnotatedString] transformation.
1341 *
1342 * @param transform the transformation method
1343 * @return newly allocated transformed AnnotatedString
1344 */
transformnull1345 internal expect fun AnnotatedString.transform(
1346 transform: (String, Int, Int) -> String
1347 ): AnnotatedString
1348
1349 /**
1350 * Pushes [style] to the [AnnotatedString.Builder], executes [block] and then pops the [style].
1351 *
1352 * @sample androidx.compose.ui.text.samples.AnnotatedStringBuilderWithStyleSample
1353 * @param style [SpanStyle] to be applied
1354 * @param block function to be executed
1355 * @return result of the [block]
1356 * @see AnnotatedString.Builder.pushStyle
1357 * @see AnnotatedString.Builder.pop
1358 */
1359 inline fun <R : Any> Builder.withStyle(style: SpanStyle, block: Builder.() -> R): R {
1360 val index = pushStyle(style)
1361 return try {
1362 block(this)
1363 } finally {
1364 pop(index)
1365 }
1366 }
1367
1368 /**
1369 * Pushes [style] to the [AnnotatedString.Builder], executes [block] and then pops the [style].
1370 *
1371 * @sample androidx.compose.ui.text.samples.AnnotatedStringBuilderWithStyleSample
1372 * @param style [SpanStyle] to be applied
1373 * @param block function to be executed
1374 * @return result of the [block]
1375 * @see AnnotatedString.Builder.pushStyle
1376 * @see AnnotatedString.Builder.pop
1377 */
withStylenull1378 inline fun <R : Any> Builder.withStyle(
1379 style: ParagraphStyle,
1380 crossinline block: Builder.() -> R
1381 ): R {
1382 val index = pushStyle(style)
1383 return try {
1384 block(this)
1385 } finally {
1386 pop(index)
1387 }
1388 }
1389
1390 /**
1391 * Pushes an annotation to the [AnnotatedString.Builder], executes [block] and then pops the
1392 * annotation.
1393 *
1394 * @param tag the tag used to distinguish annotations
1395 * @param annotation the string annotation attached on this AnnotatedString
1396 * @param block function to be executed
1397 * @return result of the [block]
1398 * @see AnnotatedString.Builder.pushStringAnnotation
1399 * @see AnnotatedString.Builder.pop
1400 */
withAnnotationnull1401 inline fun <R : Any> Builder.withAnnotation(
1402 tag: String,
1403 annotation: String,
1404 crossinline block: Builder.() -> R
1405 ): R {
1406 val index = pushStringAnnotation(tag, annotation)
1407 return try {
1408 block(this)
1409 } finally {
1410 pop(index)
1411 }
1412 }
1413
1414 /**
1415 * Pushes an [TtsAnnotation] to the [AnnotatedString.Builder], executes [block] and then pops the
1416 * annotation.
1417 *
1418 * @param ttsAnnotation an object that stores text to speech metadata that intended for the TTS
1419 * engine.
1420 * @param block function to be executed
1421 * @return result of the [block]
1422 * @see AnnotatedString.Builder.pushStringAnnotation
1423 * @see AnnotatedString.Builder.pop
1424 */
withAnnotationnull1425 inline fun <R : Any> Builder.withAnnotation(
1426 ttsAnnotation: TtsAnnotation,
1427 crossinline block: Builder.() -> R
1428 ): R {
1429 val index = pushTtsAnnotation(ttsAnnotation)
1430 return try {
1431 block(this)
1432 } finally {
1433 pop(index)
1434 }
1435 }
1436
1437 /**
1438 * Pushes an [UrlAnnotation] to the [AnnotatedString.Builder], executes [block] and then pops the
1439 * annotation.
1440 *
1441 * @param urlAnnotation A [UrlAnnotation] object that stores the URL being linked to.
1442 * @param block function to be executed
1443 * @return result of the [block]
1444 * @see AnnotatedString.Builder.pushStringAnnotation
1445 * @see AnnotatedString.Builder.pop
1446 */
1447 @ExperimentalTextApi
1448 @Deprecated("Use LinkAnnotation API for links instead", ReplaceWith("withLink(, block)"))
1449 @Suppress("Deprecation")
withAnnotationnull1450 inline fun <R : Any> Builder.withAnnotation(
1451 urlAnnotation: UrlAnnotation,
1452 crossinline block: Builder.() -> R
1453 ): R {
1454 val index = pushUrlAnnotation(urlAnnotation)
1455 return try {
1456 block(this)
1457 } finally {
1458 pop(index)
1459 }
1460 }
1461
1462 /**
1463 * Pushes a [LinkAnnotation] to the [AnnotatedString.Builder], executes [block] and then pops the
1464 * annotation.
1465 *
1466 * @param link A [LinkAnnotation] object representing a clickable part of the text
1467 * @param block function to be executed
1468 * @return result of the [block]
1469 * @sample androidx.compose.ui.text.samples.AnnotatedStringWithLinkSample
1470 * @sample androidx.compose.ui.text.samples.AnnotatedStringWithHoveredLinkStylingSample
1471 * @sample androidx.compose.ui.text.samples.AnnotatedStringWithListenerSample
1472 */
withLinknull1473 inline fun <R : Any> Builder.withLink(link: LinkAnnotation, block: Builder.() -> R): R {
1474 val index = pushLink(link)
1475 return try {
1476 block(this)
1477 } finally {
1478 pop(index)
1479 }
1480 }
1481
1482 /**
1483 * Creates a bullet list item around the content produced by the [block]. The list item creates a
1484 * separate paragraph with the indentation to the bullet defined by the preceding
1485 * [Builder.withBulletList] calls.
1486 *
1487 * @param bullet defines the bullet to be drawn
1488 * @param block function to be executed
1489 */
withBulletListItemnull1490 internal fun <R : Any> Builder.BulletScope.withBulletListItem(
1491 bullet: Bullet? = null,
1492 block: Builder.() -> R
1493 ): R {
1494 val lastItemInStack = bulletListSettingStack.lastOrNull()
1495 val itemIndentation = lastItemInStack?.first ?: DefaultBulletIndentation
1496 val itemBullet = bullet ?: (lastItemInStack?.second ?: DefaultBullet)
1497 val parIndex =
1498 builder.pushStyle(ParagraphStyle(textIndent = TextIndent(itemIndentation, itemIndentation)))
1499 val bulletIndex = builder.pushBullet(itemBullet)
1500 return try {
1501 block(builder)
1502 } finally {
1503 builder.pop(bulletIndex)
1504 builder.pop(parIndex)
1505 }
1506 }
1507
1508 /**
1509 * Filter the range list based on [Range.start] and [Range.end] to include ranges only in the range
1510 * of [start] (inclusive) and [end] (exclusive).
1511 *
1512 * @param start the inclusive start offset of the text range
1513 * @param end the exclusive end offset of the text range
1514 */
filterRangesnull1515 private fun <T> filterRanges(ranges: List<Range<out T>>?, start: Int, end: Int): List<Range<T>>? {
1516 requirePrecondition(start <= end) {
1517 "start ($start) should be less than or equal to end ($end)"
1518 }
1519 val nonNullRange = ranges ?: return null
1520
1521 return nonNullRange
1522 .fastFilteredMap({ intersect(start, end, it.start, it.end) }) {
1523 Range(
1524 item = it.item,
1525 start = maxOf(start, it.start) - start,
1526 end = minOf(end, it.end) - start,
1527 tag = it.tag
1528 )
1529 }
1530 .ifEmpty { null }
1531 }
1532
1533 /**
1534 * Create an AnnotatedString with a [spanStyle] that will apply to the whole text.
1535 *
1536 * @param spanStyle [SpanStyle] to be applied to whole text
1537 * @param paragraphStyle [ParagraphStyle] to be applied to whole text
1538 */
AnnotatedStringnull1539 fun AnnotatedString(
1540 text: String,
1541 spanStyle: SpanStyle,
1542 paragraphStyle: ParagraphStyle? = null
1543 ): AnnotatedString =
1544 AnnotatedString(
1545 text,
1546 listOf(Range(spanStyle, 0, text.length)),
1547 if (paragraphStyle == null) listOf() else listOf(Range(paragraphStyle, 0, text.length))
1548 )
1549
1550 /**
1551 * Create an AnnotatedString with a [paragraphStyle] that will apply to the whole text.
1552 *
1553 * @param paragraphStyle [ParagraphStyle] to be applied to whole text
1554 */
1555 fun AnnotatedString(text: String, paragraphStyle: ParagraphStyle): AnnotatedString =
1556 AnnotatedString(text, listOf(), listOf(Range(paragraphStyle, 0, text.length)))
1557
1558 /**
1559 * Build a new AnnotatedString by populating newly created [AnnotatedString.Builder] provided by
1560 * [builder].
1561 *
1562 * @sample androidx.compose.ui.text.samples.AnnotatedStringBuilderLambdaSample
1563 * @param builder lambda to modify [AnnotatedString.Builder]
1564 */
1565 inline fun buildAnnotatedString(builder: (Builder).() -> Unit): AnnotatedString =
1566 Builder().apply(builder).toAnnotatedString()
1567
1568 /**
1569 * Helper function that checks if the range [baseStart, baseEnd) contains the range [targetStart,
1570 * targetEnd).
1571 *
1572 * @return true if
1573 * [baseStart, baseEnd) contains [targetStart, targetEnd), vice versa. When [baseStart]==[baseEnd]
1574 * it return true iff [targetStart]==[targetEnd]==[baseStart].
1575 */
1576 internal fun contains(baseStart: Int, baseEnd: Int, targetStart: Int, targetEnd: Int) =
1577 (baseStart <= targetStart && targetEnd <= baseEnd) &&
1578 (baseEnd != targetEnd || (targetStart == targetEnd) == (baseStart == baseEnd))
1579
1580 /**
1581 * Helper function that checks if the range [lStart, lEnd) intersects with the range [rStart, rEnd).
1582 *
1583 * @return [lStart, lEnd) intersects with range [rStart, rEnd), vice versa.
1584 */
1585 internal fun intersect(lStart: Int, lEnd: Int, rStart: Int, rEnd: Int): Boolean {
1586 // We can check if two ranges intersect just by performing the following operation:
1587 //
1588 // lStart < rEnd && rStart < lEnd
1589 //
1590 // This operation handles all cases, including when one of the ranges is fully included in the
1591 // other ranges. This is however not enough in this particular case because our ranges are open
1592 // at the end, but closed at the start.
1593 //
1594 // This means the test above would fail cases like: [1, 4) intersect [1, 1)
1595 // To address this we check if either one of the ranges is a "point" (empty selection). If
1596 // that's the case and both ranges share the same start point, then they intersect.
1597 //
1598 // In addition, we use bitwise operators (or, and) instead of boolean operators (||, &&) to
1599 // generate branchless code.
1600 return ((lStart == lEnd) or (rStart == rEnd) and (lStart == rStart)) or
1601 ((lStart < rEnd) and (rStart < lEnd))
1602 }
1603
1604 private val EmptyAnnotatedString: AnnotatedString = AnnotatedString("")
1605
1606 /** Returns an AnnotatedString with empty text and no annotations. */
emptyAnnotatedStringnull1607 internal fun emptyAnnotatedString() = EmptyAnnotatedString
1608