|
| 1 | +package dev.bnorm.storyboard.text |
| 2 | + |
| 3 | +import androidx.compose.ui.text.AnnotatedString |
| 4 | +import androidx.compose.ui.text.SpanStyle |
| 5 | +import androidx.compose.ui.text.buildAnnotatedString |
| 6 | + |
| 7 | +data class TextTag(val id: String) { |
| 8 | + init { |
| 9 | + require(TAG_START !in id && TAG_END !in id) { "id cannot contain '$TAG_START' or '$TAG_END': $id" } |
| 10 | + } |
| 11 | + |
| 12 | + override fun toString(): String { |
| 13 | + return "$TAG_START$id$TAG_END" |
| 14 | + } |
| 15 | + |
| 16 | + companion object { |
| 17 | + // TODO could we make these tags customizable? |
| 18 | + // - within a TextTagScope for example? |
| 19 | + private const val TAG_START = '⦕' |
| 20 | + private const val TAG_END = '⦖' |
| 21 | + val REGEX = "$TAG_START(?<id>[^$TAG_START$TAG_END]+)$TAG_END".toRegex() |
| 22 | + internal const val TAG_NAME = "$TAG_START$TAG_END" |
| 23 | + |
| 24 | + fun extractTags(str: AnnotatedString): AnnotatedString { |
| 25 | + return buildAnnotatedString { |
| 26 | + // Merge the original AnnotatedString. |
| 27 | + append(str) |
| 28 | + |
| 29 | + // And extracted tags from the raw text of the original AnnotatedString. |
| 30 | + val ranges = extractTags(str.text).getStringAnnotations(TAG_NAME, 0, length) |
| 31 | + for (range in ranges) { |
| 32 | + addStringAnnotation(TAG_NAME, range.item, range.start, range.end) |
| 33 | + } |
| 34 | + } |
| 35 | + } |
| 36 | + |
| 37 | + fun extractTags(str: String): AnnotatedString { |
| 38 | + val starts = mutableMapOf<TextTag, Int>() |
| 39 | + val annotatedString = AnnotatedString.Builder() |
| 40 | + |
| 41 | + var offset = 0 |
| 42 | + var removed = 0 |
| 43 | + for (match in REGEX.findAll(str)) { |
| 44 | + // Build string without tags. |
| 45 | + annotatedString.append(str.substring(offset, match.range.start)) |
| 46 | + offset = match.range.endInclusive + 1 |
| 47 | + |
| 48 | + // Build a list of tag ranges. |
| 49 | + val tag = TextTag(match.groupValues[1]) |
| 50 | + val last = starts.remove(tag) |
| 51 | + if (last != null) { |
| 52 | + val range = last..<(match.range.start - removed) |
| 53 | + annotatedString.addStringAnnotation( |
| 54 | + tag = TAG_NAME, |
| 55 | + annotation = tag.id, |
| 56 | + start = range.start, |
| 57 | + end = range.endInclusive + 1 |
| 58 | + ) |
| 59 | + } else { |
| 60 | + starts.put(tag, match.range.start - removed) |
| 61 | + } |
| 62 | + removed += match.value.length |
| 63 | + } |
| 64 | + |
| 65 | + // Add remaining text to string. |
| 66 | + if (offset < str.length) { |
| 67 | + annotatedString.append(str.substring(offset, str.length)) |
| 68 | + } |
| 69 | + |
| 70 | + // Add remaining tags to string. |
| 71 | + // TODO should this be an error? |
| 72 | + for ((tag, start) in starts) { |
| 73 | + val range = (start - removed)..(str.length - removed) |
| 74 | + annotatedString.addStringAnnotation( |
| 75 | + tag = TAG_NAME, |
| 76 | + annotation = tag.id, |
| 77 | + start = range.start, |
| 78 | + end = range.endInclusive + 1 |
| 79 | + ) |
| 80 | + } |
| 81 | + |
| 82 | + return annotatedString.toAnnotatedString() |
| 83 | + } |
| 84 | + } |
| 85 | +} |
| 86 | + |
| 87 | +fun AnnotatedString.addStyleByTag( |
| 88 | + tag: TextTag, |
| 89 | + tagged: SpanStyle? = null, |
| 90 | + untagged: SpanStyle? = null, |
| 91 | +): AnnotatedString { |
| 92 | + if (tagged == null && untagged == null) return this |
| 93 | + |
| 94 | + val ranges = getStringAnnotations(TextTag.TAG_NAME, 0, length).filter { it.item == tag.id } |
| 95 | + if (ranges.isEmpty()) { |
| 96 | + return when (untagged) { |
| 97 | + null -> this |
| 98 | + else -> buildAnnotatedString { |
| 99 | + append(this@addStyleByTag) |
| 100 | + addStyle(untagged, 0, length) |
| 101 | + } |
| 102 | + } |
| 103 | + } |
| 104 | + |
| 105 | + val newText = buildAnnotatedString { |
| 106 | + append(this@addStyleByTag) |
| 107 | + |
| 108 | + var last = 0 |
| 109 | + for (range in ranges) { |
| 110 | + if (range.start != last && untagged != null) addStyle(untagged, last, range.start) |
| 111 | + if (tagged != null) addStyle(tagged, range.start, range.end) |
| 112 | + last = range.end |
| 113 | + } |
| 114 | + |
| 115 | + if (last != text.length && untagged != null) { |
| 116 | + addStyle(untagged, last, text.length) |
| 117 | + } |
| 118 | + } |
| 119 | + |
| 120 | + return newText |
| 121 | +} |
| 122 | + |
| 123 | +fun AnnotatedString.replaceAllByTag( |
| 124 | + tag: TextTag, |
| 125 | + replacement: AnnotatedString, |
| 126 | +): AnnotatedString { |
| 127 | + val ranges = getStringAnnotations(TextTag.TAG_NAME, 0, length).filter { it.item == tag.id } |
| 128 | + if (ranges.isEmpty()) return this |
| 129 | + |
| 130 | + val builder = AnnotatedString.Builder() |
| 131 | + var last = 0 |
| 132 | + for (range in ranges.sortedBy { it.start }) { |
| 133 | + if (range.start != last) { |
| 134 | + builder.append(subSequence(last, range.start)) |
| 135 | + } |
| 136 | + // TODO if there is no replacement, could we merging continuous annotations? |
| 137 | + if (replacement.isNotEmpty()) { |
| 138 | + builder.append(replacement) |
| 139 | + } |
| 140 | + last = range.end |
| 141 | + } |
| 142 | + |
| 143 | + if (last != length) { |
| 144 | + builder.append(subSequence(last, length)) |
| 145 | + } |
| 146 | + |
| 147 | + return builder.toAnnotatedString() |
| 148 | +} |
0 commit comments