diff --git a/README.md b/README.md index ae5b65e..ad64121 100644 --- a/README.md +++ b/README.md @@ -24,14 +24,16 @@ for more info on ANSI escape codes. 40–47 -> Set background color (See: [Wikipedia ANSI Page](https://en.wikipedia.org/wiki/ANSI_escape_code#Colors)) 48 -> Set background color (Partial, see: [True-color](#supported-true-color-formats)) 49 -> Reset background color -58 -> Set underline color (Only Android10+ & Partial, see: [True-color](#supported-true-color-formats)) -59 -> Reset underline color (Only Android10+) +58\* -> Set underline color (Partial, see: [True-color](#supported-true-color-formats)) +59\* -> Reset underline color 73 -> Superscript 74 -> Subscript 75 -> Neither superscript nor subscript 90–97 -> Set bright foreground color (See: [Wikipedia ANSI Page](https://en.wikipedia.org/wiki/ANSI_escape_code#Colors)) 100–107 -> Set bright background color (See: [Wikipedia ANSI Page](https://en.wikipedia.org/wiki/ANSI_escape_code#Colors)) +\*: Only Android10+ and unavailable with material compose compatibility layer. + ## Supported True color formats True color is `38`, `48`, or `58` with T + args. @@ -70,9 +72,9 @@ repositories { dependencies { - implementation 'com.github.Fox2Code.AndroidANSI:library:1.1.0' + implementation 'com.github.Fox2Code.AndroidANSI:library:1.2.0' // You can also add the ktx module for the kotlin extension. - implementation 'com.github.Fox2Code.AndroidANSI:library-ktx:1.1.0' + implementation 'com.github.Fox2Code.AndroidANSI:library-ktx:1.2.0' } ``` diff --git a/androidansiapp/src/main/java/com/fox2code/androidansiapp/MainActivity.java b/androidansiapp/src/main/java/com/fox2code/androidansiapp/MainActivity.java index 2c75c32..ffe6c85 100644 --- a/androidansiapp/src/main/java/com/fox2code/androidansiapp/MainActivity.java +++ b/androidansiapp/src/main/java/com/fox2code/androidansiapp/MainActivity.java @@ -6,7 +6,6 @@ import android.widget.TextView; import com.fox2code.androidansi.AnsiParser; -import com.fox2code.androidansi.AnsiTextView; public class MainActivity extends AppCompatActivity { diff --git a/gradle.properties b/gradle.properties index 0339a9b..02ef851 100644 --- a/gradle.properties +++ b/gradle.properties @@ -24,4 +24,4 @@ android.disableAutomaticComponentCreation=true # Android targt SDK andorid.targetSdk=33 # Library version -library.version=1.1.0 +library.version=1.2.0 diff --git a/library-ktx/build.gradle b/library-ktx/build.gradle index a572450..90460dd 100644 --- a/library-ktx/build.gradle +++ b/library-ktx/build.gradle @@ -46,6 +46,7 @@ android { dependencies { api(project(":library")) compileOnly('androidx.appcompat:appcompat:1.6.1') + compileOnly('androidx.compose.ui:ui-text:1.4.3') } afterEvaluate { diff --git a/library-ktx/src/main/java/com/fox2code/androidansi/builder/AnnotatedStringAnsiComponentBuilder.kt b/library-ktx/src/main/java/com/fox2code/androidansi/builder/AnnotatedStringAnsiComponentBuilder.kt new file mode 100644 index 0000000..4fa40f1 --- /dev/null +++ b/library-ktx/src/main/java/com/fox2code/androidansi/builder/AnnotatedStringAnsiComponentBuilder.kt @@ -0,0 +1,24 @@ +package com.fox2code.androidansi.builder + +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.AnnotatedString.Builder +import com.fox2code.androidansi.AnsiContext +import com.fox2code.androidansi.ktx.toAnsiSpanStyle + +class AnnotatedStringAnsiComponentBuilder : AnsiComponentBuilder() { + private val builder: Builder = Builder() + private var used = false + override fun notifyUse() { + check(!used) { "AnnotatedStringAnsiComponentBuilder can only be used once!" } + used = true + } + + override fun appendWithSpan(buffer: String, bufferStart: Int, bufferEnd: Int, ansiContext: AnsiContext, visibleStart: Int, visibleEnd: Int) { + builder.append(buffer, bufferStart, bufferEnd) + builder.addStyle(ansiContext.toAnsiSpanStyle(), visibleStart, visibleEnd) + } + + override fun build(): AnnotatedString { + return builder.toAnnotatedString() + } +} \ No newline at end of file diff --git a/library-ktx/src/main/java/com/fox2code/androidansi/ktx/AnsiComposeEntensions.kt b/library-ktx/src/main/java/com/fox2code/androidansi/ktx/AnsiComposeEntensions.kt new file mode 100644 index 0000000..899574a --- /dev/null +++ b/library-ktx/src/main/java/com/fox2code/androidansi/ktx/AnsiComposeEntensions.kt @@ -0,0 +1,95 @@ +@file:Suppress("NOTHING_TO_INLINE") // Aliases to public API. + +package com.fox2code.androidansi.ktx + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.BaselineShift +import androidx.compose.ui.text.style.TextDecoration +import com.fox2code.androidansi.AnsiConstants +import com.fox2code.androidansi.AnsiContext +import com.fox2code.androidansi.AnsiParser +import com.fox2code.androidansi.builder.AnnotatedStringAnsiComponentBuilder +import org.jetbrains.annotations.Contract + +@Contract(pure = true) +fun AnsiContext.toAnsiSpanStyle(): SpanStyle { + var baselineShift: BaselineShift = BaselineShift.None + if (this.style and AnsiConstants.FLAG_STYLE_SUBSCRIPT != 0) { + baselineShift = BaselineShift.Subscript + } else if (this.style and AnsiConstants.FLAG_STYLE_SUPERSCRIPT != 0) { + baselineShift = BaselineShift.Superscript + } + val backgroundColor: Color = when(val background: ULong = + this.background.toULong() or 0xFF000000.toULong()) { + this.defaultBackground.toULong() -> Color.Unspecified + 0xFF000000.toULong() -> Color.Black + 0xFF444444.toULong() -> Color.DarkGray + 0xFF888888.toULong() -> Color.Gray + 0xFFCCCCCC.toULong() -> Color.LightGray + 0xFFFFFFFF.toULong() -> Color.White + 0xFFFF0000.toULong() -> Color.Red + 0xFF00FF00.toULong() -> Color.Green + 0xFF0000FF.toULong() -> Color.Blue + 0xFFFFFF00.toULong() -> Color.Yellow + 0xFFFFFF00.toULong() -> Color.Cyan + 0xFFFFFF00.toULong() -> Color.Magenta + else -> Color(background) + } + var foregroundColor: Color = when(val foreground: ULong = + this.foreground.toULong() or 0xFF000000.toULong()) { + this.defaultForeground.toULong() -> Color.Unspecified + 0xFF000000.toULong() -> Color.Black + 0xFF444444.toULong() -> Color.DarkGray + 0xFF888888.toULong() -> Color.Gray + 0xFFCCCCCC.toULong() -> Color.LightGray + 0xFFFFFFFF.toULong() -> Color.White + 0xFFFF0000.toULong() -> Color.Red + 0xFF00FF00.toULong() -> Color.Green + 0xFF0000FF.toULong() -> Color.Blue + 0xFFFFFF00.toULong() -> Color.Yellow + 0xFFFFFF00.toULong() -> Color.Cyan + 0xFFFFFF00.toULong() -> Color.Magenta + else -> Color(foreground) + } + val textDecoration: TextDecoration = when(this.style and ( + AnsiConstants.FLAG_STYLE_UNDERLINE or AnsiConstants.FLAG_STYLE_STRIKE + )) { + AnsiConstants.FLAG_STYLE_UNDERLINE or + AnsiConstants.FLAG_STYLE_STRIKE -> + TextDecoration.Underline.plus(TextDecoration.LineThrough) + AnsiConstants.FLAG_STYLE_UNDERLINE -> TextDecoration.Underline + AnsiConstants.FLAG_STYLE_STRIKE -> TextDecoration.LineThrough + else -> TextDecoration.None + } + if (this.style and AnsiConstants.FLAG_STYLE_DIM != 0) { + foregroundColor = foregroundColor.copy(alpha = (9F/16F)) + } + val fontStyle: FontStyle? = when(this.style and AnsiConstants.FLAG_STYLE_ITALIC) { + AnsiConstants.FLAG_STYLE_ITALIC -> FontStyle.Italic + else -> null + } + val fontWeight: FontWeight? = when(this.style and AnsiConstants.FLAG_STYLE_BOLD) { + AnsiConstants.FLAG_STYLE_BOLD -> FontWeight.Bold + else -> null + } + return SpanStyle(baselineShift = baselineShift, + textDecoration = textDecoration, + background = backgroundColor, + color = foregroundColor, + fontStyle = fontStyle, + fontWeight = fontWeight) +} + +@Contract(pure = true) +inline fun AnsiContext.parseAsAnsiAnnotatedString(text: String, parseFlags: Int = 0): AnnotatedString { + return AnsiParser.parseWithBuilder(AnnotatedStringAnsiComponentBuilder(), text, this, parseFlags) +} + +@Contract(pure = true) +inline fun String.parseAsAnsiAnnotatedString(context: AnsiContext? = null, parseFlags: Int = 0): AnnotatedString { + return AnsiParser.parseWithBuilder(AnnotatedStringAnsiComponentBuilder(), this, context, parseFlags) +} \ No newline at end of file diff --git a/library-ktx/src/main/java/com/fox2code/androidansi/ktx/AnsiExtensions.kt b/library-ktx/src/main/java/com/fox2code/androidansi/ktx/AnsiExtensions.kt index f7a56e7..5463a3f 100644 --- a/library-ktx/src/main/java/com/fox2code/androidansi/ktx/AnsiExtensions.kt +++ b/library-ktx/src/main/java/com/fox2code/androidansi/ktx/AnsiExtensions.kt @@ -18,14 +18,17 @@ const val FLAG_ANSI_PARSE_DISABLE_COLORS: Int = AnsiParser.FLAG_PARSE_DISABLE_CO const val FLAG_ANSI_PARSE_DISABLE_ATTRIBUTES: Int = AnsiParser.FLAG_PARSE_DISABLE_ATTRIBUTES const val FLAG_ANSI_PARSE_DISABLE_EXTRAS_COLORS: Int = AnsiParser.FLAG_PARSE_DISABLE_EXTRAS_COLORS const val FLAG_ANSI_PARSE_DISABLE_SUBSCRIPT: Int = AnsiParser.FLAG_PARSE_DISABLE_SUBSCRIPT +const val FLAG_ANSI_PARSE_DISABLE_FONT_ALTERING: Int = AnsiParser.FLAG_PARSE_DISABLE_FONT_ALTERING const val FLAGS_ANSI_PARSE_DISABLE_ALL: Int = AnsiParser.FLAGS_DISABLE_ALL @Contract(pure = true) inline fun String.patchAnsiEscapeSequences(): String = AnsiParser.patchEscapeSequences(this) @Contract(pure = true) inline fun String.removeAnsiEscapeSequences(): String = AnsiParser.removeEscapeSequences(this) +@Contract(pure = true) +inline fun String.removeAllAnsiDecorations(): String = AnsiParser.removeAllDecorations(this) -inline fun String.parseAsAnsi(context: AnsiContext? = null, parseFlags: Int = 0): Spannable { +inline fun String.parseAsAnsiSpannable(context: AnsiContext? = null, parseFlags: Int = 0): Spannable { return if (context == null) { AnsiParser.parseAsSpannable(this, parseFlags) } else { diff --git a/library/src/main/java/com/fox2code/androidansi/AnsiParser.java b/library/src/main/java/com/fox2code/androidansi/AnsiParser.java index 09eb553..fac4db2 100644 --- a/library/src/main/java/com/fox2code/androidansi/AnsiParser.java +++ b/library/src/main/java/com/fox2code/androidansi/AnsiParser.java @@ -2,13 +2,16 @@ import android.graphics.Color; import android.text.Spannable; -import android.text.SpannableString; -import android.text.SpannableStringBuilder; import android.util.Log; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; + +import com.fox2code.androidansi.builder.AnsiComponentBuilder; +import com.fox2code.androidansi.builder.SpannableAnsiComponentBuilder; +import com.fox2code.androidansi.builder.StringAnsiComponentBuilder; + import org.jetbrains.annotations.Contract; import java.util.Arrays; @@ -18,18 +21,22 @@ public final class AnsiParser { // ANSI Has 2 escape sequences, let support both private static final String ESCAPE1 = "\\e["; private static final String ESCAPE2 = "\u001B["; + private static final StringAnsiComponentBuilder RESETTABLE_BUILDER = + new StringAnsiComponentBuilder(true); // Any disabled attributes are unmodified. // Disable colors, implies FLAG_PARSE_DISABLE_EXTRAS_COLORS public static final int FLAG_PARSE_DISABLE_COLORS = 0x0001; // Disable attributes like italic, bold, underline and crossed out text - // implies FLAG_PARSE_DISABLE_SUBSCRIPT + // implies FLAG_PARSE_DISABLE_SUBSCRIPT and FLAG_PARSE_DISABLE_FONT_ALTERING public static final int FLAG_PARSE_DISABLE_ATTRIBUTES = 0x0002; // Disable extra color customization other than foreground or background // Useful to have consistent display across Android versions public static final int FLAG_PARSE_DISABLE_EXTRAS_COLORS = 0x0004; // Disable subscript and superscript text public static final int FLAG_PARSE_DISABLE_SUBSCRIPT = 0x0008; + // Disable font altering component that may change text size + public static final int FLAG_PARSE_DISABLE_FONT_ALTERING = 0x0010; // Disable all attributes changes public static final int FLAGS_DISABLE_ALL = FLAG_PARSE_DISABLE_COLORS | FLAG_PARSE_DISABLE_ATTRIBUTES; @@ -52,6 +59,16 @@ public static String removeEscapeSequences(String string) { return string.replace(ESCAPE1, "").replace(ESCAPE2, ""); } + /** + * Remove all ANSI decoration for a given text + */ + @Contract(pure = true, value = "null -> fail") + public static String removeAllDecorations(String string) { + synchronized (RESETTABLE_BUILDER) { + return parseWithBuilder(RESETTABLE_BUILDER, string, null, FLAGS_DISABLE_ALL); + } + } + @Contract(pure = true) public static Spannable parseAsSpannable(@NonNull String text) { return parseAsSpannable(text, null); @@ -72,11 +89,18 @@ public static Spannable parseAsSpannable( @Contract(pure = true) public static Spannable parseAsSpannable( @NonNull String text, @Nullable AnsiContext ansiContext, int parseFlags) { - if (text.length() == 0) return new SpannableString(text); - ColorTransformer transformer = AnsiConstants.NO_OP_TRANSFORMER; + return parseWithBuilder(new SpannableAnsiComponentBuilder(), text, ansiContext, parseFlags); + } + + public static T parseWithBuilder( + @NonNull AnsiComponentBuilder builder, @NonNull String text, + @Nullable AnsiContext ansiContext, int parseFlags) { + if (text.isEmpty()) { + builder.notifyUse(); + return builder.build(); + } ansiContext = (ansiContext == null ? AnsiContext.DARK : ansiContext).asMutable(); - SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder(); - int index = 0, indexEx = 0; + int index = 0, indexEx = 0, simulatedLength = 0; while (true) { int index1 = text.indexOf(ESCAPE1, indexEx); int index2 = text.indexOf(ESCAPE2, indexEx); @@ -97,10 +121,11 @@ public static Spannable parseAsSpannable( int currentEnd = index2 == -1 || (index1 != -1 && index1 < index2) ? index1 : index2; if (currentEnd != index) { - int nStart = spannableStringBuilder.length(), - nEnd = nStart + (currentEnd - index); - spannableStringBuilder.append(text, index, currentEnd); - spannableStringBuilder.setSpan(ansiContext.toAnsiTextSpan(), nStart, nEnd, 0); + int addedLen = (currentEnd - index); + int nStart = simulatedLength, + nEnd = nStart + addedLen; + simulatedLength += addedLen; + builder.appendWithSpan(text, index, currentEnd, ansiContext, nStart, nEnd); } index = indexEx = i + 1; try { @@ -112,12 +137,11 @@ public static Spannable parseAsSpannable( } int currentEnd = text.length(); if (currentEnd != index) { - int nStart = spannableStringBuilder.length(), - nEnd = nStart + (currentEnd - index); - spannableStringBuilder.append(text, index, currentEnd); - spannableStringBuilder.setSpan(ansiContext.toAnsiTextSpan(), nStart, nEnd, 0); + // int nStart = simulatedLength; // IDEA optimizations + int nEnd = simulatedLength + (currentEnd - index); + builder.appendWithSpan(text, index, currentEnd, ansiContext, simulatedLength, nEnd); } - return spannableStringBuilder; + return builder.build(); } public static void parseTokens(String[] tokens, AnsiContext ansiContext,int parseFlags) { @@ -134,7 +158,9 @@ public static void parseTokens(String[] tokens, AnsiContext ansiContext,int pars break; case 1: ansiContext.style &= ~AnsiConstants.FLAG_STYLE_DIM; - ansiContext.style |= AnsiConstants.FLAG_STYLE_BOLD; + if ((parseFlags & FLAG_PARSE_DISABLE_FONT_ALTERING) == 0) { + ansiContext.style |= AnsiConstants.FLAG_STYLE_BOLD; + } break; case 2: ansiContext.style &= ~AnsiConstants.FLAG_STYLE_BOLD; @@ -150,7 +176,9 @@ public static void parseTokens(String[] tokens, AnsiContext ansiContext,int pars ansiContext.style |= AnsiConstants.FLAG_STYLE_STRIKE; break; case 21: - ansiContext.style &= ~AnsiConstants.FLAG_STYLE_BOLD; + if ((parseFlags & FLAG_PARSE_DISABLE_FONT_ALTERING) == 0) { + ansiContext.style &= ~AnsiConstants.FLAG_STYLE_BOLD; + } break; case 22: ansiContext.style &= ~(AnsiConstants.FLAG_STYLE_BOLD | diff --git a/library/src/main/java/com/fox2code/androidansi/builder/AnsiComponentBuilder.java b/library/src/main/java/com/fox2code/androidansi/builder/AnsiComponentBuilder.java new file mode 100644 index 0000000..b779fac --- /dev/null +++ b/library/src/main/java/com/fox2code/androidansi/builder/AnsiComponentBuilder.java @@ -0,0 +1,30 @@ +package com.fox2code.androidansi.builder; + +import androidx.annotation.NonNull; + +import com.fox2code.androidansi.AnsiContext; + +/** + * This class is called when ANSI parsable text need to be transformed to formatted text. + */ +public abstract class AnsiComponentBuilder { + /** + * Can be overridden if you don't want your builder to be used multiple times. + */ + public void notifyUse() {} + + /** + * @param buffer input raw ansi buffer + * @param bufferStart start index of part of buffer to read + * @param bufferEnd end index of part of buffer to read + * @param ansiContext current ansi context call + * {@link AnsiContext#toAnsiTextSpan()} to set a span. + * @param visibleStart start of simulated visible index if all strings were appended correctly + * @param visibleEnd end of simulated visible index if all strings were appended correctly + */ + public abstract void appendWithSpan(@NonNull String buffer, int bufferStart, int bufferEnd, + @NonNull AnsiContext ansiContext, int visibleStart, int visibleEnd); + + @NonNull + public abstract T build(); +} diff --git a/library/src/main/java/com/fox2code/androidansi/builder/SpannableAnsiComponentBuilder.java b/library/src/main/java/com/fox2code/androidansi/builder/SpannableAnsiComponentBuilder.java new file mode 100644 index 0000000..568004a --- /dev/null +++ b/library/src/main/java/com/fox2code/androidansi/builder/SpannableAnsiComponentBuilder.java @@ -0,0 +1,34 @@ +package com.fox2code.androidansi.builder; + +import android.text.Spannable; +import android.text.SpannableStringBuilder; + +import androidx.annotation.NonNull; + +import com.fox2code.androidansi.AnsiContext; + +public final class SpannableAnsiComponentBuilder extends AnsiComponentBuilder { + private final SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder(); + private boolean used = false; + + @Override + public void notifyUse() { + if (this.used) { + throw new IllegalStateException("SpannableAnsiComponentBuilder can only be used once!"); + } + this.used = true; + } + + @Override + public void appendWithSpan(@NonNull String buffer, int bufferStart, int bufferEnd, + @NonNull AnsiContext ansiContext, int visibleStart, int visibleEnd) { + spannableStringBuilder.append(buffer, bufferStart, bufferEnd); + spannableStringBuilder.setSpan(ansiContext.toAnsiTextSpan(), visibleStart, visibleEnd, 0); + } + + @NonNull + @Override + public Spannable build() { + return spannableStringBuilder; + } +} diff --git a/library/src/main/java/com/fox2code/androidansi/builder/StringAnsiComponentBuilder.java b/library/src/main/java/com/fox2code/androidansi/builder/StringAnsiComponentBuilder.java new file mode 100644 index 0000000..b4ac8ba --- /dev/null +++ b/library/src/main/java/com/fox2code/androidansi/builder/StringAnsiComponentBuilder.java @@ -0,0 +1,38 @@ +package com.fox2code.androidansi.builder; + + +import androidx.annotation.NonNull; + +import com.fox2code.androidansi.AnsiContext; + +public final class StringAnsiComponentBuilder extends AnsiComponentBuilder { + private final StringBuilder stringBuilder = new StringBuilder(); + public final boolean resetOnUse; + + public StringAnsiComponentBuilder() { + this.resetOnUse = false; + } + + public StringAnsiComponentBuilder(boolean resetOnUse) { + this.resetOnUse = resetOnUse; + } + + @Override + public void notifyUse() { + if (this.resetOnUse) { + stringBuilder.setLength(0); + } + } + + @Override + public void appendWithSpan(@NonNull String buffer, int bufferStart, int bufferEnd, + @NonNull AnsiContext ansiContext, int visibleStart, int visibleEnd) { + stringBuilder.append(buffer, bufferStart, bufferEnd); + } + + @NonNull + @Override + public String build() { + return stringBuilder.toString(); + } +}