From 2fbd0951f9676ea14d3f8a5cfc68594281d6b5ae Mon Sep 17 00:00:00 2001 From: Janne Mareike Koschinski <janne@kuschku.de> Date: Wed, 2 Mar 2022 04:50:38 +0100 Subject: [PATCH] feat: implement annotated string formatting --- .../util/AnnotatedStringAppender.kt | 61 +++++++ .../quasseldroid/util/format/FormatString.kt | 51 ++++++ .../quasseldroid/util/format/TextFormatter.kt | 152 ++++++++++++++++++ app/src/main/res/values/strings_messages.xml | 17 ++ .../util/irc/TextFormatterTest.kt | 102 ++++++++++++ 5 files changed, 383 insertions(+) create mode 100644 app/src/main/kotlin/de/justjanne/quasseldroid/util/AnnotatedStringAppender.kt create mode 100644 app/src/main/kotlin/de/justjanne/quasseldroid/util/format/FormatString.kt create mode 100644 app/src/main/kotlin/de/justjanne/quasseldroid/util/format/TextFormatter.kt create mode 100644 app/src/main/res/values/strings_messages.xml create mode 100644 app/src/test/kotlin/de/kuschku/justjanne/quasseldroid/util/irc/TextFormatterTest.kt diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/util/AnnotatedStringAppender.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/util/AnnotatedStringAppender.kt new file mode 100644 index 000000000..e7036641b --- /dev/null +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/util/AnnotatedStringAppender.kt @@ -0,0 +1,61 @@ +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) + } +} diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/util/format/FormatString.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/util/format/FormatString.kt new file mode 100644 index 000000000..a8c8ea9ff --- /dev/null +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/util/format/FormatString.kt @@ -0,0 +1,51 @@ +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) + } + } +} diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/util/format/TextFormatter.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/util/format/TextFormatter.kt new file mode 100644 index 000000000..0278fc4f9 --- /dev/null +++ b/app/src/main/kotlin/de/justjanne/quasseldroid/util/format/TextFormatter.kt @@ -0,0 +1,152 @@ +/* + * 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])") +} diff --git a/app/src/main/res/values/strings_messages.xml b/app/src/main/res/values/strings_messages.xml new file mode 100644 index 000000000..d6c1be59a --- /dev/null +++ b/app/src/main/res/values/strings_messages.xml @@ -0,0 +1,17 @@ +<?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> diff --git a/app/src/test/kotlin/de/kuschku/justjanne/quasseldroid/util/irc/TextFormatterTest.kt b/app/src/test/kotlin/de/kuschku/justjanne/quasseldroid/util/irc/TextFormatterTest.kt new file mode 100644 index 000000000..3a844e9de --- /dev/null +++ b/app/src/test/kotlin/de/kuschku/justjanne/quasseldroid/util/irc/TextFormatterTest.kt @@ -0,0 +1,102 @@ +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() + } + ) + ) + } +} -- GitLab