package net.kentonc.si.ui import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface 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.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.unit.dp import androidx.compose.ui.unit.sp /** * A deliberately small Markdown renderer: headings, bullet/numbered lists, code * fences, blockquotes, and inline bold/italic/code/links are enough for agent * replies without adding a whole Markdown dependency to the bundle. */ @Composable fun MarkdownText(markdown: String, modifier: Modifier = Modifier) { val scheme = MaterialTheme.colorScheme SelectionContainer { Column(modifier) { for (block in remember(markdown) { parseBlocks(markdown) }) { when (block) { is Block.Paragraph -> Text( inline(block.text, scheme), style = MaterialTheme.typography.bodyMedium, ) is Block.Heading -> Text( inline(block.text.trimStart('#', ' '), scheme), style = MaterialTheme.typography.titleLarge.copy( fontSize = (18 - 2 * (block.level - 1)).coerceAtLeast(14).sp, fontWeight = FontWeight.SemiBold, ), ) is Block.Bullets -> Column { for (item in block.items) { Row(Modifier.padding(start = 12.dp, bottom = 2.dp)) { Text( block.marker, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.width(widthMarker(block.marker)), ) Spacer(Modifier.width(4.dp)) Text(inline(item, scheme), style = MaterialTheme.typography.bodyMedium) } } } is Block.Code -> Surface( shape = RoundedCornerShape(8.dp), color = scheme.surfaceVariant, modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp), ) { Text( block.lines.joinToString("\n"), fontFamily = FontFamily.Monospace, fontSize = 13.sp, modifier = Modifier .horizontalScroll(rememberScrollState()) .padding(10.dp), ) } is Block.Quote -> Text( inline(block.text, scheme), style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic), color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 12.dp, top = 2.dp, bottom = 2.dp), ) } } } } } private fun widthMarker(marker: String) = (marker.length * 10).dp // ─── block parsing ───────────────────────────────────────────────────────────── sealed interface Block { data class Paragraph(val text: String) : Block data class Heading(val level: Int, val text: String) : Block data class Bullets(val items: List, val marker: String) : Block data class Code(val lines: List) : Block data class Quote(val text: String) : Block } /** Numbered list markers: "1." "2)" … */ private val ORDERED = Regex("^\\d+[.)] ") private fun isHeading(line: String) = line.startsWith("#") && line.dropWhile { it == '#' }.startsWith(" ") private fun isFence(line: String) = line.trimStart().startsWith("```") private fun parseBlocks(markdown: String): List { val blocks = ArrayList() val lines = markdown.split('\n') var i = 0 while (i < lines.size) { val line = lines[i].trimEnd() when { isFence(line) -> { i++ val code = ArrayList() while (i < lines.size && !isFence(lines[i])) { code.add(lines[i]); i++ } blocks.add(Block.Code(code)) i++ // skip the closing fence (or run off the end) } line.isBlank() -> i++ isHeading(line) -> { blocks.add(Block.Heading(line.takeWhile { it == '#' }.length, line)) i++ } line.startsWith("- ") || line.startsWith("* ") -> { val items = ArrayList() while (i < lines.size) { val l = lines[i].trimEnd() if (l.startsWith("- ") || l.startsWith("* ")) { items.add(l.substring(2)); i++ } else break } blocks.add(Block.Bullets(items, "•")) } ORDERED.matches(line) -> { val marker = ORDERED.find(line)!!.value val items = ArrayList() while (i < lines.size && ORDERED.matches(lines[i].trimEnd())) { items.add(lines[i].trimEnd().dropWhile { it != ' ' }.trim()); i++ } blocks.add(Block.Bullets(items, marker)) } line.startsWith(">") -> { blocks.add(Block.Quote(line.dropWhile { it == '>' }.trimStart())) i++ } else -> { // Merge consecutive plain lines into one paragraph. val paragraph = ArrayList() while (i < lines.size) { val l = lines[i].trimEnd() if (l.isBlank() || isFence(l) || isHeading(l) || l.startsWith(">") || l.startsWith("- ") || l.startsWith("* ") || ORDERED.matches(l) ) break paragraph.add(l); i++ } blocks.add(Block.Paragraph(paragraph.joinToString("\n"))) } } } return blocks } // ─── inline formatting ───────────────────────────────────────────────────────── /** Renders **bold**, *italic*, `code`, and `[text](url)` into an AnnotatedString. */ private fun inline(text: String, scheme: ColorScheme): AnnotatedString = buildAnnotatedString { val plain = StringBuilder() fun flushPlain() { append(plain.toString()); plain.clear() } var i = 0 while (i < text.length) { when { // **bold** text.startsWith("**", i) -> { val end = text.indexOf("**", i + 2) if (end > i + 1) { flushPlain() pushStyle(SpanStyle(fontWeight = FontWeight.Bold)) append(text.substring(i + 2, end)) pop() i = end + 2 } else { plain.append("**"); i += 2 } } // `code` text[i] == '`' -> { val end = text.indexOf('`', i + 1) if (end > i) { flushPlain() pushStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = scheme.surfaceVariant)) append(text.substring(i + 1, end)) pop() i = end + 1 } else { plain.append(text[i]); i++ } } // *italic* (not a bare '*' followed by space) text[i] == '*' && i + 1 < text.length && text[i + 1] != ' ' -> { val end = text.indexOf('*', i + 1) if (end > i + 1) { flushPlain() pushStyle(SpanStyle(fontStyle = FontStyle.Italic)) append(text.substring(i + 1, end)) pop() i = end + 1 } else { plain.append(text[i]); i++ } } // [label](url) text[i] == '[' -> { val closeBracket = text.indexOf("](", i) val closeParen = if (closeBracket >= 0) text.indexOf(')', closeBracket) else -1 if (closeBracket > i && closeParen > closeBracket) { flushPlain() pushStyle(SpanStyle(color = Link, textDecoration = TextDecoration.Underline)) append(text.substring(i + 1, closeBracket)) pop() i = closeParen + 1 } else { plain.append(text[i]); i++ } } else -> { plain.append(text[i]); i++ } } } flushPlain() } private val Link = Color(0xFF8AB4F8)