Thistle

Kotlin multiplatform String markup library, inspired by SRML.

GitHub release (latest by date) Maven Central Kotlin Version

Overview

Thistle is a library for converting Strings with markup tags into text with inline styles. It supports a variety of different targets, including Android, Compose UI, and Consoles, but with a common markup syntax shared by all targets.

Example Usage

// Android
val thistle = ThistleParser(AndroidDefaults(context.applicationContext))
binding.textView.applyStyledText(
    thistle,
    "Text with {{b}}bold{{/b}} or {{foreground color=#ff0000}}red{{/foreground}} styles"
)


// Compose (both Android and Desktop)
MaterialTheme {
    ProvideThistle {
        StyledText("Text with {{b}}bold{{/b}} or {{foreground color=#ff0000}}red{{/foreground}} styles")
    }
}


// Console (ANSI codes)
val thistle = ThistleParser(ConsoleDefaults())
printlnStyledText(
    thistle,
    "Text with {{b}}bold{{/b}} or {{red}}red{{/red}} styles"
)

Motivation

All UI platforms have some concept of inline text styling (using a single "text view" but having portions of the text with different colors, fonts, font weight, etc.). However, the API for actually using these inline styles is usually very verbose and difficult to understand, and the actual implementation of these styles varies greatly by platform. Some use a tree-like structure which is relatively easy to work with (HTML <span>, or KTX extensions for Android Spannable), while others have you manually computing text offsets and attaching markup tags manually (Compose, iOS). And don't even get me started on Console ANSI codes...

Thistle aims to abstract all that complexity away, so that you don't need to know anything about the platform's underlying text-styling mechanisms. You just provide your Strings and mark them up with the intended styling, and Thistle will take care of the rest for you. The syntax is similar to many other markup languages, such as Twig or Handlebars, but can also be tweaked if needed.

The initial idea came from an old Android library, SRML. It's a small library that has been a lifesaver for my team for years, and Thistle began as a re-implementation of that library in Kotlin using a proper parser and AST instead of Regex. Having this intermediate AST representation allows Thistle to easily adapt the same syntax to the different APIs needed for each target.

Important Note: Thistle is not designed to be a full template engine. The parser is not optimized for handling large inputs, and the syntax is intentionally limited to only work with styling and simple interpolation, but no logic. This is to keep the library focused and avoid bloat, while also maximizing the ability for sharing input Strings and internal code across platforms without major compatibility issues.

Installation

repositories {
    mavenCentral()
}

// for plain JVM or Android projects
dependencies {
    implementation("io.github.copper-leaf:thistle-core:4.0.1")
    implementation("io.github.copper-leaf:thistle-android:4.0.1")
    implementation("io.github.copper-leaf:thistle-compose-ui:4.0.1")
    implementation("io.github.copper-leaf:thistle-console:4.0.1")
}

// for multiplatform projects
kotlin {
    sourceSets {
        val commonMain by getting {
            dependencies {
                implementation("io.github.copper-leaf:thistle-core:4.0.1")
                implementation("io.github.copper-leaf:thistle-android:4.0.1")
                implementation("io.github.copper-leaf:thistle-compose-ui:4.0.1")
                implementation("io.github.copper-leaf:thistle-console:4.0.1")
            }
        }
    }
}

Syntax

The sample apps demo a variety of different tags, use-cases, and customizations, but here's a rundown of the basic syntax (which can be tweaked to your needs).

The following examples all demonstrate usage on Android.

Tags

Tags work similar to inline HTML tags. They can be interspersed throughout the main text, and they can also be nested within each other arbitrarily deep.

The following example shows usage of both the foreground and background tags placed inside normal text:

