@@ -1,228 +1,14 @@
1package soy.iko.opencode.ui.components
1package 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
13import androidx.compose.material3.MaterialTheme
3import androidx.compose.material3.MaterialTheme
14−import androidx.compose.material3.Text
15import androidx.compose.runtime.Composable
4import androidx.compose.runtime.Composable
16−import androidx.compose.runtime.remember
17import androidx.compose.ui.Modifier
5import 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
23import androidx.compose.ui.text.TextStyle
6import androidx.compose.ui.text.TextStyle
24−import androidx.compose.ui.text.buildAnnotatedString
7+import com.mikepenz.markdown.m3.Markdown
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,
10+ * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
225− * inline bold/italic/code/links, and horizontal rules.
11+ * (commonmark-java under the hood). Keeps a local API so callers don't import the library directly.
227@Composable
13@Composable
228fun MarkdownText(
14fun MarkdownText(
@@ -230,92 +16,8 @@ fun MarkdownText(
230 modifier: Modifier = Modifier,
16 modifier: Modifier = Modifier,
231 style: TextStyle = MaterialTheme.typography.bodyLarge,
17 style: TextStyle = MaterialTheme.typography.bodyLarge,
233− val blocks = remember(markdown) { parseMarkdown(markdown) }
19+ Markdown(
234− val colors = InlineColors(
20+ content = markdown,
235− link = MaterialTheme.colorScheme.primary,
21+ modifier = modifier,
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),