@@ -5,23 +5,47 @@ import android.content.ClipboardManager
5import android.content.Context
5import android.content.Context
6import android.widget.Toast
6import android.widget.Toast
7import androidx.compose.foundation.ExperimentalFoundationApi
7import androidx.compose.foundation.ExperimentalFoundationApi
8+import androidx.compose.foundation.background
8import androidx.compose.foundation.combinedClickable
9import androidx.compose.foundation.combinedClickable
10+import androidx.compose.foundation.horizontalScroll
11+import androidx.compose.foundation.layout.Box
12+import androidx.compose.foundation.layout.fillMaxWidth
13+import androidx.compose.foundation.layout.padding
14+import androidx.compose.foundation.layout.size
15+import androidx.compose.foundation.rememberScrollState
16+import androidx.compose.foundation.shape.RoundedCornerShape
17+import androidx.compose.material.icons.Icons
18+import androidx.compose.material.icons.filled.ContentCopy
19+import androidx.compose.material3.Icon
20+import androidx.compose.material3.IconButton
9import androidx.compose.material3.MaterialTheme
21import androidx.compose.material3.MaterialTheme
22+import androidx.compose.material3.Surface
23+import androidx.compose.material3.Text
10import androidx.compose.runtime.Composable
24import androidx.compose.runtime.Composable
25+import androidx.compose.ui.Alignment
11import androidx.compose.ui.Modifier
26import androidx.compose.ui.Modifier
27+import androidx.compose.ui.draw.clip
12import androidx.compose.ui.platform.LocalContext
28import androidx.compose.ui.platform.LocalContext
13import androidx.compose.ui.semantics.Role
29import androidx.compose.ui.semantics.Role
14import androidx.compose.ui.semantics.contentDescription
30import androidx.compose.ui.semantics.contentDescription
15import androidx.compose.ui.semantics.role
31import androidx.compose.ui.semantics.role
16import androidx.compose.ui.semantics.semantics
32import androidx.compose.ui.semantics.semantics
17import androidx.compose.ui.text.TextStyle
33import androidx.compose.ui.text.TextStyle
34+import androidx.compose.ui.text.font.FontFamily
35+import androidx.compose.ui.unit.dp
36+import com.mikepenz.markdown.compose.components.MarkdownComponentModel
37+import com.mikepenz.markdown.compose.components.markdownComponents
18import com.mikepenz.markdown.m3.Markdown
38import com.mikepenz.markdown.m3.Markdown
39+import org.intellij.markdown.MarkdownElementTypes
40+import org.intellij.markdown.ast.ASTNode
41+import org.intellij.markdown.ast.getTextInNode
19import soy.iko.opencode.R
42import soy.iko.opencode.R
22 * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
45 * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
23 * (commonmark-java under the hood). Keeps a local API so callers don't import the library directly.
46 * (commonmark-java under the hood). Keeps a local API so callers don't import the library directly.
24− * Long-press copies the raw markdown to the system clipboard.
47+ * Long-press copies the raw markdown to the system clipboard. Code blocks/fences get an
48+ * inline copy button.
26@OptIn(ExperimentalFoundationApi::class)
50@OptIn(ExperimentalFoundationApi::class)
27@Composable
51@Composable
@@ -31,7 +55,6 @@ fun MarkdownText(
31 style: TextStyle = MaterialTheme.typography.bodyLarge,
55 style: TextStyle = MaterialTheme.typography.bodyLarge,
33 val context = LocalContext.current
57 val context = LocalContext.current
34− val copyLabel = context.getString(R.string.copy)
36 content = markdown,
59 content = markdown,
37 modifier = modifier.semantics(mergeDescendants = true) {
60 modifier = modifier.semantics(mergeDescendants = true) {
@@ -41,9 +64,74 @@ fun MarkdownText(
41 onClick = {},
64 onClick = {},
42 onLongClick = { copyToClipboard(context, markdown) },
65 onLongClick = { copyToClipboard(context, markdown) },
67+ components = markdownComponents(
68+ codeFence = { CodeWithCopy(it) },
69+ codeBlock = { CodeWithCopy(it) },
75+ * Renders a fenced or indented code block with a small copy button in the top-right corner.
76+ * Self-contained so it doesn't depend on the library's internal code-block composables.
79+private fun CodeWithCopy(model: MarkdownComponentModel) {
80+ val context = LocalContext.current
81+ val code = androidx.compose.runtime.remember(model) { extractCode(model.content, model.node) }
82+ val codeStyle = model.typography.code.copy(fontFamily = FontFamily.Monospace)
86+ .clip(RoundedCornerShape(8.dp))
87+ .background(MaterialTheme.colorScheme.surfaceVariant),
93+ .horizontalScroll(rememberScrollState())
95+ .padding(start = 12.dp, top = 12.dp, end = 40.dp, bottom = 12.dp),
98+ color = MaterialTheme.colorScheme.surface,
99+ shape = RoundedCornerShape(6.dp),
101+ .align(Alignment.TopEnd)
105+ onClick = { copyToClipboard(context, "code", code) },
106+ modifier = Modifier.size(28.dp),
109+ Icons.Filled.ContentCopy,
110+ contentDescription = context.getString(R.string.copy),
111+ modifier = Modifier.size(14.dp),
112+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
120+ * Extract the raw code text from a markdown code node, stripping fence markers
121+ * (```/~~~ and the language tag) so only the code content is copied.
123+private fun extractCode(content: String, node: ASTNode): String {
124+ val raw = node.getTextInNode(content).toString()
125+ val isOpenFence = node.type == MarkdownElementTypes.CODE_FENCE
126+ if (!isOpenFence) return raw.trimIndent()
127+ val lines = raw.lines()
128+ val body = lines.drop(1).toMutableList()
129+ if (body.isNotEmpty() && (body.last().startsWith("```") || body.last().startsWith("~~~"))) {
130+ body.removeAt(body.lastIndex)
132+ return body.joinToString("\n").trimEnd()
47/** Copy [text] to the system clipboard and show a confirmation toast. */
135/** Copy [text] to the system clipboard and show a confirmation toast. */
48internal fun copyToClipboard(context: Context, label: String, text: String = label) {
136internal fun copyToClipboard(context: Context, label: String, text: String = label) {
49 val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
137 val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return