@@ -1,228 +1,14 @@
11package soy.iko.opencode.ui.components
22
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
133import androidx.compose.material3.MaterialTheme
14−import androidx.compose.material3.Text
154import androidx.compose.runtime.Composable
16−import androidx.compose.runtime.remember
175import 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
236import 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 -------------------------------------------------------------
7+import com.mikepenz.markdown.m3.Markdown
2228
2239/**
224− * Renders a markdown string with headings, code blocks, lists, blockquotes,
225− * inline bold/italic/code/links, and horizontal rules.
10+ * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
11+ * (commonmark-java under the hood). Keeps a local API so callers don't import the library directly.
22612 */
22713@Composable
22814fun MarkdownText(
@@ -230,92 +16,8 @@ fun MarkdownText(
23016 modifier: Modifier = Modifier,
23117 style: TextStyle = MaterialTheme.typography.bodyLarge,
23218) {
233− val blocks = remember(markdown) { parseMarkdown(markdown) }
234− val colors = InlineColors(
235− link = MaterialTheme.colorScheme.primary,
236− codeBg = MaterialTheme.colorScheme.surfaceVariant,
19+ Markdown(
20+ content = markdown,
21+ modifier = modifier,
23722 )
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− }
32123}