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
No related branches found
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