upgrade Markdown renderer — tables, strikethrough, clickable links, task lists

New block types: Table (header + separator + body rows, horizontal scroll,
styled borders), task lists (- [ ] / - [x] with check icons).
New inline: ~~strikethrough~~, clickable links (tap opens ACTION_VIEW).
Code highlighting: block comments (/* */), HTML comments (<!-- -->),
template strings, SQL case-insensitive keywords, added C/C++/Ruby/PHP/
Dockerfile/TOML language support. All regex patterns pre-compiled as
top-level constants for streaming performance.
This commit is contained in:
Bruno Deanoz 2026-06-22 19:20:17 +02:00
parent 70aa5c56da
commit fd0de5ccef

View file

@ -1,5 +1,7 @@
package dev.kaizen.app.chat package dev.kaizen.app.chat
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -11,6 +13,7 @@ import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -18,6 +21,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import dev.kaizen.app.ui.shape.KaizenShapes import dev.kaizen.app.ui.shape.KaizenShapes
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -33,14 +37,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import dev.kaizen.app.haptics.rememberHaptics import dev.kaizen.app.haptics.rememberHaptics
@ -48,6 +54,8 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import dev.kaizen.app.ui.icon.KaizenIcons import dev.kaizen.app.ui.icon.KaizenIcons
// ── Block types ────────────────────────────────────────────────────────────────
private sealed interface MdBlock { private sealed interface MdBlock {
data class Paragraph(val text: String) : MdBlock data class Paragraph(val text: String) : MdBlock
data class Heading(val level: Int, val text: String) : MdBlock data class Heading(val level: Int, val text: String) : MdBlock
@ -56,9 +64,31 @@ private sealed interface MdBlock {
data class Item(val text: String, val ordered: Boolean, val index: Int) : MdBlock data class Item(val text: String, val ordered: Boolean, val index: Int) : MdBlock
data object HorizontalRule : MdBlock data object HorizontalRule : MdBlock
data class Blockquote(val text: String) : MdBlock data class Blockquote(val text: String) : MdBlock
data class Table(val headers: List<String>, val rows: List<List<String>>) : MdBlock
} }
private data class ListItem(val text: String, val ordered: Boolean, val index: Int) private data class ListItem(val text: String, val ordered: Boolean, val index: Int, val checked: Boolean? = null)
// ── Pre-compiled patterns ──────────────────────────────────────────────────────
private val HR_PATTERN = Regex("^\\s*([-*_])\\s*\\1\\s*\\1[\\s\\-*_]*$")
private val BQ_PATTERN = Regex("^\\s*>\\s?(.*)")
private val HEADING_PATTERN = Regex("^(#{1,3})\\s+(.+)")
private val UL_PATTERN = Regex("^\\s*[-*+]\\s+(.*)")
private val OL_PATTERN = Regex("^\\s*(\\d+)[.)]\\s+(.*)")
private val TASK_PATTERN = Regex("^\\[([xX ])]\\s+(.*)")
private val TABLE_ROW_PATTERN = Regex("^\\|(.+)\\|\\s*$")
private val TABLE_SEP_PATTERN = Regex("^\\|[\\s:]*-{2,}[\\s:]*(?:\\|[\\s:]*-{2,}[\\s:]*)*\\|\\s*$")
private val FENCE_START = "```"
private val PARA_BREAK_PATTERNS = listOf(
Regex("^#{1,3}\\s+"),
Regex("^\\s*[-*+]\\s+"),
Regex("^\\s*\\d+[.)]\\s+"),
Regex("^\\s*>\\s?"),
Regex("^\\|.+\\|\\s*$"),
)
// ── Parser ─────────────────────────────────────────────────────────────────────
private fun parseBlocks(src: String): List<MdBlock> { private fun parseBlocks(src: String): List<MdBlock> {
val blocks = mutableListOf<MdBlock>() val blocks = mutableListOf<MdBlock>()
@ -67,100 +97,101 @@ private fun parseBlocks(src: String): List<MdBlock> {
while (i < lines.size) { while (i < lines.size) {
val line = lines[i] val line = lines[i]
// Horizontal rule: ---, ***, ___ (3+ of same char, optionally spaced) if (HR_PATTERN.matches(line)) {
if (Regex("^\\s*([-*_])\\s*\\1\\s*\\1[\\s\\-*_]*$").matches(line)) {
blocks.add(MdBlock.HorizontalRule) blocks.add(MdBlock.HorizontalRule)
i++ i++
continue continue
} }
// Blockquote: > ... val bqMatch = BQ_PATTERN.matchEntire(line)
val bqMatch = Regex("^\\s*>\\s?(.*)").matchEntire(line)
if (bqMatch != null) { if (bqMatch != null) {
val bqLines = mutableListOf(bqMatch.groupValues[1]) val bqLines = mutableListOf(bqMatch.groupValues[1])
i++ i++
while (i < lines.size) { while (i < lines.size) {
val m = Regex("^\\s*>\\s?(.*)").matchEntire(lines[i]) val m = BQ_PATTERN.matchEntire(lines[i])
if (m != null) { if (m != null) { bqLines.add(m.groupValues[1]); i++ } else break
bqLines.add(m.groupValues[1])
i++
} else break
} }
blocks.add(MdBlock.Blockquote(bqLines.joinToString("\n"))) blocks.add(MdBlock.Blockquote(bqLines.joinToString("\n")))
continue continue
} }
// Code fence if (line.trimStart().startsWith(FENCE_START)) {
if (line.trimStart().startsWith("```")) { val lang = line.trimStart().removePrefix(FENCE_START).trim()
val lang = line.trimStart().removePrefix("```").trim()
val codeLines = mutableListOf<String>() val codeLines = mutableListOf<String>()
i++ i++
while (i < lines.size && !lines[i].trimStart().startsWith("```")) { while (i < lines.size && !lines[i].trimStart().startsWith(FENCE_START)) {
codeLines.add(lines[i]) codeLines.add(lines[i]); i++
i++
} }
if (i < lines.size) i++ // skip closing ``` if (i < lines.size) i++
blocks.add(MdBlock.CodeFence(lang, codeLines.joinToString("\n"))) blocks.add(MdBlock.CodeFence(lang, codeLines.joinToString("\n")))
continue continue
} }
// Heading // Table: header row, separator row, then body rows
val headingMatch = Regex("^(#{1,3})\\s+(.+)").matchEntire(line) val tableMatch = TABLE_ROW_PATTERN.matchEntire(line)
if (tableMatch != null && i + 1 < lines.size && TABLE_SEP_PATTERN.matches(lines[i + 1])) {
val headers = tableMatch.groupValues[1].split("|").map { it.trim() }
i += 2 // skip header + separator
val rows = mutableListOf<List<String>>()
while (i < lines.size) {
val rowMatch = TABLE_ROW_PATTERN.matchEntire(lines[i])
if (rowMatch != null) {
rows.add(rowMatch.groupValues[1].split("|").map { it.trim() })
i++
} else break
}
blocks.add(MdBlock.Table(headers, rows))
continue
}
val headingMatch = HEADING_PATTERN.matchEntire(line)
if (headingMatch != null) { if (headingMatch != null) {
val level = headingMatch.groupValues[1].length blocks.add(MdBlock.Heading(headingMatch.groupValues[1].length, headingMatch.groupValues[2]))
blocks.add(MdBlock.Heading(level, headingMatch.groupValues[2]))
i++ i++
continue continue
} }
// Unordered list item val ulMatch = UL_PATTERN.matchEntire(line)
val ulMatch = Regex("^\\s*[-*+]\\s+(.+)").matchEntire(line)
if (ulMatch != null) { if (ulMatch != null) {
val items = mutableListOf<ListItem>() val items = mutableListOf<ListItem>()
while (i < lines.size) { while (i < lines.size) {
val m = Regex("^\\s*[-*+]\\s+(.+)").matchEntire(lines[i]) val m = UL_PATTERN.matchEntire(lines[i]) ?: break
if (m != null) { val content = m.groupValues[1]
items.add(ListItem(m.groupValues[1], ordered = false, index = items.size + 1)) val taskMatch = TASK_PATTERN.matchEntire(content)
if (taskMatch != null) {
val checked = taskMatch.groupValues[1] != " "
items.add(ListItem(taskMatch.groupValues[2], ordered = false, index = items.size + 1, checked = checked))
} else {
items.add(ListItem(content, ordered = false, index = items.size + 1))
}
i++ i++
} else break
} }
blocks.add(MdBlock.ListBlock(items)) blocks.add(MdBlock.ListBlock(items))
continue continue
} }
// Ordered list item val olMatch = OL_PATTERN.matchEntire(line)
val olMatch = Regex("^\\s*(\\d+)[.)]\\s+(.+)").matchEntire(line)
if (olMatch != null) { if (olMatch != null) {
val items = mutableListOf<ListItem>() val items = mutableListOf<ListItem>()
while (i < lines.size) { while (i < lines.size) {
val m = Regex("^\\s*(\\d+)[.)]\\s+(.+)").matchEntire(lines[i]) val m = OL_PATTERN.matchEntire(lines[i]) ?: break
if (m != null) {
items.add(ListItem(m.groupValues[2], ordered = true, index = m.groupValues[1].toInt())) items.add(ListItem(m.groupValues[2], ordered = true, index = m.groupValues[1].toInt()))
i++ i++
} else break
} }
blocks.add(MdBlock.ListBlock(items)) blocks.add(MdBlock.ListBlock(items))
continue continue
} }
// Empty line — skip if (line.isBlank()) { i++; continue }
if (line.isBlank()) {
i++
continue
}
// Paragraph: collect consecutive non-blank, non-special lines
val paraLines = mutableListOf(line) val paraLines = mutableListOf(line)
i++ i++
while (i < lines.size) { while (i < lines.size) {
val next = lines[i] val next = lines[i]
if (next.isBlank() if (next.isBlank()
|| next.trimStart().startsWith("```") || next.trimStart().startsWith(FENCE_START)
|| Regex("^#{1,3}\\s+").containsMatchIn(next) || HR_PATTERN.matches(next)
|| Regex("^\\s*[-*+]\\s+").containsMatchIn(next) || PARA_BREAK_PATTERNS.any { it.containsMatchIn(next) }
|| Regex("^\\s*\\d+[.)]\\s+").containsMatchIn(next)
|| Regex("^\\s*([-*_])\\s*\\1\\s*\\1[\\s\\-*_]*$").matches(next)
|| Regex("^\\s*>\\s?").containsMatchIn(next)
) break ) break
paraLines.add(next) paraLines.add(next)
i++ i++
@ -170,7 +201,17 @@ private fun parseBlocks(src: String): List<MdBlock> {
return blocks return blocks
} }
private fun inlineMarkdown(text: String, baseColor: Color, codeColor: Color, codeBg: Color, linkColor: Color = Color(0xFF3B82F6)): AnnotatedString { // ── Inline markdown ────────────────────────────────────────────────────────────
private const val LINK_TAG = "URL"
private fun inlineMarkdown(
text: String,
baseColor: Color,
codeColor: Color,
codeBg: Color,
linkColor: Color = Color(0xFF3B82F6),
): AnnotatedString {
return buildAnnotatedString { return buildAnnotatedString {
var pos = 0 var pos = 0
val src = text val src = text
@ -182,9 +223,12 @@ private fun inlineMarkdown(text: String, baseColor: Color, codeColor: Color, cod
val closeParen = src.indexOf(')', closeBracket + 2) val closeParen = src.indexOf(')', closeBracket + 2)
if (closeParen > closeBracket) { if (closeParen > closeBracket) {
val linkText = src.substring(pos + 1, closeBracket) val linkText = src.substring(pos + 1, closeBracket)
withStyle(SpanStyle(color = linkColor, fontWeight = FontWeight.Medium)) { val url = src.substring(closeBracket + 2, closeParen)
pushStringAnnotation(tag = LINK_TAG, annotation = url)
withStyle(SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline)) {
append(linkText) append(linkText)
} }
pop()
pos = closeParen + 1 pos = closeParen + 1
continue continue
} }
@ -195,12 +239,25 @@ private fun inlineMarkdown(text: String, baseColor: Color, codeColor: Color, cod
val end = src.indexOf('`', pos + 1) val end = src.indexOf('`', pos + 1)
if (end > pos) { if (end > pos) {
withStyle(SpanStyle(fontFamily = FontFamily.Monospace, color = codeColor, background = codeBg, fontSize = 14.sp)) { withStyle(SpanStyle(fontFamily = FontFamily.Monospace, color = codeColor, background = codeBg, fontSize = 14.sp)) {
append("")
append(src.substring(pos + 1, end)) append(src.substring(pos + 1, end))
append("")
} }
pos = end + 1 pos = end + 1
continue continue
} }
} }
// Strikethrough: ~~...~~
if (pos + 1 < src.length && src[pos] == '~' && src[pos + 1] == '~') {
val end = src.indexOf("~~", pos + 2)
if (end > pos) {
withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough, color = baseColor.copy(alpha = 0.5f))) {
append(src.substring(pos + 2, end))
}
pos = end + 2
continue
}
}
// Bold+italic: ***...*** or ___...___ // Bold+italic: ***...*** or ___...___
if (pos + 2 < src.length && src.substring(pos, pos + 3).let { it == "***" || it == "___" }) { if (pos + 2 < src.length && src.substring(pos, pos + 3).let { it == "***" || it == "___" }) {
val marker = src.substring(pos, pos + 3) val marker = src.substring(pos, pos + 3)
@ -243,7 +300,8 @@ private fun inlineMarkdown(text: String, baseColor: Color, codeColor: Color, cod
} }
} }
// Syntax-highlighting keywords per language // ── Syntax highlighting ────────────────────────────────────────────────────────
private val KEYWORDS = mapOf( 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"), "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"), "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"),
@ -261,6 +319,12 @@ private val KEYWORDS = mapOf(
"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"), "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"), "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"), "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"),
"c" to setOf("auto", "break", "case", "char", "const", "continue", "default", "do", "double", "else", "enum", "extern", "float", "for", "goto", "if", "int", "long", "register", "return", "short", "signed", "sizeof", "static", "struct", "switch", "typedef", "union", "unsigned", "void", "volatile", "while", "NULL", "true", "false", "inline", "restrict"),
"cpp" to setOf("auto", "break", "case", "char", "class", "const", "continue", "default", "delete", "do", "double", "else", "enum", "extern", "false", "float", "for", "friend", "goto", "if", "inline", "int", "long", "mutable", "namespace", "new", "nullptr", "operator", "private", "protected", "public", "register", "return", "short", "signed", "sizeof", "static", "struct", "switch", "template", "this", "throw", "true", "try", "typedef", "typename", "union", "unsigned", "using", "virtual", "void", "volatile", "while", "override", "final", "constexpr", "noexcept", "decltype", "thread_local", "static_assert"),
"ruby" to setOf("def", "class", "module", "if", "elsif", "else", "unless", "while", "until", "for", "do", "end", "return", "yield", "begin", "rescue", "ensure", "raise", "require", "include", "extend", "attr_reader", "attr_writer", "attr_accessor", "self", "nil", "true", "false", "and", "or", "not", "in", "then", "when", "case", "lambda", "proc", "block_given?", "defined?", "super", "private", "protected", "public"),
"php" to setOf("function", "class", "if", "else", "elseif", "for", "foreach", "while", "do", "switch", "case", "break", "continue", "return", "echo", "print", "new", "try", "catch", "throw", "finally", "public", "private", "protected", "static", "abstract", "interface", "extends", "implements", "namespace", "use", "require", "include", "null", "true", "false", "var", "const", "array", "isset", "unset", "empty"),
"toml" to setOf("true", "false"),
"dockerfile" to setOf("FROM", "RUN", "CMD", "EXPOSE", "ENV", "ADD", "COPY", "ENTRYPOINT", "VOLUME", "USER", "WORKDIR", "ARG", "LABEL", "STOPSIGNAL", "HEALTHCHECK", "SHELL", "ONBUILD", "AS"),
) )
private fun highlightCode(code: String, lang: String, isDark: Boolean): AnnotatedString { private fun highlightCode(code: String, lang: String, isDark: Boolean): AnnotatedString {
@ -274,7 +338,16 @@ private fun highlightCode(code: String, lang: String, isDark: Boolean): Annotate
return buildAnnotatedString { return buildAnnotatedString {
var pos = 0 var pos = 0
while (pos < code.length) { while (pos < code.length) {
// Line comment // Block comment: /* ... */
if (pos + 1 < code.length && code[pos] == '/' && code[pos + 1] == '*') {
val end = code.indexOf("*/", pos + 2).let { if (it < 0) code.length else it + 2 }
withStyle(SpanStyle(color = commentColor, fontStyle = FontStyle.Italic)) {
append(code.substring(pos, end))
}
pos = end
continue
}
// Line comment: //
if (pos + 1 < code.length && code[pos] == '/' && code[pos + 1] == '/') { if (pos + 1 < code.length && code[pos] == '/' && code[pos + 1] == '/') {
val end = code.indexOf('\n', pos).let { if (it < 0) code.length else it } val end = code.indexOf('\n', pos).let { if (it < 0) code.length else it }
withStyle(SpanStyle(color = commentColor, fontStyle = FontStyle.Italic)) { withStyle(SpanStyle(color = commentColor, fontStyle = FontStyle.Italic)) {
@ -284,7 +357,7 @@ private fun highlightCode(code: String, lang: String, isDark: Boolean): Annotate
continue continue
} }
// Hash comment // Hash comment
if (code[pos] == '#' && (lang.lowercase() in setOf("python", "bash", "sh", "yaml", "ruby"))) { if (code[pos] == '#' && (lang.lowercase() in setOf("python", "bash", "sh", "yaml", "ruby", "toml", "dockerfile"))) {
val end = code.indexOf('\n', pos).let { if (it < 0) code.length else it } val end = code.indexOf('\n', pos).let { if (it < 0) code.length else it }
withStyle(SpanStyle(color = commentColor, fontStyle = FontStyle.Italic)) { withStyle(SpanStyle(color = commentColor, fontStyle = FontStyle.Italic)) {
append(code.substring(pos, end)) append(code.substring(pos, end))
@ -292,6 +365,15 @@ private fun highlightCode(code: String, lang: String, isDark: Boolean): Annotate
pos = end pos = end
continue continue
} }
// HTML/XML comment: <!-- ... -->
if (pos + 3 < code.length && code.substring(pos, pos + 4) == "<!--") {
val end = code.indexOf("-->", pos + 4).let { if (it < 0) code.length else it + 3 }
withStyle(SpanStyle(color = commentColor, fontStyle = FontStyle.Italic)) {
append(code.substring(pos, end))
}
pos = end
continue
}
// String literals // String literals
if (code[pos] == '"' || code[pos] == '\'') { if (code[pos] == '"' || code[pos] == '\'') {
val quote = code[pos] val quote = code[pos]
@ -307,6 +389,20 @@ private fun highlightCode(code: String, lang: String, isDark: Boolean): Annotate
pos = end pos = end
continue continue
} }
// Template/backtick strings
if (code[pos] == '`' && lang.lowercase() in setOf("javascript", "typescript", "js", "ts")) {
var end = pos + 1
while (end < code.length && code[end] != '`') {
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 // Numbers
if (code[pos].isDigit() && (pos == 0 || !code[pos - 1].isLetterOrDigit())) { if (code[pos].isDigit() && (pos == 0 || !code[pos - 1].isLetterOrDigit())) {
var end = pos var end = pos
@ -318,41 +414,42 @@ private fun highlightCode(code: String, lang: String, isDark: Boolean): Annotate
continue continue
} }
// Words (potential keywords) // Words (potential keywords)
if (code[pos].isLetter() || code[pos] == '_') { if (code[pos].isLetter() || code[pos] == '_' || code[pos] == '@') {
var end = pos var end = pos
while (end < code.length && (code[end].isLetterOrDigit() || code[end] == '_')) end++ while (end < code.length && (code[end].isLetterOrDigit() || code[end] == '_')) end++
val word = code.substring(pos, end) val word = code.substring(pos, end)
if (word in keywords) { val isCaseInsensitiveKw = lang.lowercase() == "sql"
withStyle(SpanStyle(color = kwColor, fontWeight = FontWeight.SemiBold)) { if (word in keywords || (isCaseInsensitiveKw && word.uppercase() in keywords)) {
append(word) withStyle(SpanStyle(color = kwColor, fontWeight = FontWeight.SemiBold)) { append(word) }
}
} else { } else {
withStyle(SpanStyle(color = baseColor)) { withStyle(SpanStyle(color = baseColor)) { append(word) }
append(word)
}
} }
pos = end pos = end
continue continue
} }
withStyle(SpanStyle(color = baseColor)) { withStyle(SpanStyle(color = baseColor)) { append(code[pos]) }
append(code[pos])
}
pos++ pos++
} }
} }
} }
// ── Render ─────────────────────────────────────────────────────────────────────
@Composable @Composable
fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier = Modifier) { fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier = Modifier) {
val cs = MaterialTheme.colorScheme val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
val context = LocalContext.current
val blocks = remember(text) { parseBlocks(text) } val blocks = remember(text) { parseBlocks(text) }
val codeBg = if (isDark) Color(0xFF1A1E2A) else Color(0xFFF3F4F6) 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 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 inlineCodeBg = if (isDark) Color(0xFF1E2433) else Color(0xFFEBEDF0)
val inlineCodeColor = if (isDark) Color(0xFFE06C75) else Color(0xFFD63384) val inlineCodeColor = if (isDark) Color(0xFFE06C75) else Color(0xFFD63384)
val linkColor = if (isDark) Color(0xFF60A5FA) else Color(0xFF2563EB)
val langBadgeColor = cs.onSurfaceVariant.copy(alpha = 0.5f) val langBadgeColor = cs.onSurfaceVariant.copy(alpha = 0.5f)
val tableBorder = if (isDark) Color.White.copy(alpha = 0.10f) else Color.Black.copy(alpha = 0.08f)
val tableHeaderBg = if (isDark) Color.White.copy(alpha = 0.05f) else Color.Black.copy(alpha = 0.03f)
val cursor = if (streaming) "" else "" val cursor = if (streaming) "" else ""
@ -362,10 +459,8 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
when (block) { when (block) {
is MdBlock.Heading -> { is MdBlock.Heading -> {
if (idx > 0) Spacer(Modifier.height(18.dp)) if (idx > 0) Spacer(Modifier.height(18.dp))
val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg) val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg, linkColor)
Text( ClickableMarkdownText(rendered, context, cs.onBackground,
text = rendered,
color = cs.onBackground,
fontSize = when (block.level) { 1 -> 22.sp; 2 -> 19.sp; else -> 17.sp }, fontSize = when (block.level) { 1 -> 22.sp; 2 -> 19.sp; else -> 17.sp },
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
lineHeight = when (block.level) { 1 -> 30.sp; 2 -> 27.sp; else -> 24.sp }, lineHeight = when (block.level) { 1 -> 30.sp; 2 -> 27.sp; else -> 24.sp },
@ -383,7 +478,6 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
.border(1.dp, codeBorder, RoundedCornerShape(10.dp)) .border(1.dp, codeBorder, RoundedCornerShape(10.dp))
) { ) {
Column { Column {
// Header row: lang badge + copy button
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@ -417,10 +511,76 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
} }
is MdBlock.Table -> {
if (idx > 0) Spacer(Modifier.height(12.dp))
Box(
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.clip(RoundedCornerShape(8.dp))
.border(1.dp, tableBorder, RoundedCornerShape(8.dp))
) {
Column {
// Header
Row(Modifier.background(tableHeaderBg)) {
block.headers.forEach { cell ->
Box(
Modifier
.width(IntrinsicSize.Max)
.defaultMinSize(minWidth = 80.dp)
.border(0.5.dp, tableBorder)
.padding(horizontal = 10.dp, vertical = 8.dp)
) {
Text(
text = cell,
color = cs.onBackground,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
}
}
// Body rows
block.rows.forEach { row ->
Row {
row.forEachIndexed { cellIdx, cell ->
Box(
Modifier
.width(IntrinsicSize.Max)
.defaultMinSize(minWidth = 80.dp)
.border(0.5.dp, tableBorder)
.padding(horizontal = 10.dp, vertical = 7.dp)
) {
val rendered = inlineMarkdown(cell, cs.onBackground, inlineCodeColor, inlineCodeBg, linkColor)
Text(
text = rendered,
color = cs.onBackground,
fontSize = 13.sp,
lineHeight = 18.sp,
maxLines = 5,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
}
Spacer(Modifier.height(12.dp))
}
is MdBlock.ListBlock -> { is MdBlock.ListBlock -> {
if (idx > 0) Spacer(Modifier.height(8.dp)) if (idx > 0) Spacer(Modifier.height(8.dp))
block.items.forEachIndexed { itemIdx, item -> block.items.forEachIndexed { itemIdx, item ->
Row(Modifier.fillMaxWidth().padding(start = 6.dp, bottom = 6.dp)) { Row(Modifier.fillMaxWidth().padding(start = 6.dp, bottom = 6.dp)) {
if (item.checked != null) {
val checkColor = if (item.checked) linkColor else cs.onSurfaceVariant.copy(alpha = 0.4f)
val checkIcon = if (item.checked) KaizenIcons.Check else KaizenIcons.Square
Icon(checkIcon, null, tint = checkColor, modifier = Modifier.size(18.dp).padding(top = 3.dp))
Spacer(Modifier.width(6.dp))
} else {
val bullet = if (item.ordered) "${item.index}." else "" val bullet = if (item.ordered) "${item.index}." else ""
Text( Text(
bullet, bullet,
@ -428,12 +588,11 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
fontSize = 16.sp, fontSize = 16.sp,
modifier = Modifier.width(if (item.ordered) 24.dp else 16.dp), modifier = Modifier.width(if (item.ordered) 24.dp else 16.dp),
) )
}
val appendCursor = isLast && itemIdx == block.items.lastIndex val appendCursor = isLast && itemIdx == block.items.lastIndex
Text( val rendered = inlineMarkdown(item.text + if (appendCursor) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg, linkColor)
text = inlineMarkdown(item.text + if (appendCursor) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg), ClickableMarkdownText(rendered, context, cs.onBackground,
color = cs.onBackground, fontSize = 16.sp, lineHeight = 26.sp,
fontSize = 16.sp,
lineHeight = 26.sp,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} }
@ -468,11 +627,9 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
.background(cs.primary.copy(alpha = 0.5f)) .background(cs.primary.copy(alpha = 0.5f))
) )
Spacer(Modifier.width(10.dp)) Spacer(Modifier.width(10.dp))
Text( val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onSurfaceVariant, inlineCodeColor, inlineCodeBg, linkColor)
text = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onSurfaceVariant, inlineCodeColor, inlineCodeBg), ClickableMarkdownText(rendered, context, cs.onSurfaceVariant,
color = cs.onSurfaceVariant, fontSize = 15.sp, lineHeight = 24.sp,
fontSize = 15.sp,
lineHeight = 24.sp,
fontStyle = FontStyle.Italic, fontStyle = FontStyle.Italic,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
@ -482,11 +639,9 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
is MdBlock.Paragraph -> { is MdBlock.Paragraph -> {
if (idx > 0) Spacer(Modifier.height(12.dp)) if (idx > 0) Spacer(Modifier.height(12.dp))
Text( val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg, linkColor)
text = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg), ClickableMarkdownText(rendered, context, cs.onBackground,
color = cs.onBackground, fontSize = 16.sp, lineHeight = 26.sp,
fontSize = 16.sp,
lineHeight = 26.sp,
) )
} }
@ -496,6 +651,54 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
} }
} }
// ── Clickable text (handles link taps) ─────────────────────────────────────────
@Composable
private fun ClickableMarkdownText(
text: AnnotatedString,
context: android.content.Context,
color: Color,
fontSize: androidx.compose.ui.unit.TextUnit = 16.sp,
lineHeight: androidx.compose.ui.unit.TextUnit = 26.sp,
fontWeight: FontWeight? = null,
fontStyle: FontStyle? = null,
modifier: Modifier = Modifier,
) {
val hasLinks = text.getStringAnnotations(LINK_TAG, 0, text.length).isNotEmpty()
if (hasLinks) {
ClickableText(
text = text,
style = androidx.compose.ui.text.TextStyle(
color = color,
fontSize = fontSize,
lineHeight = lineHeight,
fontWeight = fontWeight,
fontStyle = fontStyle,
),
modifier = modifier,
onClick = { offset ->
text.getStringAnnotations(LINK_TAG, offset, offset).firstOrNull()?.let { ann ->
try {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(ann.item)))
} catch (_: Exception) {}
}
},
)
} else {
Text(
text = text,
color = color,
fontSize = fontSize,
lineHeight = lineHeight,
fontWeight = fontWeight,
fontStyle = fontStyle,
modifier = modifier,
)
}
}
// ── Copy button ────────────────────────────────────────────────────────────────
@Composable @Composable
private fun CopyButton(code: String) { private fun CopyButton(code: String) {
val clipboard = LocalClipboard.current val clipboard = LocalClipboard.current