feat: Markdown rendering with syntax-highlighted code blocks
Native Compose Markdown renderer replaces raw Text() for assistant messages. Supports headings, bold/italic, inline code, fenced code blocks with keyword highlighting (15+ languages), ordered/unordered lists. Streaming cursor integrates seamlessly into the last block.
This commit is contained in:
parent
423abc03af
commit
4616ef2ba1
2 changed files with 396 additions and 2 deletions
|
|
@ -325,8 +325,10 @@ fun MessageRow(message: Message) {
|
||||||
if (message.thinking) {
|
if (message.thinking) {
|
||||||
TypingDots()
|
TypingDots()
|
||||||
} else {
|
} else {
|
||||||
val display = if (message.streaming) message.content + " ▌" else message.content
|
MarkdownText(
|
||||||
Text(display, color = cs.onBackground, fontSize = 16.sp, lineHeight = 23.sp)
|
text = message.content,
|
||||||
|
streaming = message.streaming,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
392
app/src/main/java/dev/kaizen/app/chat/Markdown.kt
Normal file
392
app/src/main/java/dev/kaizen/app/chat/Markdown.kt
Normal file
|
|
@ -0,0 +1,392 @@
|
||||||
|
package dev.kaizen.app.chat
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.horizontalScroll
|
||||||
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
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.height
|
||||||
|
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.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.draw.clip
|
||||||
|
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.withStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
|
private sealed interface MdBlock {
|
||||||
|
data class Paragraph(val text: String) : MdBlock
|
||||||
|
data class Heading(val level: Int, val text: String) : MdBlock
|
||||||
|
data class CodeFence(val lang: String, val code: String) : MdBlock
|
||||||
|
data class ListBlock(val items: List<ListItem>) : MdBlock
|
||||||
|
data class Item(val text: String, val ordered: Boolean, val index: Int) : MdBlock
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ListItem(val text: String, val ordered: Boolean, val index: Int)
|
||||||
|
|
||||||
|
private fun parseBlocks(src: String): List<MdBlock> {
|
||||||
|
val blocks = mutableListOf<MdBlock>()
|
||||||
|
val lines = src.lines()
|
||||||
|
var i = 0
|
||||||
|
while (i < lines.size) {
|
||||||
|
val line = lines[i]
|
||||||
|
|
||||||
|
// Code fence
|
||||||
|
if (line.trimStart().startsWith("```")) {
|
||||||
|
val lang = line.trimStart().removePrefix("```").trim()
|
||||||
|
val codeLines = mutableListOf<String>()
|
||||||
|
i++
|
||||||
|
while (i < lines.size && !lines[i].trimStart().startsWith("```")) {
|
||||||
|
codeLines.add(lines[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if (i < lines.size) i++ // skip closing ```
|
||||||
|
blocks.add(MdBlock.CodeFence(lang, codeLines.joinToString("\n")))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heading
|
||||||
|
val headingMatch = Regex("^(#{1,3})\\s+(.+)").matchEntire(line)
|
||||||
|
if (headingMatch != null) {
|
||||||
|
val level = headingMatch.groupValues[1].length
|
||||||
|
blocks.add(MdBlock.Heading(level, headingMatch.groupValues[2]))
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unordered list item
|
||||||
|
val ulMatch = Regex("^\\s*[-*+]\\s+(.+)").matchEntire(line)
|
||||||
|
if (ulMatch != null) {
|
||||||
|
val items = mutableListOf<ListItem>()
|
||||||
|
while (i < lines.size) {
|
||||||
|
val m = Regex("^\\s*[-*+]\\s+(.+)").matchEntire(lines[i])
|
||||||
|
if (m != null) {
|
||||||
|
items.add(ListItem(m.groupValues[1], ordered = false, index = items.size + 1))
|
||||||
|
i++
|
||||||
|
} else break
|
||||||
|
}
|
||||||
|
blocks.add(MdBlock.ListBlock(items))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ordered list item
|
||||||
|
val olMatch = Regex("^\\s*(\\d+)[.)]\\s+(.+)").matchEntire(line)
|
||||||
|
if (olMatch != null) {
|
||||||
|
val items = mutableListOf<ListItem>()
|
||||||
|
while (i < lines.size) {
|
||||||
|
val m = Regex("^\\s*(\\d+)[.)]\\s+(.+)").matchEntire(lines[i])
|
||||||
|
if (m != null) {
|
||||||
|
items.add(ListItem(m.groupValues[2], ordered = true, index = m.groupValues[1].toInt()))
|
||||||
|
i++
|
||||||
|
} else break
|
||||||
|
}
|
||||||
|
blocks.add(MdBlock.ListBlock(items))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty line — skip
|
||||||
|
if (line.isBlank()) {
|
||||||
|
i++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paragraph: collect consecutive non-blank, non-special lines
|
||||||
|
val paraLines = mutableListOf(line)
|
||||||
|
i++
|
||||||
|
while (i < lines.size) {
|
||||||
|
val next = lines[i]
|
||||||
|
if (next.isBlank()
|
||||||
|
|| next.trimStart().startsWith("```")
|
||||||
|
|| Regex("^#{1,3}\\s+").containsMatchIn(next)
|
||||||
|
|| Regex("^\\s*[-*+]\\s+").containsMatchIn(next)
|
||||||
|
|| Regex("^\\s*\\d+[.)]\\s+").containsMatchIn(next)
|
||||||
|
) break
|
||||||
|
paraLines.add(next)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
blocks.add(MdBlock.Paragraph(paraLines.joinToString(" ")))
|
||||||
|
}
|
||||||
|
return blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun inlineMarkdown(text: String, baseColor: Color, codeColor: Color, codeBg: Color): AnnotatedString {
|
||||||
|
return buildAnnotatedString {
|
||||||
|
var pos = 0
|
||||||
|
val src = text
|
||||||
|
while (pos < src.length) {
|
||||||
|
// Inline code: `...`
|
||||||
|
if (src[pos] == '`' && pos + 1 < src.length) {
|
||||||
|
val end = src.indexOf('`', pos + 1)
|
||||||
|
if (end > pos) {
|
||||||
|
withStyle(SpanStyle(fontFamily = FontFamily.Monospace, color = codeColor, background = codeBg, fontSize = 14.sp)) {
|
||||||
|
append(src.substring(pos + 1, end))
|
||||||
|
}
|
||||||
|
pos = end + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Bold+italic: ***...*** or ___...___
|
||||||
|
if (pos + 2 < src.length && src.substring(pos, pos + 3).let { it == "***" || it == "___" }) {
|
||||||
|
val marker = src.substring(pos, pos + 3)
|
||||||
|
val end = src.indexOf(marker, pos + 3)
|
||||||
|
if (end > pos) {
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic)) {
|
||||||
|
append(src.substring(pos + 3, end))
|
||||||
|
}
|
||||||
|
pos = end + 3
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Bold: **...** or __...__
|
||||||
|
if (pos + 1 < src.length && src.substring(pos, pos + 2).let { it == "**" || it == "__" }) {
|
||||||
|
val marker = src.substring(pos, pos + 2)
|
||||||
|
val end = src.indexOf(marker, pos + 2)
|
||||||
|
if (end > pos) {
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||||
|
append(src.substring(pos + 2, end))
|
||||||
|
}
|
||||||
|
pos = end + 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Italic: *...* or _..._
|
||||||
|
if ((src[pos] == '*' || src[pos] == '_') && pos + 1 < src.length && src[pos + 1] != ' ') {
|
||||||
|
val marker = src[pos]
|
||||||
|
val end = src.indexOf(marker, pos + 1)
|
||||||
|
if (end > pos && end > pos + 1) {
|
||||||
|
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
|
||||||
|
append(src.substring(pos + 1, end))
|
||||||
|
}
|
||||||
|
pos = end + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
append(src[pos])
|
||||||
|
pos++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Syntax-highlighting keywords per language
|
||||||
|
private val KEYWORDS = mapOf(
|
||||||
|
"kotlin" to setOf("fun", "val", "var", "class", "object", "interface", "if", "else", "when", "for", "while", "return", "import", "package", "private", "public", "internal", "protected", "override", "suspend", "data", "sealed", "enum", "companion", "null", "true", "false", "is", "in", "as", "this", "super", "try", "catch", "finally", "throw", "by", "init", "constructor", "typealias", "abstract", "open", "annotation", "inline", "operator", "infix", "tailrec", "reified", "crossinline", "noinline", "const", "lateinit", "break", "continue", "do"),
|
||||||
|
"java" to setOf("class", "public", "private", "protected", "static", "final", "void", "int", "long", "double", "float", "boolean", "char", "byte", "short", "if", "else", "for", "while", "return", "new", "import", "package", "try", "catch", "throw", "throws", "extends", "implements", "interface", "abstract", "null", "true", "false", "this", "super", "switch", "case", "default", "break", "continue", "do", "enum", "instanceof", "synchronized", "volatile", "transient", "native", "strictfp"),
|
||||||
|
"javascript" to setOf("function", "const", "let", "var", "if", "else", "for", "while", "return", "import", "export", "default", "class", "new", "this", "try", "catch", "throw", "async", "await", "null", "undefined", "true", "false", "switch", "case", "break", "continue", "do", "typeof", "instanceof", "in", "of", "from", "yield", "delete", "void", "with", "debugger", "finally", "extends", "super", "static", "get", "set"),
|
||||||
|
"typescript" to setOf("function", "const", "let", "var", "if", "else", "for", "while", "return", "import", "export", "default", "class", "new", "this", "try", "catch", "throw", "async", "await", "null", "undefined", "true", "false", "switch", "case", "break", "continue", "do", "typeof", "instanceof", "in", "of", "from", "yield", "type", "interface", "enum", "namespace", "module", "declare", "as", "is", "keyof", "readonly", "extends", "implements", "super", "static", "get", "set", "abstract", "private", "protected", "public", "override"),
|
||||||
|
"python" to setOf("def", "class", "if", "elif", "else", "for", "while", "return", "import", "from", "as", "try", "except", "raise", "with", "pass", "break", "continue", "lambda", "yield", "del", "global", "nonlocal", "assert", "in", "not", "and", "or", "is", "None", "True", "False", "async", "await", "finally", "self"),
|
||||||
|
"bash" to setOf("if", "then", "else", "elif", "fi", "for", "while", "do", "done", "case", "esac", "function", "return", "exit", "echo", "export", "source", "local", "readonly", "declare", "unset", "shift", "set", "true", "false", "in", "select", "until", "trap", "break", "continue"),
|
||||||
|
"sh" to setOf("if", "then", "else", "elif", "fi", "for", "while", "do", "done", "case", "esac", "function", "return", "exit", "echo", "export", "source", "local", "readonly", "declare", "unset", "shift", "set", "true", "false", "in", "select", "until", "trap", "break", "continue"),
|
||||||
|
"sql" to setOf("SELECT", "FROM", "WHERE", "INSERT", "UPDATE", "DELETE", "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "INTO", "VALUES", "SET", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER", "ON", "AND", "OR", "NOT", "NULL", "IS", "IN", "AS", "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", "UNION", "ALL", "DISTINCT", "EXISTS", "BETWEEN", "LIKE", "CASE", "WHEN", "THEN", "ELSE", "END", "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "CASCADE", "DEFAULT", "CONSTRAINT", "UNIQUE", "CHECK", "TRUE", "FALSE", "BEGIN", "COMMIT", "ROLLBACK", "GRANT", "REVOKE", "WITH"),
|
||||||
|
"css" to setOf("color", "background", "border", "margin", "padding", "display", "position", "width", "height", "font", "text", "flex", "grid", "align", "justify", "overflow", "opacity", "transform", "transition", "animation", "content", "cursor", "visibility", "z-index"),
|
||||||
|
"html" to setOf("div", "span", "p", "a", "img", "input", "button", "form", "table", "tr", "td", "th", "ul", "ol", "li", "h1", "h2", "h3", "h4", "h5", "h6", "head", "body", "html", "link", "meta", "script", "style", "section", "header", "footer", "nav", "main", "article", "aside"),
|
||||||
|
"json" to setOf("true", "false", "null"),
|
||||||
|
"yaml" to setOf("true", "false", "null", "yes", "no"),
|
||||||
|
"xml" to emptySet(),
|
||||||
|
"rust" to setOf("fn", "let", "mut", "const", "if", "else", "match", "for", "while", "loop", "return", "use", "mod", "pub", "crate", "self", "super", "struct", "enum", "impl", "trait", "type", "where", "as", "in", "ref", "move", "async", "await", "true", "false", "Some", "None", "Ok", "Err", "unsafe", "extern", "dyn", "static", "macro_rules"),
|
||||||
|
"go" to setOf("func", "var", "const", "if", "else", "for", "range", "return", "import", "package", "type", "struct", "interface", "map", "chan", "go", "defer", "select", "case", "switch", "break", "continue", "default", "nil", "true", "false", "fallthrough"),
|
||||||
|
"swift" to setOf("func", "let", "var", "class", "struct", "enum", "protocol", "if", "else", "for", "while", "return", "import", "switch", "case", "break", "continue", "guard", "defer", "do", "try", "catch", "throw", "throws", "async", "await", "nil", "true", "false", "self", "Self", "init", "deinit", "extension", "subscript", "where", "in", "is", "as", "typealias", "associatedtype", "private", "public", "internal", "open", "fileprivate", "static", "override", "mutating", "weak", "unowned", "lazy", "final", "required", "convenience", "optional", "some", "any"),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun highlightCode(code: String, lang: String, isDark: Boolean): AnnotatedString {
|
||||||
|
val keywords = KEYWORDS[lang.lowercase()] ?: KEYWORDS["javascript"] ?: emptySet()
|
||||||
|
val kwColor = if (isDark) Color(0xFFFF79C6) else Color(0xFFD63384)
|
||||||
|
val strColor = if (isDark) Color(0xFFF1FA8C) else Color(0xFF22863A)
|
||||||
|
val commentColor = if (isDark) Color(0xFF6272A4) else Color(0xFF6A737D)
|
||||||
|
val numColor = if (isDark) Color(0xFFBD93F9) else Color(0xFF005CC5)
|
||||||
|
val baseColor = if (isDark) Color(0xFFF8F8F2) else Color(0xFF24292E)
|
||||||
|
|
||||||
|
return buildAnnotatedString {
|
||||||
|
var pos = 0
|
||||||
|
while (pos < code.length) {
|
||||||
|
// Line comment
|
||||||
|
if (pos + 1 < code.length && code[pos] == '/' && code[pos + 1] == '/') {
|
||||||
|
val end = code.indexOf('\n', pos).let { if (it < 0) code.length else it }
|
||||||
|
withStyle(SpanStyle(color = commentColor, fontStyle = FontStyle.Italic)) {
|
||||||
|
append(code.substring(pos, end))
|
||||||
|
}
|
||||||
|
pos = end
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Hash comment
|
||||||
|
if (code[pos] == '#' && (lang.lowercase() in setOf("python", "bash", "sh", "yaml", "ruby"))) {
|
||||||
|
val end = code.indexOf('\n', pos).let { if (it < 0) code.length else it }
|
||||||
|
withStyle(SpanStyle(color = commentColor, fontStyle = FontStyle.Italic)) {
|
||||||
|
append(code.substring(pos, end))
|
||||||
|
}
|
||||||
|
pos = end
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// String literals
|
||||||
|
if (code[pos] == '"' || code[pos] == '\'') {
|
||||||
|
val quote = code[pos]
|
||||||
|
var end = pos + 1
|
||||||
|
while (end < code.length && code[end] != quote) {
|
||||||
|
if (code[end] == '\\' && end + 1 < code.length) end++
|
||||||
|
end++
|
||||||
|
}
|
||||||
|
if (end < code.length) end++
|
||||||
|
withStyle(SpanStyle(color = strColor)) {
|
||||||
|
append(code.substring(pos, end))
|
||||||
|
}
|
||||||
|
pos = end
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Numbers
|
||||||
|
if (code[pos].isDigit() && (pos == 0 || !code[pos - 1].isLetterOrDigit())) {
|
||||||
|
var end = pos
|
||||||
|
while (end < code.length && (code[end].isDigit() || code[end] == '.' || code[end] == 'x' || code[end] == 'f' || code[end] == 'L')) end++
|
||||||
|
withStyle(SpanStyle(color = numColor)) {
|
||||||
|
append(code.substring(pos, end))
|
||||||
|
}
|
||||||
|
pos = end
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Words (potential keywords)
|
||||||
|
if (code[pos].isLetter() || code[pos] == '_') {
|
||||||
|
var end = pos
|
||||||
|
while (end < code.length && (code[end].isLetterOrDigit() || code[end] == '_')) end++
|
||||||
|
val word = code.substring(pos, end)
|
||||||
|
if (word in keywords) {
|
||||||
|
withStyle(SpanStyle(color = kwColor, fontWeight = FontWeight.SemiBold)) {
|
||||||
|
append(word)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
withStyle(SpanStyle(color = baseColor)) {
|
||||||
|
append(word)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pos = end
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
withStyle(SpanStyle(color = baseColor)) {
|
||||||
|
append(code[pos])
|
||||||
|
}
|
||||||
|
pos++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier = Modifier) {
|
||||||
|
val cs = MaterialTheme.colorScheme
|
||||||
|
val isDark = isSystemInDarkTheme()
|
||||||
|
val blocks = remember(text) { parseBlocks(text) }
|
||||||
|
|
||||||
|
val codeBg = if (isDark) Color(0xFF1A1E2A) else Color(0xFFF3F4F6)
|
||||||
|
val codeBorder = if (isDark) Color.White.copy(alpha = 0.08f) else Color.Black.copy(alpha = 0.06f)
|
||||||
|
val inlineCodeBg = if (isDark) Color(0xFF1E2433) else Color(0xFFEBEDF0)
|
||||||
|
val inlineCodeColor = if (isDark) Color(0xFFE06C75) else Color(0xFFD63384)
|
||||||
|
val langBadgeColor = cs.onSurfaceVariant.copy(alpha = 0.5f)
|
||||||
|
|
||||||
|
val cursor = if (streaming) " ▌" else ""
|
||||||
|
|
||||||
|
Column(modifier) {
|
||||||
|
blocks.forEachIndexed { idx, block ->
|
||||||
|
val isLast = idx == blocks.lastIndex
|
||||||
|
when (block) {
|
||||||
|
is MdBlock.Heading -> {
|
||||||
|
if (idx > 0) Spacer(Modifier.height(8.dp))
|
||||||
|
val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg)
|
||||||
|
Text(
|
||||||
|
text = rendered,
|
||||||
|
color = cs.onBackground,
|
||||||
|
fontSize = when (block.level) { 1 -> 22.sp; 2 -> 19.sp; else -> 17.sp },
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
lineHeight = when (block.level) { 1 -> 28.sp; 2 -> 25.sp; else -> 23.sp },
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
is MdBlock.CodeFence -> {
|
||||||
|
if (idx > 0) Spacer(Modifier.height(6.dp))
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(10.dp))
|
||||||
|
.background(codeBg)
|
||||||
|
.border(1.dp, codeBorder, RoundedCornerShape(10.dp))
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
if (block.lang.isNotBlank()) {
|
||||||
|
Text(
|
||||||
|
block.lang,
|
||||||
|
color = langBadgeColor,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
modifier = Modifier.padding(start = 12.dp, top = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.horizontalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 12.dp, vertical = if (block.lang.isNotBlank()) 6.dp else 10.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = highlightCode(block.code + if (isLast) cursor else "", block.lang, isDark),
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
lineHeight = 19.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(6.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
is MdBlock.ListBlock -> {
|
||||||
|
if (idx > 0) Spacer(Modifier.height(4.dp))
|
||||||
|
block.items.forEachIndexed { itemIdx, item ->
|
||||||
|
Row(Modifier.fillMaxWidth().padding(start = 4.dp, bottom = 3.dp)) {
|
||||||
|
val bullet = if (item.ordered) "${item.index}." else "•"
|
||||||
|
Text(
|
||||||
|
bullet,
|
||||||
|
color = cs.onSurfaceVariant,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
modifier = Modifier.width(if (item.ordered) 24.dp else 16.dp),
|
||||||
|
)
|
||||||
|
val appendCursor = isLast && itemIdx == block.items.lastIndex
|
||||||
|
Text(
|
||||||
|
text = inlineMarkdown(item.text + if (appendCursor) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg),
|
||||||
|
color = cs.onBackground,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 23.sp,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
is MdBlock.Paragraph -> {
|
||||||
|
if (idx > 0) Spacer(Modifier.height(6.dp))
|
||||||
|
Text(
|
||||||
|
text = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg),
|
||||||
|
color = cs.onBackground,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
lineHeight = 23.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is MdBlock.Item -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue