Skip to content

Commit 5687cb3

Browse files
committed
Introduce the concept of text tags
Create a TextTag class with predetermined prefix and suffix character. Also, create utility functions for extracting tags from a String into an AnnotatedString. And finally, introduce the ability to modify tagged regions within AnnotatedStrings by either adding SpanStyle or replacing the regions with another AnnotatedString. All of these should help with various text transformations starting from a tagged string. This will be particularly useful for code examples.
1 parent a92c2ea commit 5687cb3

1 file changed

Lines changed: 148 additions & 0 deletions

File tree

  • storyboard-text/src/commonMain/kotlin/dev/bnorm/storyboard/text
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
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

Comments
 (0)