This text will be {{foreground color=#FF0000}}red{{/foreground}}, while this one has a {{background color=#0000FF}}blue{{/background}} background.

syntax_tags

Tag Parameters

Also like HTML tags, Thistle tags may be parameterized to tweak their rendering behavior. Parameters are assumed to be required (though this may be relaxed in the future), and they are strongly-typed. Values of a different type will not be coerced, and will instead just throw an exception.

Tag parameters are a list of key=value pairs separated by whitespace. Unlike HTML parameters, however, Thistle parameters should not be wrapped in quotes (") unless the value is actually intended to be a String.

The foreground tag requires a color parameter to be set, which must be a hex color literal:

{{foreground color=#FF0000}}red{{/foreground}}

syntax_parameters

Thistle recognizes the following formats for parameter values. You are also able to provide your own value formats for aliasing or loading values from another source.

TypeExampleNotes
Booleantrue, false
Double1.1
Int1
String"This is a string"String in quotes may contain spaces and escaped Unicode sequences
Char'c'
Hex Color#FF0000Parsed to an Int with FF alpha channel
Context Valuecontext.usernameSee "context data" below
Unquoted StringmonospaceUnquoted string must be a single ASCII word with no spaces or Unicode sequences

Context Data

Thistle renders each format string with an optional "context data" map. The values in this map can be accessed as tag parameters, and they can also be interpolated into the output as dynamic text.

For example, given the following context data map:

val contextData = mapOf(
    "themeRed" to Color.RED
)

You can reference themeRed from the foreground tag parameters:

{{foreground color=context.themeRed}}red{{/foreground}}

syntax_context_data

Interpolation

Another useful feature of the context data is to render dynamic text into the output. This may be at the top-level, or within a tag's content. All objects in the context map are converted to text with .toString(), and Thistle does not support any further formatting or transformation on the interpolated values from the format string. Thus, if you need to customize the output of a variable, you will need to convert it manually to a string before adding it to the context data map.

For example, given the following context data map:

val contextData = mapOf(
    "username" to "AliceBob123",
    "userId" to "123456789",
)

username and userId can be rendered into the output dynamically:

Account: {{b}} {username} {{/b}} ({userId})

syntax_interpolation

Targets

Thistle uses a common parser, and a variety of renderers to format text at runtime. It supports both inline-styling, and basic interpolation. Currently, only the Android renderer is built, but more targets are planned.

Thistle also parses inputs to an Abstract Syntax Tree (AST) that can be cached and rendered multiple times for performance. For small inputs or only rendering once, this may not be necessary, but parsing is significantly slower than rendering (on the order of 10s of milliseconds for parsing, vs microseconds for rendering), so caching the AST can be quite useful for text that gets re-rendered many times per second.

Both the parser and the resulting AST are fully immutable and thus are completely thread-safe.

Each target will naturally have different rendering capabilities, which are documented in the sections below.

Android

On Android, Thistle parses a String into a Spanned instance that can be set to a TextView. The Thistle format replaces "tags" with Android Spans, wrapping the appropriate text. The normal Span API can be a bit of a pain, and Thistle makes this simpler, and also allows you to change the span formatting at runtime rather than compile time.

// create the Thistle parser. It's best to create this once and inject it wherever needed
val thistle = ThistleParser(AndroidDefaults(context.applicationContext)) // add the default tags for Android

// parse a formatted string to a Spanned instance, and set that as the text of a TextView
binding.textView.applyStyledText(
    thistle,
    "This is a {{b}}very important{{/b}}, {{foreground color=#ff0000}}urgent{{/foreground}} message!"
)

The Android target also adds an additional value format for accessing application resources, using the same @ syntax used in XML layouts.

TypeExampleNotes
String resource@string/app_name
Color resource@color/colorPrimary
Drawable resource@drawable/ic_launcher

sample_android_app

Default Tags

Tag NameParamsDescriptionExample
foregroundcolor=[hex color]Change text color{{foreground color=#FFFF00}}Text{{/foreground}}
backgroundcolor=[hex color]Change background color{{background color=#FFFF00}}Text{{/background}}
stylestyle=[bold,italic]Set text to bold or italic by argument{{style style=bold}}Text{{/style}}
bnoneSet text style to bold{{b}}Text{{/b}}
inoneSet text style to italic{{i}}Text{{/i}}
unoneAdd underline to text{{u}}Text{{/u}}
strikethroughnoneAdd strikethrough to text{{strikethrough}}Text{{/strikethrough}}
typefacetypeface=[monospace,sans,serif]Change the typeface to monospace, serif, or sans-serif by argument{{typeface typeface=serif}}Text{{/typeface}}
monospacenoneChange the typeface to monospace{{monospace}}Text{{/monospace}}
sansnoneChange the typeface to sans-serif{{sans}}Text{{/sans}}
serifnoneChange the typeface to serif{{serif}}Text{{/serif}}
subscriptnoneMove text to a subscript{{subscript}}Text{{/subscript}}
superscriptnoneMove text to a superscript{{superscript}}Text{{/superscript}}
icondrawable=[@drawable/]Replace text with an inline icon drawable{{icon drawable=@drawable/icon}}Text{{/icon}}
urlurl=[String]Make text a clickable link{{url url="https://www.example.com/"}}Text{{/url}}

Compose UI

Thistle supporting rendering to Compose UI by building an AnnotatedString, which supports both Android and Desktop. The set of available tags for Compose are the same as Android (except it is missing icon and url), allowing one to freely share Thistle strings between the standard and Compose Android renderers, if needed.

// configure the Thistle parser at your UI root, right after the Theme. It can be updated or replaced further down the tree, if needed
MaterialTheme {
    ProvideThistle {

        // Add additional tags to the root Thistle configuration for sub-trees of your UI. Especially useful for
        // configuring Link tags that are only relevant for a small section of UI, without having to define
        // those at the application root.
        //
        // This can be nested multiple times, but must be a child of `ProvideThistle`.
        ProvideAdditionalThistleConfiguration({
            tag("newTag") { TODO() }
        }) {
        }

        // Add data to the Thistle Context variables for the UI sub-tree.
        //
        // This can be nested multiple times, but must be a child of `ProvideThistle`.
        ProvideAdditionalThistleContext(
            mapOf("a" to "b")
        ) {
        }

        // Parse a formatted string to an AnnotatedString instance, and set that as the text of a BasicText
        // composable. The resulting AnnotatedString is cached both from the input String and the Context: changed
        // to the input String will be re-parsed and re-evaluated, while changes to the Context will only be
        // re-evaluated with the new context values.
        //
        // This will typically be used as a child of `ProvideThistle`, but you can manually provide the Thistle
        // instance and Context as function properties if needed, for more manual control.
        StyledText(
            "This is a {{b}}very important{{/b}}, {{foreground color=#ff0000}}urgent{{/foreground}} message!"
        )
    }
}

sample_android_app

Default Tags

Tag NameParamsDescriptionExample
foregroundcolor=[hex color]Change text color{{foreground color=#FFFF00}}Text{{/foreground}}
backgroundcolor=[hex color]Change background color{{background color=#FFFF00}}Text{{/background}}
stylestyle=[bold,italic]Set text to bold or italic by argument{{style style=bold}}Text{{/style}}
bnoneSet text style to bold{{b}}Text{{/b}}
inoneSet text style to italic{{i}}Text{{/i}}
unoneAdd underline to text{{u}}Text{{/u}}
strikethroughnoneAdd strikethrough to text{{strikethrough}}Text{{/strikethrough}}
typefacetypeface=[monospace,sans,serif]Change the typeface to monospace, serif, or sans-serif by argument{{typeface typeface=serif}}Text{{/typeface}}
monospacenoneChange the typeface to monospace{{monospace}}Text{{/monospace}}
sansnoneChange the typeface to sans-serif{{sans}}Text{{/sans}}
serifnoneChange the typeface to serif{{serif}}Text{{/serif}}
subscriptnoneMove text to a subscript{{subscript}}Text{{/subscript}}
superscriptnoneMove text to a superscript{{superscript}}Text{{/superscript}}

Console

For rendering to a console, Thistle converts the normal markup tags into ANSI escape codes. It currently supports 16-bit colors for both foreground and background, and some other basic styling options.

// create the Thistle parser. It's best to create this once and inject it wherever needed
val thistle = ThistleParser(ConsoleDefaults()) // add the default tags for rendering to the console

// parse a formatted string to a ANSI escape codes, and render that to the console with `println()`
printlnStyledText(
    thistle,
    "This is a {{b}}very important{{/b}}, {{red}}urgent{{/red}} message!"
)

sample_console

Default Tags

Color tags support both normal and "bright" or "bold" colors. Typically, a tag name that is all uppercase letters will render the color in "bold", while all lowercase letters will either render in normal style or offer a parameter for manually configuring it.

Unless otherwise specified, in the table below ansi color will refer to one of the following color values: black, red, green, yellow, blue, magenta, cyan, white.

By default, when an ANSI "reset" code is encountered, all styling is reset. Thistle automatically handles re-applying nested tags after a reset, so using nested tags works exactly as you would expect them to.

Tag NameParamsDescriptionExample
foreground/FOREGROUNDcolor=[ansi color], bold=[true,false]Change text color{{foreground color=red bold=true}}Text{{/foreground}}
background/FOREGROUNDcolor=[ansi color], bold=[true,false]Change background color{{background color=red bold=true}}Text{{/background}}
stylestyle=[bold,underline,strikethrough,reverse]Set text to bold, underline, strikethrough, or reverse by argument{{style style=bold}}Text{{/style}}
black/BLACKnoneSet foreground color to black{{black}}Text{{/black}}
red/REDnoneSet foreground color to red{{red}}Text{{/red}}
green/GREENnoneSet foreground color to green{{green}}Text{{/green}}
yellow/YELLOWnoneSet foreground color to yellow{{yellow}}Text{{/yellow}}
blue/BLUEnoneSet foreground color to blue{{blue}}Text{{/blue}}
magenta/MAGENTAnoneSet foreground color to magenta{{magenta}}Text{{/magenta}}
cyan/CYANnoneSet foreground color to cyan{{cyan}}Text{{/cyan}}
white/WHITEnoneSet foreground color to white{{white}}Text{{/white}}
bnoneSet text style to bold{{b}}Text{{/b}}
unoneAdd underline to text{{u}}Text{{/u}}
reversenoneAdd underline to text{{reverse}}Text{{/reverse}}
strikethroughnoneAdd strikethrough to text{{strikethrough}}Text{{/strikethrough}}

iOS

TODO (follow issue here)

JS DOM

TODO (follow issue here)

Customization

All targets use the same Parser implementation, and only differ in the available tags and value formats. The following customizations are available for all platforms.

Custom Tags

Thistle ships with several useful tags out-of-the-box, but for highly stylized text, using all the tags in the format string can get very tedious and muck up the original intent of the text: which is to do simple decoration of your text without much fuss.

Do do this, you'll first need to provide Thistle with a custom ThistleTag implementation that parses the tag name and tag attributes from the format string. That Tag must then return the appropriate Android Span to apply the formatting you need.

Custom tag arguments are assumed to always be required. This may be relaxed in a future version, or you can ignore it by not using checkArgs and pulling values from the args map manually.

// create a custom implementation of ThistleTag
class CustomStyle : ThistleTagFactory<AndroidThistleRenderContext, Any> {
    override fun invoke(renderContext: AndroidThistleRenderContext): Any {
        // use checkArgs to safely pull properties from the input args and ensure incorrect args are not set
        return checkArgs(renderContext) {
            val color: Int by int()

            // return anything that can be set to a `SpannableStringBuilder`
            BackgroundColorSpan(color)
        }
    }
}

val thistle = ThistleParser(AndroidDefaults(context.applicationContext)) {
    // register your custom tab with the Thistle parser
    tag("customStyle") { CustomStyle() }

    // the Link ThistleTag is useful for making portions of text clickable
    tag("inc") { AndroidLink { widget: View -> /* do something on link-click */ } }
    tag("dec") { AndroidLink { widget: View -> /* do something on link-click */ } }
}

binding.textView.applyStyledText(
    thistle,
    "{{inc}}+{{/inc}} {{customStyle}}count:{{/customStyle}} {{dec}}-{{/dec}}"
)

Custom Value Formats

Want to create new formats for literals not handled out-of-the-box, or create custom aliases? No problem! You can provide custom Kudzu parsers for that too. You'll need to use a MappedParser to wrap your syntax and format the text value to the intended literal value.

val thistle = ThistleParser(AndroidDefaults(context.applicationContext)) {
    valueFormat {
        MappedParser(
            LiteralTokenParser("@color/red")
        ) { Color.RED }.asThistleValueParser()
    }
}

binding.textView.applyStyledText(
    thistle,
    "{{foreground color=@color/red}}|{{/foreground}}"
)

Custom Start/End Tokens

Thistle allows you full control over the syntax for each tag, also you're also free to create new tags for your needs. By providing custom Kudzu token parsers for the open or close tags, you can tweak the look to match your preferences.

val thistle = ThistleParser(AndroidDefaults(context.applicationContext)) {
    // customize syntax to use Django/Twig/Pebble-style tags
    customSyntax(
        openTagStartToken = LiteralTokenParser("{%"),
        openTagEndToken = LiteralTokenParser("%}"),
        closeTagStartToken = LiteralTokenParser("{%"),
        closeTagEndToken = LiteralTokenParser("%}"),
        interpolateStartToken = LiteralTokenParser("{{"),
        interpolateEndToken = LiteralTokenParser("}}"),
    )
}

binding.textView.applyStyledText(
    thistle,
    "{% inc %}Click Me!{% inc %}    {% foreground color=#ff0000%}|{% foreground %}    {% dec %}Don't Click Me!{% dec %}"
)