@@ -0,0 +1,321 @@
1+package soy.iko.opencode.ui.components
3+import androidx.compose.foundation.horizontalScroll
4+import androidx.compose.foundation.layout.Column
5+import androidx.compose.foundation.layout.Row
6+import androidx.compose.foundation.layout.Spacer
7+import androidx.compose.foundation.layout.fillMaxWidth
8+import androidx.compose.foundation.layout.height
9+import androidx.compose.foundation.layout.padding
10+import androidx.compose.foundation.rememberScrollState
11+import androidx.compose.foundation.shape.RoundedCornerShape
12+import androidx.compose.material3.HorizontalDivider
13+import androidx.compose.material3.MaterialTheme
14+import androidx.compose.material3.Text
15+import androidx.compose.runtime.Composable
16+import androidx.compose.runtime.remember
17+import androidx.compose.ui.Modifier
18+import androidx.compose.ui.draw.clip
19+import androidx.compose.ui.draw.drawBehind
20+import androidx.compose.ui.geometry.Offset
21+import androidx.compose.ui.text.AnnotatedString
22+import androidx.compose.ui.text.SpanStyle
23+import androidx.compose.ui.text.TextStyle
24+import androidx.compose.ui.text.buildAnnotatedString
25+import androidx.compose.ui.text.font.FontFamily
26+import androidx.compose.ui.text.font.FontStyle
27+import androidx.compose.ui.text.font.FontWeight
28+import androidx.compose.ui.text.style.TextDecoration
29+import androidx.compose.ui.unit.dp
31+// --- Block model ------------------------------------------------------------
33+internal sealed interface MdBlock {
34+ data class Heading(val level: Int, val text: String) : MdBlock
35+ data class Code(val lang: String?, val code: String) : MdBlock
36+ data class ListBlock(val ordered: Boolean, val items: List<ListItem>) : MdBlock
37+ data class Quote(val text: String) : MdBlock
38+ data class Paragraph(val text: String) : MdBlock
39+ data object Hr : MdBlock
42+internal data class ListItem(val text: String, val depth: Int)
44+// --- Block parser -----------------------------------------------------------
46+private val headingRegex = Regex("^(#{1,6})\\s+(.+)$")
47+private val hrRegex = Regex("^([-*_])\\1{2,}$")
48+private val listRegex = Regex("^(\\s*)([-*+]|\\d+\\.)\\s+(.+)$")
50+internal fun parseMarkdown(src: String): List<MdBlock> {
51+ val lines = src.split("\n")
52+ val blocks = mutableListOf<MdBlock>()
54+ while (i < lines.size) {
56+ val trimmed = line.trim()
58+ if (trimmed.isEmpty()) { i++; continue }
61+ if (trimmed.startsWith("```")) {
62+ val lang = trimmed.removePrefix("```").trim().takeIf { it.isNotEmpty() }
63+ val sb = StringBuilder()
65+ while (i < lines.size && !lines[i].trim().startsWith("```")) {
66+ sb.append(lines[i]).append('\n')
70+ blocks.add(MdBlock.Code(lang, sb.toString().trimEnd('\n')))
75+ if (trimmed.matches(hrRegex)) { blocks.add(MdBlock.Hr); i++; continue }
78+ headingRegex.matchEntire(trimmed)?.let { m ->
79+ blocks.add(MdBlock.Heading(m.groupValues[1].length, m.groupValues[2].trim()))
84+ if (trimmed.startsWith(">")) {
85+ val sb = StringBuilder()
86+ while (i < lines.size && lines[i].trim().startsWith(">")) {
87+ sb.appendLine(lines[i].trim().removePrefix(">").trim())
90+ blocks.add(MdBlock.Quote(sb.toString().trim()))
95+ if (listRegex.matchEntire(trimmed) != null) {
96+ val items = mutableListOf<ListItem>()
97+ val ordered = trimmed.matches(Regex("^\\s*\\d+\\.\\s+.+"))
98+ while (i < lines.size) {
100+ val lm = listRegex.matchEntire(l)
102+ val depth = (lm.groupValues[1].length / 2).coerceAtMost(4)
103+ items.add(ListItem(lm.groupValues[3].trim(), depth))
105+ } else if (l.isBlank()) {
107+ } else if (l.startsWith(" ") || l.startsWith("\t")) {
108+ if (items.isNotEmpty()) {
109+ items[items.lastIndex] = items.last().copy(text = items.last().text + " " + l.trim())
116+ blocks.add(MdBlock.ListBlock(ordered, items))
121+ val sb = StringBuilder(trimmed)
123+ while (i < lines.size && lines[i].isNotBlank() &&
124+ !lines[i].trim().startsWith("```") &&
125+ !lines[i].trim().startsWith("#") &&
126+ !lines[i].trim().startsWith(">") &&
127+ !lines[i].trim().matches(hrRegex) &&
128+ listRegex.matchEntire(lines[i].trim()) == null
130+ sb.append('\n').append(lines[i].trim())
133+ blocks.add(MdBlock.Paragraph(sb.toString()))
139+// --- Inline parser ----------------------------------------------------------
141+private data class InlineColors(
142+ val link: androidx.compose.ui.graphics.Color,
143+ val codeBg: androidx.compose.ui.graphics.Color,
146+private fun renderInline(text: String, colors: InlineColors): AnnotatedString = buildAnnotatedString {
149+ fun pushText(s: String) { append(s) }
151+ fun findClosing(start: Int, delim: String): Int {
152+ val idx = text.indexOf(delim, start)
153+ return if (idx >= 0) idx else -1
156+ while (i < text.length) {
158+ if (text.startsWith("`", i)) {
159+ val end = findClosing(i + 1, "`")
161+ pushStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = colors.codeBg))
162+ pushText(text.substring(i + 1, end))
169+ if (text.startsWith("**", i)) {
170+ val end = findClosing(i + 2, "**")
172+ pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
173+ pushText(text.substring(i + 2, end))
180+ if (text.startsWith("~~", i)) {
181+ val end = findClosing(i + 2, "~~")
183+ pushStyle(SpanStyle(textDecoration = TextDecoration.LineThrough))
184+ pushText(text.substring(i + 2, end))
191+ if (text[i] == '*' && (i + 1 >= text.length || text[i + 1] != '*')) {
192+ val end = text.indexOf('*', i + 1)
194+ pushStyle(SpanStyle(fontStyle = FontStyle.Italic))
195+ pushText(text.substring(i + 1, end))
202+ if (text[i] == '[') {
203+ val closeBracket = text.indexOf(']', i + 1)
204+ if (closeBracket > i && closeBracket + 1 < text.length && text[closeBracket + 1] == '(') {
205+ val closeParen = text.indexOf(')', closeBracket + 2)
206+ if (closeParen > closeBracket) {
207+ val label = text.substring(i + 1, closeBracket)
208+ pushStyle(SpanStyle(color = colors.link, textDecoration = TextDecoration.Underline))
216+ pushText(text[i].toString())
221+// --- Composable -------------------------------------------------------------
224+ * Renders a markdown string with headings, code blocks, lists, blockquotes,
225+ * inline bold/italic/code/links, and horizontal rules.
230+ modifier: Modifier = Modifier,
231+ style: TextStyle = MaterialTheme.typography.bodyLarge,
233+ val blocks = remember(markdown) { parseMarkdown(markdown) }
234+ val colors = InlineColors(
235+ link = MaterialTheme.colorScheme.primary,
236+ codeBg = MaterialTheme.colorScheme.surfaceVariant,
238+ Column(modifier = modifier.fillMaxWidth()) {
239+ for (block in blocks) {
241+ is MdBlock.Heading -> Text(
242+ text = renderInline(block.text, colors),
243+ style = when (block.level) {
244+ 1 -> MaterialTheme.typography.headlineSmall
245+ 2 -> MaterialTheme.typography.titleLarge
246+ 3 -> MaterialTheme.typography.titleMedium
247+ else -> MaterialTheme.typography.titleSmall
249+ modifier = Modifier.padding(top = if (block.level <= 2) 8.dp else 4.dp, bottom = 2.dp),
252+ is MdBlock.Code -> CodeBlock(block.code)
254+ is MdBlock.ListBlock -> {
255+ Column(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
256+ block.items.forEachIndexed { index, item ->
257+ Row(modifier = Modifier.padding(start = (item.depth * 12).dp, bottom = 2.dp)) {
259+ if (block.ordered) "${index + 1}. " else "• ",
263+ renderInline(item.text, colors),
265+ modifier = Modifier.weight(1f),
272+ is MdBlock.Quote -> {
273+ val quoteColor = MaterialTheme.colorScheme.onSurfaceVariant
275+ text = renderInline(block.text, colors),
276+ style = style.copy(fontStyle = FontStyle.Italic),
281+ quoteColor.copy(alpha = 0.4f),
283+ Offset(0f, size.height),
284+ strokeWidth = 3.dp.toPx(),
287+ .padding(start = 12.dp, top = 2.dp, bottom = 2.dp),
292+ is MdBlock.Paragraph -> Text(
293+ text = renderInline(block.text, colors),
295+ modifier = Modifier.padding(bottom = 4.dp),
298+ MdBlock.Hr -> HorizontalDivider(modifier = Modifier.padding(vertical = 6.dp))
305+private fun CodeBlock(code: String) {
306+ val bg = MaterialTheme.colorScheme.surfaceVariant
310+ .padding(vertical = 4.dp)
311+ .clip(RoundedCornerShape(8.dp))
312+ .drawBehind { drawRect(bg) }
314+ .horizontalScroll(rememberScrollState()),
318+ style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),