chinwag-android/app/src/main/java/com/keylesspalace/tusky/util/SpanUtils.kt
Konrad Pozniak 8aaca3bb2c
improve span highlighting (#4480)
At first I thought simply changing the regex might help, but then I
found more and more differences between Mastodon and Tusky, so I decided
to reimplement the thing. I added 74 testcases that I all compared to
Mastodon to make sure they are correct.

On an Fairphone 4 the new implementation is faster, on an Samsung Galaxy
Tab S3 slower.

Testcases for the benchmark:
```
test of a status with #one hashtag http
```
```
test
http:// #hashtag https://connyduck.at/
http://example.org
this is a #test
and this is a @mention@test.com @test @test@test456@test.com
```
```
@mention@test.social Just your ordinary mention with a hashtag
#test
```
```
@mention@test.social Just your ordinary mention with a url
https://riot.im/app/#/room/#Tusky:matrix.org
```



FP4:
```
       11.159   ns          15 allocs    Benchmark.new_1
      119.701   ns          43 allocs    Benchmark.new_2
       21.895   ns          24 allocs    Benchmark.new_3
       87.512   ns          32 allocs    Benchmark.new_4

       16.592   ns          46 allocs    Benchmark.old_1
      134.381   ns         169 allocs    Benchmark.old_2
       28.355   ns          68 allocs    Benchmark.old_3
       45.221   ns          77 allocs    Benchmark.old_4
```

SGT3:
```
       43,785   ns          18 allocs    Benchmark.new_1
      446,074   ns          43 allocs    Benchmark.new_2
       78,802   ns          26 allocs    Benchmark.new_3
      315,478   ns          32 allocs    Benchmark.new_4

       42,186   ns          45 allocs    Benchmark.old_1
      353,570   ns         157 allocs    Benchmark.old_2
       72,376   ns          66 allocs    Benchmark.old_3
      122,985   ns          74 allocs    Benchmark.old_4
```


benchmark code is here: https://github.com/tuskyapp/tusky-span-benchmark

closes https://github.com/tuskyapp/Tusky/issues/4425
2024-06-02 16:32:58 +02:00

132 lines
4.7 KiB
Kotlin

package com.keylesspalace.tusky.util
import android.content.Context
import android.text.Spannable
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.CharacterStyle
import android.text.style.DynamicDrawableSpan
import android.text.style.ForegroundColorSpan
import android.text.style.ImageSpan
import android.text.style.URLSpan
import com.keylesspalace.tusky.util.twittertext.Regex
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
import java.util.regex.Pattern
/**
* @see <a href="https://github.com/tootsuite/mastodon/blob/master/app/models/tag.rb">
* Tag#HASHTAG_RE</a>.
*/
private const val HASHTAG_SEPARATORS = "_\\u00B7\\u30FB\\u200c"
internal const val TAG_PATTERN_STRING = "(?<![=/)\\p{Alnum}])(#(([\\w_][\\w$HASHTAG_SEPARATORS]*[\\p{Alpha}$HASHTAG_SEPARATORS][\\w$HASHTAG_SEPARATORS]*[\\w_])|([\\w_]*[\\p{Alpha}][\\w_]*)))"
private val TAG_PATTERN = TAG_PATTERN_STRING.toPattern(Pattern.CASE_INSENSITIVE)
/**
* @see <a href="https://github.com/tootsuite/mastodon/blob/master/app/models/account.rb">
* Account#MENTION_RE</a>
*/
private const val USERNAME_PATTERN_STRING = "[a-z0-9_]+([a-z0-9_.-]+[a-z0-9_]+)?"
internal const val MENTION_PATTERN_STRING = "(?<![=/\\w])(@($USERNAME_PATTERN_STRING)(?:@[\\w.-]+[\\w]+)?)"
private val MENTION_PATTERN = MENTION_PATTERN_STRING.toPattern(Pattern.CASE_INSENSITIVE)
private val VALID_URL_PATTERN = Regex.VALID_URL_PATTERN_STRING.toPattern(Pattern.CASE_INSENSITIVE)
private val spanClasses = listOf(ForegroundColorSpan::class.java, URLSpan::class.java)
// url must come first, it may contain the other patterns
val defaultFinders = listOf(
PatternFinder("http", FoundMatchType.HTTP_URL, VALID_URL_PATTERN),
PatternFinder("#", FoundMatchType.TAG, TAG_PATTERN),
PatternFinder("@", FoundMatchType.MENTION, MENTION_PATTERN)
)
enum class FoundMatchType {
HTTP_URL,
HTTPS_URL,
TAG,
MENTION
}
class PatternFinder(
val searchString: String,
val type: FoundMatchType,
val pattern: Pattern
)
/**
* Takes text containing mentions and hashtags and urls and makes them the given colour.
* @param finders The finders to use. This is here so they can be overridden from unit tests.
*/
fun Spannable.highlightSpans(colour: Int, finders: List<PatternFinder> = defaultFinders) {
// Strip all existing colour spans.
for (spanClass in spanClasses) {
clearSpans(spanClass)
}
for (finder in finders) {
// before running the regular expression, check if there is even a chance of it finding something
if (this.contains(finder.searchString)) {
val matcher = finder.pattern.matcher(this)
while (matcher.find()) {
// we found a match
val start = matcher.start(1)
val end = matcher.end(1)
// only add a span if there is no other one yet (e.g. the #anchor part of an url might match as hashtag, but must be ignored)
if (this.getSpans(start, end, URLSpan::class.java).isEmpty()) {
this.setSpan(
getSpan(finder.type, this, colour, start, end),
start,
end,
Spanned.SPAN_INCLUSIVE_EXCLUSIVE
)
}
}
}
}
}
private fun <T> Spannable.clearSpans(spanClass: Class<T>) {
for (span in getSpans(0, length, spanClass)) {
removeSpan(span)
}
}
/**
* Replaces text of the form [iconics name] with their spanned counterparts (ImageSpan).
*/
fun addDrawables(text: CharSequence, color: Int, size: Int, context: Context): Spannable {
val builder = SpannableStringBuilder(text)
val pattern = Pattern.compile("\\[iconics ([0-9a-z_]+)]")
val matcher = pattern.matcher(builder)
while (matcher.find()) {
val resourceName = matcher.group(1)
?: continue
val drawable = IconicsDrawable(context, GoogleMaterial.getIcon(resourceName))
drawable.setBounds(0, 0, size, size)
drawable.setTint(color)
builder.setSpan(ImageSpan(drawable, DynamicDrawableSpan.ALIGN_BASELINE), matcher.start(), matcher.end(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
return builder
}
private fun getSpan(
matchType: FoundMatchType,
string: CharSequence,
colour: Int,
start: Int,
end: Int
): CharacterStyle {
return when (matchType) {
FoundMatchType.HTTP_URL, FoundMatchType.HTTPS_URL -> NoUnderlineURLSpan(string.substring(start, end))
FoundMatchType.MENTION -> MentionSpan(string.substring(start, end))
else -> ForegroundColorSpan(colour)
}
}