From 2d23ceee837a6b1c4573bd068a5d77e985cb8db9 Mon Sep 17 00:00:00 2001 From: Harsh Shandilya Date: Fri, 9 Jun 2023 19:55:32 +0530 Subject: [PATCH] feat(common): add `HTMLText` composable This allows rendering raw HTML text directly without roundtripping it as Markdown --- .../dev/msfjarvis/claw/common/ui/HTMLText.kt | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 common/src/main/kotlin/dev/msfjarvis/claw/common/ui/HTMLText.kt diff --git a/common/src/main/kotlin/dev/msfjarvis/claw/common/ui/HTMLText.kt b/common/src/main/kotlin/dev/msfjarvis/claw/common/ui/HTMLText.kt new file mode 100644 index 00000000..9c03447f --- /dev/null +++ b/common/src/main/kotlin/dev/msfjarvis/claw/common/ui/HTMLText.kt @@ -0,0 +1,171 @@ +/* + * Copyright © 2023 Harsh Shandilya. + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ +package dev.msfjarvis.claw.common.ui + +import android.graphics.Typeface +import android.text.Spanned +import android.text.style.BulletSpan +import android.text.style.ForegroundColorSpan +import android.text.style.RelativeSizeSpan +import android.text.style.StrikethroughSpan +import android.text.style.StyleSpan +import android.text.style.SubscriptSpan +import android.text.style.SuperscriptSpan +import android.text.style.URLSpan +import android.text.style.UnderlineSpan +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +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.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp +import androidx.core.text.HtmlCompat + +private const val URL_TAG = "url_tag" + +@Composable +fun HTMLText( + text: String, + modifier: Modifier = Modifier, + style: TextStyle = MaterialTheme.typography.bodyLarge, + softWrap: Boolean = true, + overflow: TextOverflow = TextOverflow.Clip, + maxLines: Int = Int.MAX_VALUE, + onTextLayout: (TextLayoutResult) -> Unit = {}, + fontSize: TextUnit = 14.sp, + flags: Int = HtmlCompat.FROM_HTML_MODE_COMPACT, + customSpannedHandler: ((Spanned) -> AnnotatedString)? = null +) { + val content = text.asHTML(fontSize, flags, customSpannedHandler) + val uriHandler = LocalUriHandler.current + ClickableText( + modifier = modifier, + text = content, + style = style, + softWrap = softWrap, + overflow = overflow, + maxLines = maxLines, + onTextLayout = onTextLayout, + onClick = { offset -> + content.getStringAnnotations(URL_TAG, offset, offset).firstOrNull()?.let { stringAnnotation -> + uriHandler.openUri(stringAnnotation.item) + } + } + ) +} + +@Composable +private fun String.asHTML( + fontSize: TextUnit, + flags: Int, + customSpannedHandler: ((Spanned) -> AnnotatedString)? = null +) = buildAnnotatedString { + val spanned = HtmlCompat.fromHtml(this@asHTML, flags) + val spans = spanned.getSpans(0, spanned.length, Any::class.java) + + if (customSpannedHandler != null) { + append(customSpannedHandler(spanned)) + } else { + append(spanned.toString()) + } + + spans + .filter { it !is BulletSpan } + .forEach { span -> + val start = spanned.getSpanStart(span) + val end = spanned.getSpanEnd(span) + when (span) { + is RelativeSizeSpan -> span.spanStyle(fontSize) + is StyleSpan -> span.spanStyle() + is UnderlineSpan -> span.spanStyle() + is ForegroundColorSpan -> span.spanStyle() + is StrikethroughSpan -> span.spanStyle() + is SuperscriptSpan -> span.spanStyle() + is SubscriptSpan -> span.spanStyle() + is URLSpan -> { + addStringAnnotation(tag = URL_TAG, annotation = span.url, start = start, end = end) + span.spanStyle() + } + else -> { + null + } + }?.let { spanStyle -> addStyle(spanStyle, start, end) } + } +} + +@Suppress("UnusedReceiverParameter") +@Composable +private fun URLSpan.spanStyle(): SpanStyle = + SpanStyle( + background = MaterialTheme.colorScheme.surfaceVariant, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold, + textDecoration = TextDecoration.Underline, + ) + +@Suppress("UnusedReceiverParameter") +private fun UnderlineSpan.spanStyle(): SpanStyle = + SpanStyle(textDecoration = TextDecoration.Underline) + +private fun ForegroundColorSpan.spanStyle(): SpanStyle = SpanStyle(color = Color(foregroundColor)) + +@Suppress("UnusedReceiverParameter") +private fun StrikethroughSpan.spanStyle(): SpanStyle = + SpanStyle(textDecoration = TextDecoration.LineThrough) + +private fun RelativeSizeSpan.spanStyle(fontSize: TextUnit): SpanStyle = + SpanStyle(fontSize = (fontSize.value * sizeChange).sp) + +private fun StyleSpan.spanStyle(): SpanStyle? = + when (style) { + Typeface.BOLD -> SpanStyle(fontWeight = FontWeight.Bold) + Typeface.ITALIC -> SpanStyle(fontStyle = FontStyle.Italic) + Typeface.BOLD_ITALIC -> + SpanStyle( + fontWeight = FontWeight.Bold, + fontStyle = FontStyle.Italic, + ) + else -> null + } + +@Suppress("UnusedReceiverParameter") +private fun SubscriptSpan.spanStyle(): SpanStyle = + SpanStyle(baselineShift = BaselineShift.Subscript) + +@Suppress("UnusedReceiverParameter") +private fun SuperscriptSpan.spanStyle(): SpanStyle = + SpanStyle(baselineShift = BaselineShift.Superscript) + +@Preview +@Composable +fun HTMLTextPreview() { + HTMLText( + text = + """ +

Heading

+

This is a paragraph body

+
This is a code block
+      

This is an inline code block

+

This is a link

+

Image

+ """ + .trimIndent() + ) +}