package com.sptmobile.render import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle 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.TextDecoration import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.sp /** Theme-resolved colors for [renderMarkdown] / [highlightXml] — passed in so * the pure render stays Compose-free and JVM-unit-testable. */ data class MarkdownStyles( val codeBg: Color, val codeFg: Color, val link: Color, val quote: Color, val xmlTag: Color, val xmlAttr: Color, val xmlValue: Color, val xmlComment: Color, ) // [impl->REQ-RICH-RENDER] /** * A small, dependency-free CommonMark subset → [AnnotatedString]: ATX headings * (`#`…`######`), `**bold**`/`__bold__`, `*italic*`/`_italic_`, inline * `` `code` ``, fenced ``` code blocks (```lang```; `xml`/`html` get * [highlightXml]), `-`/`*`/`+` and `1.` lists, `>` blockquotes, and * `[text](url)` links. Not a full parser — unmatched markers render literally, * so arbitrary agent text is always safe to pass. Pure (no Compose runtime) so * the span mapping is unit-testable; [MarkdownText] wires the theme colors. */ fun renderMarkdown(text: String, styles: MarkdownStyles): AnnotatedString = buildAnnotatedString { val lines = text.split("\n") var inFence = false var fenceLang = "" val codeBuf = StringBuilder() var first = true fun newlineBetween() { if (!first) append("\n") first = false } for (line in lines) { val trimmed = line.trimStart() if (inFence) { if (trimmed.startsWith("```")) { // Close the fence: flush the buffered code block. newlineBetween() val code = codeBuf.toString() if (fenceLang == "xml" || fenceLang == "html") { appendXml(this, code, styles) } else { withStyle(SpanStyle(fontFamily = FontFamily.Monospace, color = styles.codeFg)) { append(code) } } inFence = false codeBuf.clear() } else { if (codeBuf.isNotEmpty()) codeBuf.append("\n") codeBuf.append(line) } continue } if (trimmed.startsWith("```")) { inFence = true fenceLang = trimmed.removePrefix("```").trim().lowercase() continue } newlineBetween() appendLine(this, line, styles) } // An unterminated fence still renders what it buffered. if (inFence && codeBuf.isNotEmpty()) { newlineBetween() withStyle(SpanStyle(fontFamily = FontFamily.Monospace, color = styles.codeFg)) { append(codeBuf.toString()) } } } /** One non-fence line: heading / blockquote / list prefix, then inline spans. */ private fun appendLine(b: AnnotatedString.Builder, line: String, styles: MarkdownStyles) { val trimmed = line.trimStart() // ATX heading. val hashes = trimmed.takeWhile { it == '#' }.length if (hashes in 1..6 && trimmed.getOrNull(hashes) == ' ') { val size: TextUnit = when (hashes) { 1 -> 22.sp; 2 -> 20.sp; 3 -> 18.sp; else -> 16.sp } b.withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontSize = size)) { appendInline(this, trimmed.substring(hashes + 1), styles) } return } // Blockquote. if (trimmed.startsWith("> ")) { b.withStyle(SpanStyle(color = styles.quote, fontStyle = FontStyle.Italic)) { append("┃ ") appendInline(this, trimmed.substring(2), styles) } return } // Unordered list. if (trimmed.length >= 2 && trimmed[0] in "-*+" && trimmed[1] == ' ') { b.append(" • ") appendInline(b, trimmed.substring(2), styles) return } // Ordered list (N. ). val dot = trimmed.indexOf(". ") if (dot in 1..3 && trimmed.substring(0, dot).all { it.isDigit() }) { b.append(" ${trimmed.substring(0, dot)}. ") appendInline(b, trimmed.substring(dot + 2), styles) return } appendInline(b, line, styles) } /** Inline emphasis / code / links. Unmatched markers append literally. */ private fun appendInline(b: AnnotatedString.Builder, s: String, styles: MarkdownStyles) { var i = 0 while (i < s.length) { val c = s[i] when { c == '`' -> { val end = s.indexOf('`', i + 1) if (end > i) { b.withStyle( SpanStyle( fontFamily = FontFamily.Monospace, background = styles.codeBg, color = styles.codeFg, ), ) { append(s.substring(i + 1, end)) } i = end + 1 } else { b.append(c); i++ } } (c == '*' || c == '_') && i + 1 < s.length && s[i + 1] == c -> { val marker = "$c$c" val end = s.indexOf(marker, i + 2) if (end > i + 1) { b.withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { appendInline(this, s.substring(i + 2, end), styles) } i = end + 2 } else { b.append(c); i++ } } c == '*' || c == '_' -> { val end = s.indexOf(c, i + 1) if (end > i) { b.withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { appendInline(this, s.substring(i + 1, end), styles) } i = end + 1 } else { b.append(c); i++ } } c == '[' -> { val close = s.indexOf(']', i + 1) if (close > i && s.getOrNull(close + 1) == '(') { val paren = s.indexOf(')', close + 2) if (paren > close) { b.withStyle( SpanStyle(color = styles.link, textDecoration = TextDecoration.Underline), ) { append(s.substring(i + 1, close)) } i = paren + 1 continue } } b.append(c); i++ } else -> { b.append(c); i++ } } } } // [impl->REQ-RICH-RENDER] /** Theme-derived [MarkdownStyles] from the current color scheme (light/dark). */ @Composable fun rememberMarkdownStyles(): MarkdownStyles { val cs = MaterialTheme.colorScheme return remember(cs) { MarkdownStyles( codeBg = cs.surfaceVariant, codeFg = cs.onSurfaceVariant, link = cs.primary, quote = cs.onSurfaceVariant, xmlTag = cs.primary, xmlAttr = cs.tertiary, xmlValue = cs.secondary, xmlComment = cs.onSurfaceVariant.copy(alpha = 0.7f), ) } } // [impl->REQ-RICH-RENDER] /** Drop-in for `Text` that renders [text] as markdown (with XML-highlighted * code blocks). Selection-friendly: the produced [AnnotatedString] is plain * text to a [androidx.compose.foundation.text.selection.SelectionContainer]. */ @Composable fun MarkdownText( text: String, modifier: Modifier = Modifier, style: TextStyle = LocalTextStyle.current, ) { val styles = rememberMarkdownStyles() val rendered = remember(text, styles) { renderMarkdown(text, styles) } Text(text = rendered, modifier = modifier, style = style) }