diff --git a/app/src/main/java/dev/kaizen/app/chat/Markdown.kt b/app/src/main/java/dev/kaizen/app/chat/Markdown.kt index a4d36b4..f11fd15 100644 --- a/app/src/main/java/dev/kaizen/app/chat/Markdown.kt +++ b/app/src/main/java/dev/kaizen/app/chat/Markdown.kt @@ -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, val rows: List>) : 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 { val blocks = mutableListOf() @@ -67,100 +97,101 @@ private fun parseBlocks(src: String): List { 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() 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>() + 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() 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() 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 { 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) == "", 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