git.iko.soy/opencode-android
on 325e359 help sign in
203 lines 9.1 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.runtime.LaunchedEffect
26import androidx.compose.runtime.getValue
27import androidx.compose.runtime.mutableStateOf
28import androidx.compose.runtime.remember
29import androidx.compose.runtime.rememberUpdatedState
30import androidx.compose.runtime.setValue
31import androidx.compose.runtime.snapshotFlow
32import androidx.compose.ui.Alignment
33import androidx.compose.ui.Modifier
34import androidx.compose.ui.draw.clip
35import androidx.compose.ui.platform.LocalContext
36import androidx.compose.ui.semantics.Role
37import androidx.compose.ui.text.TextStyle
38import androidx.compose.ui.text.font.FontFamily
39import androidx.compose.ui.unit.dp
40import kotlinx.coroutines.flow.conflate
41import kotlinx.coroutines.delay
42import com.mikepenz.markdown.compose.components.MarkdownComponentModel
43import com.mikepenz.markdown.compose.components.markdownComponents
44import com.mikepenz.markdown.m3.Markdown
45import org.intellij.markdown.MarkdownElementTypes
46import org.intellij.markdown.ast.ASTNode
47import org.intellij.markdown.ast.getTextInNode
48import soy.iko.opencode.R
49import soy.iko.opencode.data.network.NetworkConfig
51/**
52 * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
53 * (commonmark-java under the hood). Keeps a local API so callers don't import the library directly.
54 * Long-press copies the raw markdown to the system clipboard. Code blocks/fences get an
55 * inline copy button.
56 *
57 * During streaming, the full markdown is re-parsed on every token (the library re-parses
58 * whenever the content string changes). To avoid O(n²) work during long responses, the
59 * rendered content is throttled — the latest [markdown] is committed to the renderer at
60 * most once per frame (~16ms), so a burst of tokens coalesces into a single re-parse.
61 *
62 * The throttle uses a single long-lived `LaunchedEffect(Unit)` that observes the markdown
63 * parameter via `rememberUpdatedState` + `snapshotFlow` + `conflate` + `collect`. A keyed
64 * effect (`LaunchedEffect(markdown)`) cancels and restarts on every token; if tokens arrive
65 * faster than the throttle delay, the in-flight `delay` is cancelled before it completes,
66 * starving the render and showing stale content until the stream pauses. The conflated
67 * `collect` lets the delay run to completion, then picks up the most recent buffered value,
68 * so rendering always progresses even under continuous fast streaming.
69 */
70@OptIn(ExperimentalFoundationApi::class)
71@Composable
72fun MarkdownText(
73 markdown: String,
74 modifier: Modifier = Modifier,
75 style: TextStyle = MaterialTheme.typography.bodyLarge,
76) {
77 val context = LocalContext.current
78 // Bridge the markdown parameter into snapshot state so snapshotFlow can observe it.
79 val markdownState = rememberUpdatedState(markdown)
80 // Keyed to a content-prefix so switching to a different message resets
81 // immediately instead of showing the old message for one frame.
82 var renderedContent by remember(markdown.take(32)) { mutableStateOf(markdown) }
83 // conflate() coalesces a burst of token updates into a single emission so the
84 // collector sees only the latest value after the previous delay completes. Plain
85 // collect (not collectLatest) is critical: collectLatest would cancel the delay
86 // on every new value, reintroducing the same starvation the keyed effect had.
87 LaunchedEffect(markdown.take(32)) {
88 snapshotFlow { markdownState.value }
89 .conflate()
90 .collect { md ->
91 // Only throttle when the content is growing incrementally (streaming):
92 // a content switch or initial render proceeds immediately.
93 if (renderedContent.isNotEmpty() &&
94 md.startsWith(renderedContent) &&
95 md != renderedContent
96 ) {
97 delay(NetworkConfig.streamingThrottleMs)
98 }
99 renderedContent = md
100 }
101 }
102 Markdown(
103 content = renderedContent,
104 modifier = modifier.combinedClickable(
105 onClick = {},
106 onLongClick = { copyToClipboard(context, markdown) },
107 role = Role.Button,
108 ),
109 components = markdownComponents(
110 codeFence = { CodeWithCopy(it) },
111 codeBlock = { CodeWithCopy(it) },
112 ),
113 )
116/**
117 * Renders a fenced or indented code block with a small copy button in the top-right corner.
118 * Self-contained so it doesn't depend on the library's internal code-block composables.
119 */
120@Composable
121private fun CodeWithCopy(model: MarkdownComponentModel) {
122 val context = LocalContext.current
123 // Key on the content string + the AST node's offset range (stable ints) instead of
124 // the model itself. MarkdownComponentModel is not @Immutable/@Stable (it holds an
125 // ASTNode), so remembering on it re-executes extractCode on every recomposition.
126 val node = model.node
127 val code = remember(model.content, node.startOffset, node.endOffset) {
128 extractCode(model.content, node)
129 }
130 val codeStyle = model.typography.code.copy(fontFamily = FontFamily.Monospace)
131 Box(
132 modifier = Modifier
133 .fillMaxWidth()
134 .clip(RoundedCornerShape(8.dp))
135 .background(MaterialTheme.colorScheme.surfaceVariant),
136 ) {
137 Text(
138 text = code,
139 style = codeStyle,
140 modifier = Modifier
141 .horizontalScroll(rememberScrollState())
142 .fillMaxWidth()
143 .padding(start = 12.dp, top = 12.dp, end = 40.dp, bottom = 12.dp),
144 )
145 Surface(
146 color = MaterialTheme.colorScheme.surface,
147 shape = RoundedCornerShape(6.dp),
148 modifier = Modifier
149 .align(Alignment.TopEnd)
150 .padding(4.dp),
151 ) {
152 IconButton(
153 onClick = { copyToClipboard(context, "code", code) },
154 modifier = Modifier.size(48.dp),
155 ) {
156 Icon(
157 Icons.Filled.ContentCopy,
158 contentDescription = context.getString(R.string.copy),
159 modifier = Modifier.size(14.dp),
160 tint = MaterialTheme.colorScheme.onSurfaceVariant,
161 )
162 }
163 }
164 }
167/**
168 * Extract the raw code text from a markdown code node, stripping fence markers
169 * (```/~~~ and the language tag) so only the code content is copied.
170 */
171private fun extractCode(content: String, node: ASTNode): String =
172 extractCodeText(node.getTextInNode(content).toString(), node.type == MarkdownElementTypes.CODE_FENCE)
174/**
175 * Strip fence markers (```/~~~ and the language tag) from a raw code node text so
176 * only the code content is returned. Extracted as a pure function for testability.
177 *
178 * @param raw the raw text of the code node (including fence markers for fenced blocks)
179 * @param isFenced true for CODE_FENCE nodes, false for indented CODE_BLOCK nodes
180 */
181internal fun extractCodeText(raw: String, isFenced: Boolean): String {
182 if (!isFenced) return raw.trimIndent()
183 val lines = raw.lines()
184 val body = lines.drop(1).toMutableList()
185 if (body.isNotEmpty() && (body.last().startsWith("```") || body.last().startsWith("~~~"))) {
186 body.removeAt(body.lastIndex)
187 }
188 return body.joinToString("\n").trimEnd()
191/** Copy [text] to the system clipboard and show a confirmation toast. */
192internal fun copyToClipboard(context: Context, label: String, text: String = label) {
193 val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
194 clipboard.setPrimaryClip(ClipData.newPlainText(label.take(40), text))
195 showToast(context, context.getString(R.string.copied))
198private var lastToast: Toast? = null
200internal fun showToast(context: Context, message: String) {
201 lastToast?.cancel()
202 lastToast = Toast.makeText(context, message, Toast.LENGTH_SHORT).also { it.show() }