Skip to content
Snippets Groups Projects
Verified Commit 2fbd0951 authored by Janne Mareike Koschinski's avatar Janne Mareike Koschinski
Browse files

feat: implement annotated string formatting

parent 35b56129
Branches
No related tags found
No related merge requests found
package de.justjanne.quasseldroid.util
import android.text.SpannableStringBuilder
import androidx.compose.ui.text.AnnotatedString
import java.lang.IllegalArgumentException
class AnnotatedStringAppender(
private val builder: AnnotatedString.Builder
) : Appendable {
override fun append(text: CharSequence): Appendable = this.apply {
when (text) {
is String -> builder.append(text)
is AnnotatedString -> builder.append(text)
else -> throw IllegalArgumentException(
"Unsupported type of text for annotated string: ${text.javaClass.canonicalName}"
)
}
}
override fun append(text: CharSequence, start: Int, end: Int): Appendable = this.apply {
when (text) {
is String -> builder.append(text.substring(start, end))
is AnnotatedString -> builder.append(text.subSequence(start, end))
else -> throw IllegalArgumentException(
"Unsupported type of text for annotated string: ${text.javaClass.canonicalName}"
)
}
}
override fun append(text: Char): Appendable = this.apply {
builder.append(text)
}
}
class SpannableStringAppender(
private val builder: SpannableStringBuilder
) : Appendable {
override fun append(text: CharSequence): Appendable = this.apply {
when (text) {
is String -> builder.append(text)
is AnnotatedString -> builder.append(text)
else -> throw IllegalArgumentException(
"Unsupported type of text for annotated string: ${text.javaClass.canonicalName}"
)
}
}
override fun append(text: CharSequence, start: Int, end: Int): Appendable = this.apply {
when (text) {
is String -> builder.append(text.substring(start, end))
is AnnotatedString -> builder.append(text.subSequence(start, end))
else -> throw IllegalArgumentException(
"Unsupported type of text for annotated string: ${text.javaClass.canonicalName}"
)
}
}
override fun append(text: Char): Appendable = this.apply {
builder.append(text)
}
}
package de.justjanne.quasseldroid.util.format
sealed class FormatString {
data class FixedValue(
val content: CharSequence
) : FormatString() {
override fun toString(): String {
return "FixedValue($content)"
}
}
data class FormatSpecifier(
val index: Int?,
val flags: String?,
val width: Int?,
val precision: Int?,
val time: Boolean,
val conversion: Char
) : FormatString() {
override fun toString(): String = listOfNotNull(
index?.let { "index=$index" },
flags?.let { "flags='$flags'" },
width?.let { "width=$width" },
precision?.let { "precision=$precision" },
"time=$time",
"conversion='$conversion'"
).joinToString(", ", prefix = "FormatSpecifier(", postfix = ")")
fun toFormatSpecifier(ignoreFlags: Set<Char> = emptySet()) = buildString {
append("%")
if (index != null) {
append(index)
append("$")
}
if (flags != null) {
append(flags.filterNot(ignoreFlags::contains))
}
if (width != null) {
append(width)
}
if (precision != null) {
append('.')
append(precision)
}
if (time) {
append("t")
}
append(conversion)
}
}
}
/*
* Copyright © 2014 George T. Steel
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package de.justjanne.quasseldroid.util.format
import android.text.Spanned
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import androidx.core.text.buildSpannedString
import de.justjanne.quasseldroid.util.AnnotatedStringAppender
import java.util.*
import java.util.regex.Pattern
/**
* Provides [String.format] style functions that work with [Spanned] strings and preserve formatting.
*
* @author George T. Steel
*/
object TextFormatter {
fun format(
template: AnnotatedString,
vararg args: Any?,
locale: Locale = Locale.getDefault()
): AnnotatedString = buildAnnotatedString {
formatBlocks(AnnotatedStringAppender(this), parseBlocks(template), args, locale)
}
fun format(
template: Spanned,
vararg args: Any?,
locale: Locale = Locale.getDefault()
): Spanned = buildSpannedString {
formatBlocks(this, parseBlocks(template), args, locale)
}
fun format(
template: String,
vararg args: Any?,
locale: Locale = Locale.getDefault()
): String = buildString {
formatBlocks(this, parseBlocks(template), args, locale)
}
internal fun formatBlocks(
target: Appendable,
blocks: Sequence<FormatString>,
args: Array<out Any?>,
locale: Locale
) {
var argIndex = 0
for (block in blocks) {
when (block) {
is FormatString.FixedValue -> target.append(block.content)
is FormatString.FormatSpecifier -> {
val arg = when {
block.index != null -> args[block.index - 1]
block.flags.orEmpty().contains(FLAG_REUSE_ARGUMENT) -> args[argIndex]
else -> args[argIndex++]
}
if (block.conversion.lowercaseChar() == TYPE_STRING) {
if (arg == null) {
target.append(null)
}
fun justify(data: CharSequence): CharSequence = when {
block.width == null -> data
block.flags.orEmpty().contains(FLAG_JUSTIFY_LEFT) -> data.padEnd(block.width)
else -> data.padStart(block.width)
}
fun uppercase(data: String): String = when {
block.flags.orEmpty().contains(FLAG_UPPERCASE) -> data.uppercase(locale)
else -> data
}
when (arg) {
null -> target.append(justify(uppercase("null")))
is String -> target.append(justify(uppercase(arg)))
is CharSequence -> target.append(justify(arg))
else -> target.append(justify(uppercase(arg.toString())))
}
} else {
target.append(
String.format(
locale,
block.toFormatSpecifier(ignoreFlags = setOf(FLAG_REUSE_ARGUMENT)),
arg
)
)
}
}
}
}
}
internal fun parseBlocks(template: CharSequence) = sequence {
var index = 0
while (index < template.length) {
val match = FORMAT_SEQUENCE.toRegex().find(template, index)
if (match == null) {
yield(FormatString.FixedValue(template.subSequence(index, template.length)))
break
}
if (match.range.first != index) {
yield(FormatString.FixedValue(template.subSequence(index, match.range.first)))
}
index = match.range.last + 1
val conversionGroup = match.groups["conversion"]?.value
require(conversionGroup != null) {
"Invalid format string '$match', missing conversion"
}
require(conversionGroup.length == 1) {
"Invalid format string '$match', conversion too long"
}
val conversion = conversionGroup.first()
yield(
FormatString.FormatSpecifier(
index = match.groups["index"]?.value?.toIntOrNull(),
flags = match.groups["flags"]?.value,
width = match.groups["width"]?.value?.toIntOrNull(),
precision = match.groups["precision"]?.value?.toIntOrNull(),
time = match.groups["time"] != null,
conversion = conversion
)
)
}
}
private const val TYPE_STRING = 's'
private const val FLAG_JUSTIFY_LEFT = '-'
private const val FLAG_UPPERCASE = '^'
private const val FLAG_REUSE_ARGUMENT = '<'
private val FORMAT_SEQUENCE =
Pattern.compile("%(?:(?<index>[0-9]+)\\$)?(?<flags>[,\\-(+# 0<]+)?(?<width>[0-9]+)?(?:\\.(?<precision>[0-9]+))?(?<time>[tT])?(?<conversion>[a-zA-Z])")
}
<?xml version="1.0" encoding="utf-8" ?>
<resources>
<string name="message_format_action">— %1$s%2$s %3$s</string>
<string name="message_format_notice">[%1$s%2$s] %3$s</string>
<string name="message_format_nick">%1$s%2$s is now known as %3$s%4$s</string>
<string name="message_format_nick_self">You are now known as %1$s%2$s</string>
<string name="message_format_mode">Mode %1$s by %2$s%3$s</string>
<string name="message_format_join">%1$s%2$s joined %3$s</string>
<string name="message_format_part_1">%1$s%2$s left</string>
<string name="message_format_part_2">%1$s%2$s left (%3$s)</string>
<string name="message_format_quit_1">%1$s%2$s quit</string>
<string name="message_format_quit_2">%1$s%2$s quit (%3$s)</string>
<string name="message_format_kick_1">%1$s was kicked by %2$s%3$s</string>
<string name="message_format_kick_2">%1$s was kicked by %2$s%3$s (%4$s)</string>
<string name="message_format_kill_1">%1$s was killed by %2$s%3$s</string>
<string name="message_format_kill_2">%1$s was killed by %2$s%3$s (%4$s)</string>
</resources>
package de.kuschku.justjanne.quasseldroid.util.irc
import android.text.Spanned
import android.text.style.ForegroundColorSpan
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.core.text.buildSpannedString
import de.justjanne.quasseldroid.util.format.TextFormatter
import org.junit.jupiter.api.Test
import java.util.*
import kotlin.test.assertEquals
class TextFormatterTest {
@Test
fun testBasicFormatting() {
val calendar = GregorianCalendar(1995, Calendar.MAY, 23, 13, 34, 18)
assertEquals(
" d c b a",
TextFormatter.format(
"%4\$2s %3\$2s %2\$2s %1\$2s", "a", "b", "c", "d",
locale = Locale.ENGLISH
)
)
assertEquals(
"e = +2,7183",
TextFormatter.format("e = %+10.4f", Math.E, locale = Locale.FRANCE)
)
assertEquals(
"Amount gained or lost since last statement: $ (6,217.58)",
TextFormatter.format(
"Amount gained or lost since last statement: \$ %(,.2f", -6217.58,
locale = Locale.ENGLISH
)
)
assertEquals(
"Local time: 13:34:18",
TextFormatter.format(
"Local time: %tT",
calendar,
locale = Locale.ENGLISH
)
)
assertEquals(
"Unable to open file 'food': No such file or directory",
TextFormatter.format(
"Unable to open file '%1\$s': %2\$s",
"food",
"No such file or directory",
locale = Locale.ENGLISH
)
)
assertEquals(
"Duke's Birthday: May 23, 1995",
TextFormatter.format(
"Duke's Birthday: %1\$tb %1\$te, %1\$tY",
calendar,
locale = Locale.ENGLISH
)
)
assertEquals(
"Duke's Birthday: May 23, 1995",
TextFormatter.format(
"Duke's Birthday: %1\$tb %<te, %<tY",
calendar,
locale = Locale.ENGLISH,
)
)
}
@Test
fun testAnnotatedStrings() {
assertEquals(
buildAnnotatedString {
append("Hello ")
pushStyle(SpanStyle(color = Color.Red))
append("World")
pop()
append(", ")
pushStyle(SpanStyle(color = Color.Blue))
append("I love you")
pop()
append('!')
},
TextFormatter.format(
buildAnnotatedString {
append("Hello %1\$s, ")
pushStyle(SpanStyle(color = Color.Blue))
append("I love you")
pop()
append('!')
},
buildAnnotatedString {
pushStyle(SpanStyle(color = Color.Red))
append("World")
pop()
}
)
)
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment