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