git.iko.soy/opencode-android
on 0bc1eda help sign in
140 lines 5.7 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.semantics.Role
30import androidx.compose.ui.semantics.contentDescription
31import androidx.compose.ui.semantics.role
32import androidx.compose.ui.semantics.semantics
33import androidx.compose.ui.text.TextStyle
34import androidx.compose.ui.text.font.FontFamily
35import androidx.compose.ui.unit.dp
36import com.mikepenz.markdown.compose.components.MarkdownComponentModel
37import com.mikepenz.markdown.compose.components.markdownComponents
38import com.mikepenz.markdown.m3.Markdown
39import org.intellij.markdown.MarkdownElementTypes
40import org.intellij.markdown.ast.ASTNode
41import org.intellij.markdown.ast.getTextInNode
42import soy.iko.opencode.R
44/**
45 * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
46 * (commonmark-java under the hood). Keeps a local API so callers don't import the library directly.
47 * Long-press copies the raw markdown to the system clipboard. Code blocks/fences get an
48 * inline copy button.
49 */
50@OptIn(ExperimentalFoundationApi::class)
51@Composable
52fun MarkdownText(
53 markdown: String,
54 modifier: Modifier = Modifier,
55 style: TextStyle = MaterialTheme.typography.bodyLarge,
56) {
57 val context = LocalContext.current
58 Markdown(
59 content = markdown,
60 modifier = modifier.semantics(mergeDescendants = true) {
61 contentDescription = markdown.take(500)
62 role = Role.Button
63 }.combinedClickable(
64 onClick = {},
65 onLongClick = { copyToClipboard(context, markdown) },
66 ),
67 components = markdownComponents(
68 codeFence = { CodeWithCopy(it) },
69 codeBlock = { CodeWithCopy(it) },
70 ),
71 )
74/**
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.
77 */
78@Composable
79private 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)
83 Box(
84 modifier = Modifier
85 .fillMaxWidth()
86 .clip(RoundedCornerShape(8.dp))
87 .background(MaterialTheme.colorScheme.surfaceVariant),
88 ) {
89 Text(
90 text = code,
91 style = codeStyle,
92 modifier = Modifier
93 .horizontalScroll(rememberScrollState())
94 .fillMaxWidth()
95 .padding(start = 12.dp, top = 12.dp, end = 40.dp, bottom = 12.dp),
96 )
97 Surface(
98 color = MaterialTheme.colorScheme.surface,
99 shape = RoundedCornerShape(6.dp),
100 modifier = Modifier
101 .align(Alignment.TopEnd)
102 .padding(4.dp),
103 ) {
104 IconButton(
105 onClick = { copyToClipboard(context, "code", code) },
106 modifier = Modifier.size(28.dp),
107 ) {
108 Icon(
109 Icons.Filled.ContentCopy,
110 contentDescription = context.getString(R.string.copy),
111 modifier = Modifier.size(14.dp),
112 tint = MaterialTheme.colorScheme.onSurfaceVariant,
113 )
114 }
115 }
116 }
119/**
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.
122 */
123private 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)
131 }
132 return body.joinToString("\n").trimEnd()
135/** Copy [text] to the system clipboard and show a confirmation toast. */
136internal fun copyToClipboard(context: Context, label: String, text: String = label) {
137 val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
138 clipboard.setPrimaryClip(ClipData.newPlainText(label.take(40), text))
139 Toast.makeText(context, context.getString(R.string.copied), Toast.LENGTH_SHORT).show()