richtext-ui/src/commonMain/kotlin/com/halilibo/richtext/ui/string/RichTextString.kt (263 lines of code) (raw):

@file:Suppress("RemoveEmptyParenthesesFromAnnotationEntry", "SuspiciousCollectionReassignment") package com.halilibo.richtext.ui.string import androidx.compose.foundation.text.appendInlineContent import androidx.compose.runtime.Immutable import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.ParagraphStyle import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily 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 androidx.compose.ui.unit.sp import com.halilibo.richtext.ui.DefaultCodeBlockBackgroundColor import com.halilibo.richtext.ui.string.RichTextString.Builder import com.halilibo.richtext.ui.string.RichTextString.Format import com.halilibo.richtext.ui.string.RichTextString.Format.Bold import com.halilibo.richtext.ui.string.RichTextString.Format.Code import com.halilibo.richtext.ui.string.RichTextString.Format.Companion.FormatAnnotationScope import com.halilibo.richtext.ui.string.RichTextString.Format.Italic import com.halilibo.richtext.ui.string.RichTextString.Format.Link import com.halilibo.richtext.ui.string.RichTextString.Format.Strikethrough import com.halilibo.richtext.ui.string.RichTextString.Format.Subscript import com.halilibo.richtext.ui.string.RichTextString.Format.Superscript import com.halilibo.richtext.ui.string.RichTextString.Format.Underline import com.halilibo.richtext.ui.util.randomUUID import kotlin.LazyThreadSafetyMode.NONE /** Copied from inline content. */ @PublishedApi internal const val REPLACEMENT_CHAR: String = "\uFFFD" /** * Defines the [SpanStyle]s that are used for various [RichTextString] formatting directives. */ @Immutable public data class RichTextStringStyle( val boldStyle: SpanStyle? = null, val italicStyle: SpanStyle? = null, val underlineStyle: SpanStyle? = null, val strikethroughStyle: SpanStyle? = null, val subscriptStyle: SpanStyle? = null, val superscriptStyle: SpanStyle? = null, val codeStyle: SpanStyle? = null, val linkStyle: SpanStyle? = null ) { internal fun merge(otherStyle: RichTextStringStyle?): RichTextStringStyle { if (otherStyle == null) return this return RichTextStringStyle( boldStyle = boldStyle.merge(otherStyle.boldStyle), italicStyle = italicStyle.merge(otherStyle.italicStyle), underlineStyle = underlineStyle.merge(otherStyle.underlineStyle), strikethroughStyle = strikethroughStyle.merge(otherStyle.strikethroughStyle), subscriptStyle = subscriptStyle.merge(otherStyle.subscriptStyle), superscriptStyle = superscriptStyle.merge(otherStyle.superscriptStyle), codeStyle = codeStyle.merge(otherStyle.codeStyle), linkStyle = linkStyle.merge(otherStyle.linkStyle) ) } internal fun resolveDefaults(): RichTextStringStyle = RichTextStringStyle( boldStyle = boldStyle ?: Bold.DefaultStyle, italicStyle = italicStyle ?: Italic.DefaultStyle, underlineStyle = underlineStyle ?: Underline.DefaultStyle, strikethroughStyle = strikethroughStyle ?: Strikethrough.DefaultStyle, subscriptStyle = subscriptStyle ?: Subscript.DefaultStyle, superscriptStyle = superscriptStyle ?: Superscript.DefaultStyle, codeStyle = codeStyle ?: Code.DefaultStyle, linkStyle = linkStyle ?: Link.DefaultStyle ) public companion object { public val Default: RichTextStringStyle = RichTextStringStyle() private fun SpanStyle?.merge(otherStyle: SpanStyle?): SpanStyle? = this?.merge(otherStyle) ?: otherStyle } } /** * Convenience function for creating a [RichTextString] using a [Builder]. */ public inline fun richTextString(builder: Builder.() -> Unit): RichTextString = Builder().apply(builder) .toRichTextString() /** * A special type of [AnnotatedString] that is formatted using higher-level directives that are * configured using a [RichTextStringStyle]. */ @Immutable public data class RichTextString internal constructor( private val taggedString: AnnotatedString, internal val formatObjects: Map<String, Any> ) { private val length: Int get() = taggedString.length val text: String get() = taggedString.text public operator fun plus(other: RichTextString): RichTextString = Builder(length + other.length).run { append(this@RichTextString) append(other) toRichTextString() } internal fun toAnnotatedString( style: RichTextStringStyle, contentColor: Color ): AnnotatedString = buildAnnotatedString { append(taggedString) // Get all of our format annotations. val tags = taggedString.getStringAnnotations(FormatAnnotationScope, 0, taggedString.length) // And apply their actual SpanStyles to the string. tags.forEach { range -> val format = Format.findTag(range.item, formatObjects) ?: return@forEach format.getStyle(style, contentColor) ?.let { spanStyle -> addStyle(spanStyle, range.start, range.end) } } } internal fun getInlineContents(): Map<String, InlineContent> = formatObjects.asSequence() .mapNotNull { (tag, format) -> tag.removePrefix("inline:") // If no prefix was found then we ignore it. .takeUnless { it === tag } ?.let { Pair(it, format as InlineContent) } } .toMap() public open class Format(private val simpleTag: String? = null) { public open fun getStyle( richTextStyle: RichTextStringStyle, contentColor: Color ): SpanStyle? = null public object Italic : Format("italic") { internal val DefaultStyle = SpanStyle(fontStyle = FontStyle.Italic) public override fun getStyle( richTextStyle: RichTextStringStyle, contentColor: Color ): SpanStyle? = richTextStyle.italicStyle } public object Bold : Format(simpleTag = "foo") { internal val DefaultStyle = SpanStyle(fontWeight = FontWeight.Bold) public override fun getStyle( richTextStyle: RichTextStringStyle, contentColor: Color ): SpanStyle? = richTextStyle.boldStyle } public object Underline : Format("underline") { internal val DefaultStyle = SpanStyle(textDecoration = TextDecoration.Underline) public override fun getStyle( richTextStyle: RichTextStringStyle, contentColor: Color ): SpanStyle? = richTextStyle.underlineStyle } public object Strikethrough : Format("strikethrough") { internal val DefaultStyle = SpanStyle(textDecoration = TextDecoration.LineThrough) public override fun getStyle( richTextStyle: RichTextStringStyle, contentColor: Color ): SpanStyle? = richTextStyle.strikethroughStyle } public object Subscript : Format("subscript") { internal val DefaultStyle = SpanStyle( baselineShift = BaselineShift(-0.2f), // TODO this should be relative to current font size fontSize = 10.sp ) public override fun getStyle( richTextStyle: RichTextStringStyle, contentColor: Color ): SpanStyle? = richTextStyle.subscriptStyle } public object Superscript : Format("superscript") { internal val DefaultStyle = SpanStyle( baselineShift = BaselineShift.Superscript, fontSize = 10.sp ) public override fun getStyle( richTextStyle: RichTextStringStyle, contentColor: Color ): SpanStyle? = richTextStyle.superscriptStyle } public object Code : Format("code") { internal val DefaultStyle = SpanStyle( fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Medium, background = DefaultCodeBlockBackgroundColor ) public override fun getStyle( richTextStyle: RichTextStringStyle, contentColor: Color ): SpanStyle? = richTextStyle.codeStyle } public data class Link(val onClick: () -> Unit) : Format() { public override fun getStyle( richTextStyle: RichTextStringStyle, contentColor: Color ): SpanStyle? = richTextStyle.linkStyle internal companion object { val DefaultStyle = SpanStyle( textDecoration = TextDecoration.Underline, color = Color.Blue ) } } internal fun registerTag(tags: MutableMap<String, Any>): String { simpleTag?.let { return it } val uuid = randomUUID() tags[uuid] = this return "format:$uuid" } internal companion object { val FormatAnnotationScope = Format::class.qualifiedName!! // For some reason, if this isn't lazy, Bold will always be null. Is Compose messing up static // initialization order? private val simpleTags by lazy(NONE) { listOf(Bold, Italic, Underline, Strikethrough, Subscript, Superscript, Code) } fun findTag( tag: String, tags: Map<String, Any> ): Format? { val stripped = tag.removePrefix("format:") return if (stripped === tag) { // If the original string was returned, it means the string did not have the prefix. simpleTags.firstOrNull { it.simpleTag == tag } } else { tags[stripped] as? Format } } } } public class Builder(capacity: Int = 16) { private val builder = AnnotatedString.Builder(capacity) private val formatObjects = mutableMapOf<String, Any>() public fun addFormat( format: Format, start: Int, end: Int ) { val tag = format.registerTag(formatObjects) builder.addStringAnnotation(FormatAnnotationScope, tag, start, end) } public fun pushFormat(format: Format): Int { val tag = format.registerTag(formatObjects) return builder.pushStringAnnotation(FormatAnnotationScope, tag) } public fun pop(): Unit = builder.pop() public fun pop(index: Int): Unit = builder.pop(index) public fun append(text: String): Unit = builder.append(text) public fun append(text: RichTextString) { builder.append(text.taggedString) formatObjects.putAll(text.formatObjects) } public fun appendInlineContent( alternateText: String = REPLACEMENT_CHAR, content: InlineContent ) { val tag = randomUUID() formatObjects["inline:$tag"] = content // Resets the style to defaults. // // This is important for inline content because the user might set a global line height // via ProvideTextStyle(TextStyle(lineHeight = 1.3.em)) { ... } // Once set, the line height is fixed for all objects and any inline content (like images) // will expand over the text. Since this is not a text section, it should be fine. // // Fixed line height seems to only affect mobile. if (content.renderOnNewLine) { builder.pushStyle(ParagraphStyle()) } builder.appendInlineContent(tag, alternateText) if (content.renderOnNewLine) { builder.pop() } } /** * Provides access to the underlying builder, which can be used to add arbitrary formatting, * including mixed with formatting from this Builder. */ public fun <T> withAnnotatedString(block: AnnotatedString.Builder.() -> T): T = builder.block() public fun toRichTextString(): RichTextString = RichTextString( builder.toAnnotatedString(), formatObjects.toMap() ) } } public inline fun Builder.withFormat( format: Format, block: Builder.() -> Unit ) { val index = pushFormat(format) block() pop(index) }