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
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import dev.kaizen.app.ui.shape.KaizenShapes
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@ -33,14 +37,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
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.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.sp
import dev.kaizen.app.haptics.rememberHaptics
@ -48,6 +54,8 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import dev.kaizen.app.ui.icon.KaizenIcons
// ── Block types ────────────────────────────────────────────────────────────────
private sealed interface MdBlock {
data class Paragraph(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 object HorizontalRule : 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> {
val blocks = mutableListOf<MdBlock>()
@ -67,100 +97,101 @@ private fun parseBlocks(src: String): List<MdBlock> {
while (i < lines.size) {
val line = lines[i]
// Horizontal rule: ---, ***, ___ (3+ of same char, optionally spaced)
if (Regex("^\\s*([-*_])\\s*\\1\\s*\\1[\\s\\-*_]*$").matches(line)) {
if (HR_PATTERN.matches(line)) {
blocks.add(MdBlock.HorizontalRule)
i++
continue
}
// Blockquote: > ...
val bqMatch = Regex("^\\s*>\\s?(.*)").matchEntire(line)
val bqMatch = BQ_PATTERN.matchEntire(line)
if (bqMatch != null) {
val bqLines = mutableListOf(bqMatch.groupValues[1])
i++
while (i < lines.size) {
val m = Regex("^\\s*>\\s?(.*)").matchEntire(lines[i])
if (m != null) {
bqLines.add(m.groupValues[1])
i++
} else break
val m = BQ_PATTERN.matchEntire(lines[i])
if (m != null) { bqLines.add(m.groupValues[1]); i++ } else break
}
blocks.add(MdBlock.Blockquote(bqLines.joinToString("\n")))
continue
}
// Code fence
if (line.trimStart().startsWith("```")) {
val lang = line.trimStart().removePrefix("```").trim()
if (line.trimStart().startsWith(FENCE_START)) {
val lang = line.trimStart().removePrefix(FENCE_START).trim()
val codeLines = mutableListOf<String>()
i++
while (i < lines.size && !lines[i].trimStart().startsWith("```")) {
codeLines.add(lines[i])
i++
while (i < lines.size && !lines[i].trimStart().startsWith(FENCE_START)) {
codeLines.add(lines[i]); i++
}
if (i < lines.size) i++ // skip closing ```
if (i < lines.size) i++
blocks.add(MdBlock.CodeFence(lang, codeLines.joinToString("\n")))
continue
}
// Heading
val headingMatch = Regex("^(#{1,3})\\s+(.+)").matchEntire(line)
// Table: header row, separator row, then body rows
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) {
val level = headingMatch.groupValues[1].length
blocks.add(MdBlock.Heading(level, headingMatch.groupValues[2]))
blocks.add(MdBlock.Heading(headingMatch.groupValues[1].length, headingMatch.groupValues[2]))
i++
continue
}
// Unordered list item
val ulMatch = Regex("^\\s*[-*+]\\s+(.+)").matchEntire(line)
val ulMatch = UL_PATTERN.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
val m = UL_PATTERN.matchEntire(lines[i]) ?: break
val content = m.groupValues[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++
}
blocks.add(MdBlock.ListBlock(items))
continue
}
// Ordered list item
val olMatch = Regex("^\\s*(\\d+)[.)]\\s+(.+)").matchEntire(line)
val olMatch = OL_PATTERN.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
val m = OL_PATTERN.matchEntire(lines[i]) ?: break
items.add(ListItem(m.groupValues[2], ordered = true, index = m.groupValues[1].toInt()))
i++
}
blocks.add(MdBlock.ListBlock(items))
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)
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)
|| Regex("^\\s*([-*_])\\s*\\1\\s*\\1[\\s\\-*_]*$").matches(next)
|| Regex("^\\s*>\\s?").containsMatchIn(next)
|| next.trimStart().startsWith(FENCE_START)
|| HR_PATTERN.matches(next)
|| PARA_BREAK_PATTERNS.any { it.containsMatchIn(next) }
) break
paraLines.add(next)
i++
@ -170,7 +201,17 @@ private fun parseBlocks(src: String): List<MdBlock> {
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 {
var pos = 0
val src = text
@ -182,9 +223,12 @@ private fun inlineMarkdown(text: String, baseColor: Color, codeColor: Color, cod
val closeParen = src.indexOf(')', closeBracket + 2)
if (closeParen > 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)
}
pop()
pos = closeParen + 1
continue
}
@ -195,12 +239,25 @@ private fun inlineMarkdown(text: String, baseColor: Color, codeColor: Color, cod
val end = src.indexOf('`', pos + 1)
if (end > pos) {
withStyle(SpanStyle(fontFamily = FontFamily.Monospace, color = codeColor, background = codeBg, fontSize = 14.sp)) {
append("")
append(src.substring(pos + 1, end))
append("")
}
pos = end + 1
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 ___...___
if (pos + 2 < src.length && src.substring(pos, pos + 3).let { it == "***" || it == "___" }) {
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(
"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"),
@ -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"),
"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"),
"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 {
@ -274,7 +338,16 @@ private fun highlightCode(code: String, lang: String, isDark: Boolean): Annotate
return buildAnnotatedString {
var pos = 0
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] == '/') {
val end = code.indexOf('\n', pos).let { if (it < 0) code.length else it }
withStyle(SpanStyle(color = commentColor, fontStyle = FontStyle.Italic)) {
@ -284,7 +357,7 @@ private fun highlightCode(code: String, lang: String, isDark: Boolean): Annotate
continue
}
// 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 }
withStyle(SpanStyle(color = commentColor, fontStyle = FontStyle.Italic)) {
append(code.substring(pos, end))
@ -292,6 +365,15 @@ private fun highlightCode(code: String, lang: String, isDark: Boolean): Annotate
pos = end
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
if (code[pos] == '"' || code[pos] == '\'') {
val quote = code[pos]
@ -307,6 +389,20 @@ private fun highlightCode(code: String, lang: String, isDark: Boolean): Annotate
pos = end
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
if (code[pos].isDigit() && (pos == 0 || !code[pos - 1].isLetterOrDigit())) {
var end = pos
@ -318,41 +414,42 @@ private fun highlightCode(code: String, lang: String, isDark: Boolean): Annotate
continue
}
// Words (potential keywords)
if (code[pos].isLetter() || code[pos] == '_') {
if (code[pos].isLetter() || code[pos] == '_' || 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)
}
val isCaseInsensitiveKw = lang.lowercase() == "sql"
if (word in keywords || (isCaseInsensitiveKw && word.uppercase() in keywords)) {
withStyle(SpanStyle(color = kwColor, fontWeight = FontWeight.SemiBold)) { append(word) }
} else {
withStyle(SpanStyle(color = baseColor)) {
append(word)
}
withStyle(SpanStyle(color = baseColor)) { append(word) }
}
pos = end
continue
}
withStyle(SpanStyle(color = baseColor)) {
append(code[pos])
}
withStyle(SpanStyle(color = baseColor)) { append(code[pos]) }
pos++
}
}
}
// ── Render ─────────────────────────────────────────────────────────────────────
@Composable
fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier = Modifier) {
val cs = MaterialTheme.colorScheme
val isDark = isSystemInDarkTheme()
val context = LocalContext.current
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 linkColor = if (isDark) Color(0xFF60A5FA) else Color(0xFF2563EB)
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 ""
@ -362,10 +459,8 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
when (block) {
is MdBlock.Heading -> {
if (idx > 0) Spacer(Modifier.height(18.dp))
val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg)
Text(
text = rendered,
color = cs.onBackground,
val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg, linkColor)
ClickableMarkdownText(rendered, context, cs.onBackground,
fontSize = when (block.level) { 1 -> 22.sp; 2 -> 19.sp; else -> 17.sp },
fontWeight = FontWeight.Bold,
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))
) {
Column {
// Header row: lang badge + copy button
Row(
Modifier
.fillMaxWidth()
@ -417,23 +511,88 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
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 -> {
if (idx > 0) Spacer(Modifier.height(8.dp))
block.items.forEachIndexed { itemIdx, item ->
Row(Modifier.fillMaxWidth().padding(start = 6.dp, bottom = 6.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),
)
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 ""
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 = 26.sp,
val rendered = inlineMarkdown(item.text + if (appendCursor) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg, linkColor)
ClickableMarkdownText(rendered, context, cs.onBackground,
fontSize = 16.sp, lineHeight = 26.sp,
modifier = Modifier.weight(1f),
)
}
@ -468,11 +627,9 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
.background(cs.primary.copy(alpha = 0.5f))
)
Spacer(Modifier.width(10.dp))
Text(
text = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onSurfaceVariant, inlineCodeColor, inlineCodeBg),
color = cs.onSurfaceVariant,
fontSize = 15.sp,
lineHeight = 24.sp,
val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onSurfaceVariant, inlineCodeColor, inlineCodeBg, linkColor)
ClickableMarkdownText(rendered, context, cs.onSurfaceVariant,
fontSize = 15.sp, lineHeight = 24.sp,
fontStyle = FontStyle.Italic,
modifier = Modifier.weight(1f),
)
@ -482,11 +639,9 @@ fun MarkdownText(text: String, streaming: Boolean = false, modifier: Modifier =
is MdBlock.Paragraph -> {
if (idx > 0) Spacer(Modifier.height(12.dp))
Text(
text = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg),
color = cs.onBackground,
fontSize = 16.sp,
lineHeight = 26.sp,
val rendered = inlineMarkdown(block.text + if (isLast) cursor else "", cs.onBackground, inlineCodeColor, inlineCodeBg, linkColor)
ClickableMarkdownText(rendered, context, cs.onBackground,
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
private fun CopyButton(code: String) {
val clipboard = LocalClipboard.current