git.iko.soy/opencode-android
on 0a26f8d help sign in
308 lines 16 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.background
8import androidx.compose.foundation.horizontalScroll
9import androidx.compose.foundation.layout.Arrangement
10import androidx.compose.foundation.layout.Box
11import androidx.compose.foundation.layout.Column
12import androidx.compose.foundation.layout.Row
13import androidx.compose.foundation.layout.fillMaxWidth
14import androidx.compose.foundation.layout.padding
15import androidx.compose.foundation.layout.size
16import androidx.compose.foundation.rememberScrollState
17import androidx.compose.foundation.text.selection.SelectionContainer
18import androidx.compose.material.icons.Icons
19import androidx.compose.material.icons.filled.ContentCopy
20import androidx.compose.material.icons.automirrored.filled.WrapText
21import androidx.compose.material3.Icon
22import androidx.compose.material3.IconButton
23import androidx.compose.material3.MaterialTheme
24import androidx.compose.material3.Surface
25import androidx.compose.material3.Text
26import androidx.compose.material3.Typography
27import androidx.compose.runtime.Composable
28import androidx.compose.runtime.LaunchedEffect
29import androidx.compose.runtime.getValue
30import androidx.compose.runtime.mutableStateOf
31import androidx.compose.runtime.remember
32import androidx.compose.runtime.rememberUpdatedState
33import androidx.compose.runtime.setValue
34import androidx.compose.runtime.snapshotFlow
35import androidx.compose.ui.Alignment
36import androidx.compose.ui.Modifier
37import androidx.compose.ui.draw.clip
38import androidx.compose.ui.platform.LocalContext
39import androidx.compose.ui.text.TextStyle
40import androidx.compose.ui.text.font.FontFamily
41import androidx.compose.ui.unit.dp
42import kotlinx.coroutines.flow.conflate
43import kotlinx.coroutines.delay
44import com.mikepenz.markdown.compose.components.MarkdownComponentModel
45import com.mikepenz.markdown.compose.components.markdownComponents
46import com.mikepenz.markdown.m3.Markdown
47import org.intellij.markdown.MarkdownElementTypes
48import org.intellij.markdown.ast.getTextInNode
49import soy.iko.opencode.R
50import soy.iko.opencode.data.network.NetworkConfig
52/**
53 * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
54 * (commonmark-java under the hood). Keeps a local API so callers don't import the library directly.
55 * Code blocks/fences get an inline copy button.
56 *
57 * The rendered text is wrapped in a [SelectionContainer] so the user can select and
58 * copy a portion of the response (e.g. a single code snippet or paragraph) instead of
59 * the all-or-nothing long-press copy. The per-message copy button in [MessageBubble]
60 * copies all TextParts; this complements it with partial selection.
61 *
62 * During streaming, the full markdown is re-parsed on every token (the library re-parses
63 * whenever the content string changes). To avoid O(n²) work during long responses, the
64 * rendered content is throttled — the latest [markdown] is committed to the renderer at
65 * most once every ~50ms, so a burst of tokens coalesces into a single re-parse.
66 *
67 * The throttle uses a single long-lived `LaunchedEffect(Unit)` that observes the markdown
68 * parameter via `rememberUpdatedState` + `snapshotFlow` + `conflate` + `collect`. A keyed
69 * effect (`LaunchedEffect(markdown)`) cancels and restarts on every token; if tokens arrive
70 * faster than the throttle delay, the in-flight `delay` is cancelled before it completes,
71 * starving the render and showing stale content until the stream pauses. The conflated
72 * `collect` lets the delay run to completion, then picks up the most recent buffered value,
73 * so rendering always progresses even under continuous fast streaming.
74 *
75 * When [streaming] is false (the common case: every non-active message in the chat list),
76 * the throttle pipeline is skipped entirely — no `LaunchedEffect`, no `snapshotFlow`, no
77 * coroutine. This eliminates per-item coroutine churn as messages scroll in and out of view.
78 */
79@Composable
80fun MarkdownText(
81 markdown: String,
82 modifier: Modifier = Modifier,
83 @Suppress("UNUSED_PARAMETER") style: TextStyle = MaterialTheme.typography.bodyLarge,
84 streaming: Boolean = false,
85) {
86 val scale = LocalChatTextScale.current
87 if (scale == 1f) {
88 MarkdownBody(markdown, modifier, streaming)
89 } else {
90 // Scale the entire markdown subtree (headings, lists, code, body) from the user's
91 // chat text-size preference by nesting a MaterialTheme with a scaled typography:
92 // the renderer derives its typography from MaterialTheme, so this reaches every
93 // element without plumbing a scale through the library. colorScheme/shapes default
94 // to the current theme's, so only sizes change. Memoized so a recomposition that
95 // doesn't change the scale doesn't rebuild the 15-role Typography.
96 val base = MaterialTheme.typography
97 val scaled = remember(scale, base) { base.scaledBy(scale) }
98 MaterialTheme(typography = scaled) {
99 MarkdownBody(markdown, modifier, streaming)
100 }
101 }
104@Composable
105private fun MarkdownBody(
106 markdown: String,
107 modifier: Modifier = Modifier,
108 streaming: Boolean = false,
109) {
110 val components = remember {
111 markdownComponents(
112 codeFence = { CodeWithCopy(it) },
113 codeBlock = { CodeWithCopy(it) },
114 )
115 }
116 if (streaming) {
117 // Bridge the markdown parameter into snapshot state so snapshotFlow can observe it.
118 val markdownState = rememberUpdatedState(markdown)
119 var renderedContent by remember { mutableStateOf(markdown) }
120 // A SINGLE long-lived effect (keyed on Unit), per this file's design. Keying on the
121 // content — even a prefix like markdown.take(32) — cancels and restarts the effect as the
122 // first characters stream in (the prefix changes on nearly every early token), so the
123 // in-flight delay never completes and the throttle is defeated at the start of every
124 // response. A streaming MarkdownBody instance only ever grows its content (a different
125 // message is a fresh composition with its own remember), so no reset key is needed.
126 // conflate() coalesces a burst of token updates into a single emission so the collector
127 // sees only the latest value after the previous delay completes. Plain collect (not
128 // collectLatest) is critical: collectLatest would cancel the delay on every new value,
129 // reintroducing the same starvation a keyed effect has.
130 LaunchedEffect(Unit) {
131 snapshotFlow { markdownState.value }
132 .conflate()
133 .collect { md ->
134 // Only throttle when the content is growing incrementally (streaming): a
135 // shorter switch or initial render proceeds immediately. Checking length is
136 // O(1) vs. startsWith which is O(n) in the rendered content length — during a
137 // long streaming response this runs every throttle cycle. Within one streaming
138 // composition, growing length means appending (streaming), not a rewrite.
139 if (renderedContent.isNotEmpty() &&
140 md.length > renderedContent.length
141 ) {
142 delay(NetworkConfig.streamingThrottleMs)
143 }
144 renderedContent = md
145 }
146 }
147 // SelectionContainer is intentionally omitted during streaming: the content is
148 // changing every ~50ms, and an active selection would be invalidated (and the
149 // selection handles would flicker) on each throttle commit. Partial selection
150 // becomes available once the stream finishes; the per-message Copy button in
151 // MessageBubble copies all TextParts at any time.
152 Markdown(
153 content = renderedContent,
154 modifier = modifier,
155 components = components,
156 )
157 } else {
158 SelectionContainer {
159 Markdown(
160 content = markdown,
161 modifier = modifier,
162 components = components,
163 )
164 }
165 }
168/**
169 * Renders a fenced or indented code block with a header toolbar: the fence's language
170 * label (when present), a wrap toggle (soft-wrap long lines vs. horizontal scroll), and a
171 * copy button. Self-contained so it doesn't depend on the library's internal code-block
172 * composables. The wrap toggle starts from the user's [LocalCodeWrap] preference but can
173 * be flipped per-block.
174 */
175@Composable
176private fun CodeWithCopy(model: MarkdownComponentModel) {
177 val context = LocalContext.current
178 // Key on the content string + the AST node's offset range (stable ints) instead of
179 // the model itself. MarkdownComponentModel is not @Immutable/@Stable (it holds an
180 // ASTNode), so remembering on it re-executes extract* on every recomposition.
181 val node = model.node
182 val isFenced = node.type == MarkdownElementTypes.CODE_FENCE
183 val raw = remember(model.content, node.startOffset, node.endOffset) {
184 node.getTextInNode(model.content).toString()
185 }
186 val code = remember(raw, isFenced) { extractCodeText(raw, isFenced) }
187 val language = remember(raw, isFenced) { extractFenceLanguage(raw, isFenced) }
188 val codeStyle = model.typography.code.copy(fontFamily = FontFamily.Monospace)
189 // Default per-block wrap from the global preference; re-seed if the preference changes
190 // while this block is composed (rememberSaveable would over-persist across blocks).
191 val defaultWrap = LocalCodeWrap.current
192 var wrap by remember(defaultWrap) { mutableStateOf(defaultWrap) }
193 Column(
194 modifier = Modifier
195 .fillMaxWidth()
196 .clip(MaterialTheme.shapes.small)
197 .background(MaterialTheme.colorScheme.surfaceVariant),
198 ) {
199 Row(
200 modifier = Modifier
201 .fillMaxWidth()
202 .padding(start = 12.dp, end = 2.dp),
203 verticalAlignment = Alignment.CenterVertically,
204 ) {
205 Text(
206 language ?: context.getString(R.string.code),
207 style = MaterialTheme.typography.labelSmall,
208 color = MaterialTheme.colorScheme.onSurfaceVariant,
209 modifier = Modifier.weight(1f),
210 )
211 val wrapLabel = context.getString(if (wrap) R.string.code_no_wrap else R.string.code_wrap)
212 IconButton(onClick = { wrap = !wrap }, modifier = Modifier.size(36.dp)) {
213 Icon(
214 Icons.AutoMirrored.Filled.WrapText,
215 contentDescription = wrapLabel,
216 modifier = Modifier.size(16.dp),
217 tint = if (wrap) MaterialTheme.colorScheme.primary
218 else MaterialTheme.colorScheme.onSurfaceVariant,
219 )
220 }
221 IconButton(
222 onClick = { copyToClipboard(context, "code", code) },
223 modifier = Modifier.size(36.dp),
224 ) {
225 Icon(
226 Icons.Filled.ContentCopy,
227 contentDescription = context.getString(R.string.copy),
228 modifier = Modifier.size(14.dp),
229 tint = MaterialTheme.colorScheme.onSurfaceVariant,
230 )
231 }
232 }
233 // Wrap → let the Text wrap naturally (no horizontal scroll). No-wrap → horizontal
234 // scroll so long lines keep their formatting. Selectable so a portion can be copied.
235 SelectionContainer {
236 Text(
237 text = code,
238 style = codeStyle,
239 modifier = if (wrap) {
240 Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = 12.dp)
241 } else {
242 Modifier
243 .horizontalScroll(rememberScrollState())
244 .fillMaxWidth()
245 .padding(start = 12.dp, end = 12.dp, bottom = 12.dp)
246 },
247 )
248 }
249 }
252/**
253 * Extract the fence language tag (e.g. "kotlin" from ```kotlin) so the code block can
254 * label itself. Returns null for indented blocks or fences with no info string. Extracted
255 * as a pure function for testability.
256 */
257internal fun extractFenceLanguage(raw: String, isFenced: Boolean): String? {
258 if (!isFenced) return null
259 val first = raw.lineSequence().firstOrNull()?.trim() ?: return null
260 // Strip the whole opening fence run (CommonMark allows 3+ backticks or tildes, e.g.
261 // ````kotlin), not a fixed 3 chars, before reading the info string.
262 val info = first.trimStart('`', '~').trim()
263 // A fence info string can carry more than the language (e.g. "```ts title=foo"); the
264 // first whitespace-delimited token is the language. Split on ANY whitespace (CommonMark
265 // ends the language at the first whitespace char), not just a space, so a tab-delimited
266 // info string ("```ts\ttitle=foo") still yields "ts" rather than the whole run.
267 return info.takeWhile { !it.isWhitespace() }.takeIf { it.isNotBlank() }
270/**
271 * Strip fence markers (```/~~~ and the language tag) from a raw code node text so
272 * only the code content is returned. Extracted as a pure function for testability.
273 *
274 * @param raw the raw text of the code node (including fence markers for fenced blocks)
275 * @param isFenced true for CODE_FENCE nodes, false for indented CODE_BLOCK nodes
276 */
277internal fun extractCodeText(raw: String, isFenced: Boolean): String {
278 if (!isFenced) return raw.trimIndent()
279 val body = raw.lines().drop(1).toMutableList()
280 // Strip the closing fence. getTextInNode can include a trailing newline, making the true last
281 // line "" — so locate the last NON-blank line and drop from there, rather than only checking
282 // body.last() (which would miss the fence in that case and leak the closing ```/~~~ into the
283 // displayed/copied code).
284 val lastNonBlank = body.indexOfLast { it.isNotBlank() }
285 // CommonMark allows the closing fence to be indented up to 3 spaces, and a fence nested inside
286 // a list item keeps that indentation in the node's raw text — so trim leading whitespace before
287 // the marker check. Without it, an indented closing ```/~~~ fails startsWith and leaks into the
288 // displayed/copied code (the same leak a prior fix closed for the trailing-newline case).
289 val closer = if (lastNonBlank >= 0) body[lastNonBlank].trimStart() else ""
290 if (lastNonBlank >= 0 && (closer.startsWith("```") || closer.startsWith("~~~"))) {
291 while (body.size > lastNonBlank) body.removeAt(body.lastIndex)
292 }
293 return body.joinToString("\n").trimEnd()
296/** Copy [text] to the system clipboard and show a confirmation toast. */
297internal fun copyToClipboard(context: Context, label: String, text: String = label) {
298 val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
299 clipboard.setPrimaryClip(ClipData.newPlainText(label.take(40), text))
300 showToast(context, context.getString(R.string.copied))
303private var lastToast: Toast? = null
305internal fun showToast(context: Context, message: String) {
306 lastToast?.cancel()
307 lastToast = Toast.makeText(context.applicationContext, message, Toast.LENGTH_SHORT).also { it.show() }