@@ -0,0 +1,321 @@
1+package soy.iko.opencode.ui.components
2+
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
30+
31+// --- Block model ------------------------------------------------------------
32+
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
40+}
41+
42+internal data class ListItem(val text: String, val depth: Int)
43+
44+// --- Block parser -----------------------------------------------------------
45+
46+private val headingRegex = Regex("^(#{1,6})\\s+(.+)$")
47+private val hrRegex = Regex("^([-*_])\\1{2,}$")
48+private val listRegex = Regex("^(\\s*)([-*+]|\\d+\\.)\\s+(.+)$")
49+
50+internal fun parseMarkdown(src: String): List<MdBlock> {
51+ val lines = src.split("\n")
52+ val blocks = mutableListOf<MdBlock>()
53+ var i = 0
54+ while (i < lines.size) {
55+ val line = lines[i]
56+ val trimmed = line.trim()
57+
58+ if (trimmed.isEmpty()) { i++; continue }
59+
60+ // Fenced code block
61+ if (trimmed.startsWith("```")) {
62+ val lang = trimmed.removePrefix("```").trim().takeIf { it.isNotEmpty() }
63+ val sb = StringBuilder()
64+ i++
65+ while (i < lines.size && !lines[i].trim().startsWith("```")) {
66+ sb.append(lines[i]).append('\n')
67+ i++
68+ }
69+ i++
70+ blocks.add(MdBlock.Code(lang, sb.toString().trimEnd('\n')))
71+ continue
72+ }
73+
74+ // Horizontal rule
75+ if (trimmed.matches(hrRegex)) { blocks.add(MdBlock.Hr); i++; continue }
76+
77+ // Heading
78+ headingRegex.matchEntire(trimmed)?.let { m ->
79+ blocks.add(MdBlock.Heading(m.groupValues[1].length, m.groupValues[2].trim()))
80+ i++
81+ return@let
82+ } ?: run {
83+ // Blockquote
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())
88+ i++
89+ }
90+ blocks.add(MdBlock.Quote(sb.toString().trim()))
91+ return@run
92+ }
93+
94+ // List
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) {
99+ val l = lines[i]
100+ val lm = listRegex.matchEntire(l)
101+ if (lm != null) {
102+ val depth = (lm.groupValues[1].length / 2).coerceAtMost(4)
103+ items.add(ListItem(lm.groupValues[3].trim(), depth))
104+ i++
105+ } else if (l.isBlank()) {
106+ i++
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())
110+ }
111+ i++
112+ } else {
113+ break
114+ }
115+ }
116+ blocks.add(MdBlock.ListBlock(ordered, items))
117+ return@run
118+ }
119+
120+ // Paragraph
121+ val sb = StringBuilder(trimmed)
122+ i++
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
129+ ) {
130+ sb.append('\n').append(lines[i].trim())
131+ i++
132+ }
133+ blocks.add(MdBlock.Paragraph(sb.toString()))
134+ }
135+ }
136+ return blocks
137+}
138+
139+// --- Inline parser ----------------------------------------------------------
140+
141+private data class InlineColors(
142+ val link: androidx.compose.ui.graphics.Color,
143+ val codeBg: androidx.compose.ui.graphics.Color,
144+)
145+
146+private fun renderInline(text: String, colors: InlineColors): AnnotatedString = buildAnnotatedString {
147+ var i = 0
148+
149+ fun pushText(s: String) { append(s) }
150+
151+ fun findClosing(start: Int, delim: String): Int {
152+ val idx = text.indexOf(delim, start)
153+ return if (idx >= 0) idx else -1
154+ }
155+
156+ while (i < text.length) {
157+ // Inline code
158+ if (text.startsWith("`", i)) {
159+ val end = findClosing(i + 1, "`")
160+ if (end > i) {
161+ pushStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = colors.codeBg))
162+ pushText(text.substring(i + 1, end))
163+ pop()
164+ i = end + 1
165+ continue
166+ }
167+ }
168+ // Bold
169+ if (text.startsWith("**", i)) {
170+ val end = findClosing(i + 2, "**")
171+ if (end > i) {
172+ pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
173+ pushText(text.substring(i + 2, end))
174+ pop()
175+ i = end + 2
176+ continue
177+ }
178+ }
179+ // Strikethrough
180+ if (text.startsWith("~~", i)) {
181+ val end = findClosing(i + 2, "~~")
182+ if (end > i) {
183+ pushStyle(SpanStyle(textDecoration = TextDecoration.LineThrough))
184+ pushText(text.substring(i + 2, end))
185+ pop()
186+ i = end + 2
187+ continue
188+ }
189+ }
190+ // Italic (*text*)
191+ if (text[i] == '*' && (i + 1 >= text.length || text[i + 1] != '*')) {
192+ val end = text.indexOf('*', i + 1)
193+ if (end > i + 1) {
194+ pushStyle(SpanStyle(fontStyle = FontStyle.Italic))
195+ pushText(text.substring(i + 1, end))
196+ pop()
197+ i = end + 1
198+ continue
199+ }
200+ }
201+ // Link [text](url)
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))
209+ pushText(label)
210+ pop()
211+ i = closeParen + 1
212+ continue
213+ }
214+ }
215+ }
216+ pushText(text[i].toString())
217+ i++
218+ }
219+}
220+
221+// --- Composable -------------------------------------------------------------
222+
223+/**
224+ * Renders a markdown string with headings, code blocks, lists, blockquotes,
225+ * inline bold/italic/code/links, and horizontal rules.
226+ */
227+@Composable
228+fun MarkdownText(
229+ markdown: String,
230+ modifier: Modifier = Modifier,
231+ style: TextStyle = MaterialTheme.typography.bodyLarge,
232+) {
233+ val blocks = remember(markdown) { parseMarkdown(markdown) }
234+ val colors = InlineColors(
235+ link = MaterialTheme.colorScheme.primary,
236+ codeBg = MaterialTheme.colorScheme.surfaceVariant,
237+ )
238+ Column(modifier = modifier.fillMaxWidth()) {
239+ for (block in blocks) {
240+ when (block) {
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
248+ },
249+ modifier = Modifier.padding(top = if (block.level <= 2) 8.dp else 4.dp, bottom = 2.dp),
250+ )
251+
252+ is MdBlock.Code -> CodeBlock(block.code)
253+
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)) {
258+ Text(
259+ if (block.ordered) "${index + 1}. " else "• ",
260+ style = style,
261+ )
262+ Text(
263+ renderInline(item.text, colors),
264+ style = style,
265+ modifier = Modifier.weight(1f),
266+ )
267+ }
268+ }
269+ }
270+ }
271+
272+ is MdBlock.Quote -> {
273+ val quoteColor = MaterialTheme.colorScheme.onSurfaceVariant
274+ Text(
275+ text = renderInline(block.text, colors),
276+ style = style.copy(fontStyle = FontStyle.Italic),
277+ modifier = Modifier
278+ .fillMaxWidth()
279+ .drawBehind {
280+ drawLine(
281+ quoteColor.copy(alpha = 0.4f),
282+ Offset(0f, 0f),
283+ Offset(0f, size.height),
284+ strokeWidth = 3.dp.toPx(),
285+ )
286+ }
287+ .padding(start = 12.dp, top = 2.dp, bottom = 2.dp),
288+ color = quoteColor,
289+ )
290+ }
291+
292+ is MdBlock.Paragraph -> Text(
293+ text = renderInline(block.text, colors),
294+ style = style,
295+ modifier = Modifier.padding(bottom = 4.dp),
296+ )
297+
298+ MdBlock.Hr -> HorizontalDivider(modifier = Modifier.padding(vertical = 6.dp))
299+ }
300+ }
301+ }
302+}
303+
304+@Composable
305+private fun CodeBlock(code: String) {
306+ val bg = MaterialTheme.colorScheme.surfaceVariant
307+ Column(
308+ modifier = Modifier
309+ .fillMaxWidth()
310+ .padding(vertical = 4.dp)
311+ .clip(RoundedCornerShape(8.dp))
312+ .drawBehind { drawRect(bg) }
313+ .padding(10.dp)
314+ .horizontalScroll(rememberScrollState()),
315+ ) {
316+ Text(
317+ text = code,
318+ style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
319+ )
320+ }
321+}