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:
parent
70aa5c56da
commit
fd0de5ccef
1 changed files with 294 additions and 91 deletions
|
|
@ -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)
|
||||||
i++
|
if (taskMatch != null) {
|
||||||
} else break
|
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))
|
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,23 +511,88 @@ 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)) {
|
||||||
val bullet = if (item.ordered) "${item.index}." else "•"
|
if (item.checked != null) {
|
||||||
Text(
|
val checkColor = if (item.checked) linkColor else cs.onSurfaceVariant.copy(alpha = 0.4f)
|
||||||
bullet,
|
val checkIcon = if (item.checked) KaizenIcons.Check else KaizenIcons.Square
|
||||||
color = cs.onSurfaceVariant,
|
Icon(checkIcon, null, tint = checkColor, modifier = Modifier.size(18.dp).padding(top = 3.dp))
|
||||||
fontSize = 16.sp,
|
Spacer(Modifier.width(6.dp))
|
||||||
modifier = Modifier.width(if (item.ordered) 24.dp else 16.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
|
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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue