@@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
22import androidx.compose.foundation.layout.padding
22import androidx.compose.foundation.layout.padding
23import androidx.compose.foundation.layout.size
23import androidx.compose.foundation.layout.size
24import androidx.compose.foundation.rememberScrollState
24import androidx.compose.foundation.rememberScrollState
25+import androidx.compose.foundation.text.BasicText
25import androidx.compose.foundation.text.selection.SelectionContainer
26import androidx.compose.foundation.text.selection.SelectionContainer
26import androidx.compose.material.icons.Icons
27import androidx.compose.material.icons.Icons
27import androidx.compose.material.icons.automirrored.filled.WrapText
28import androidx.compose.material.icons.automirrored.filled.WrapText
@@ -60,6 +61,7 @@ import androidx.compose.ui.platform.UriHandler
60import androidx.compose.ui.res.pluralStringResource
61import androidx.compose.ui.res.pluralStringResource
61import androidx.compose.ui.res.stringResource
62import androidx.compose.ui.res.stringResource
62import androidx.compose.ui.semantics.LiveRegionMode
63import androidx.compose.ui.semantics.LiveRegionMode
64+import androidx.compose.ui.semantics.heading
63import androidx.compose.ui.semantics.invisibleToUser
65import androidx.compose.ui.semantics.invisibleToUser
64import androidx.compose.ui.semantics.liveRegion
66import androidx.compose.ui.semantics.liveRegion
65import androidx.compose.ui.semantics.semantics
67import androidx.compose.ui.semantics.semantics
@@ -80,7 +82,6 @@ import com.mikepenz.markdown.compose.LocalMarkdownColors
80import com.mikepenz.markdown.compose.LocalMarkdownTypography
82import com.mikepenz.markdown.compose.LocalMarkdownTypography
81import com.mikepenz.markdown.compose.components.MarkdownComponentModel
83import com.mikepenz.markdown.compose.components.MarkdownComponentModel
82import com.mikepenz.markdown.compose.components.markdownComponents
84import com.mikepenz.markdown.compose.components.markdownComponents
83−import com.mikepenz.markdown.compose.elements.MarkdownText as LibraryMarkdownText
84import com.mikepenz.markdown.m3.Markdown
85import com.mikepenz.markdown.m3.Markdown
85import com.mikepenz.markdown.model.DefaultMarkdownAnnotator
86import com.mikepenz.markdown.model.DefaultMarkdownAnnotator
86import com.mikepenz.markdown.model.DefaultMarkdownAnnotatorConfig
87import com.mikepenz.markdown.model.DefaultMarkdownAnnotatorConfig
@@ -94,7 +95,7 @@ import soy.iko.opencode.data.model.FilePart
94import soy.iko.opencode.data.network.NetworkConfig
95import soy.iko.opencode.data.network.NetworkConfig
96/** String-annotation tag stamped onto each inline-code (`CODE_SPAN`) range by the inline-code
97/** String-annotation tag stamped onto each inline-code (`CODE_SPAN`) range by the inline-code
97− * annotator wrapper, so [ParagraphWithInlineCodeCopy]'s `pointerInput` can resolve a long-press
98+ * annotator wrapper, so [MarkdownTextNoAnim]'s `pointerInput` can resolve a long-press
98 * offset back to the code text and copy it. Mirrors the library's own `MARKDOWN_TAG_URL` pattern
99 * offset back to the code text and copy it. Mirrors the library's own `MARKDOWN_TAG_URL` pattern
99 * for link-tap hit-testing. */
100 * for link-tap hit-testing. */
100private const val MARKDOWN_TAG_INLINE_CODE = "MARKDOWN_INLINE_CODE"
101private const val MARKDOWN_TAG_INLINE_CODE = "MARKDOWN_INLINE_CODE"
@@ -164,22 +165,30 @@ private fun MarkdownBody(
164 // Without this the library's default image loader fetches with no auth and fails on
165 // Without this the library's default image loader fetches with no auth and fails on
165 // server-relative URLs. When no context is available, fall back to the library default
166 // server-relative URLs. When no context is available, fall back to the library default
166 // by not overriding the image component.
167 // by not overriding the image component.
167− // The `paragraph` component is overridden with [ParagraphWithInlineCodeCopy] so a long-press
168+ // The `paragraph` and `heading` components are overridden with [MarkdownTextNoAnim] so a long-press
168 // on a single-backtick inline code span copies its text — matching the copy affordance the
169 // on a single-backtick inline code span copies its text — matching the copy affordance the
169 // fenced/indented code blocks already have via [CodeWithCopy].
170 // fenced/indented code blocks already have via [CodeWithCopy].
170 val components = if (imageContext != null) {
171 val components = if (imageContext != null) {
171 // Remember on imageContext so a recomposition that doesn't change the context (the
172 // Remember on imageContext so a recomposition that doesn't change the context (the
172 // common case during streaming/scroll) doesn't rebuild the 21-field components
173 // common case during streaming/scroll) doesn't rebuild the 21-field components
173 // container + 4 lambdas. The lambdas capture `imageContext` (for the image component)
174 // container + 4 lambdas. The lambdas capture `imageContext` (for the image component)
174− // and the CodeWithCopy/ParagraphWithInlineCodeCopy factories (which only capture the
175+ // and the CodeWithCopy/MarkdownTextNoAnim factories (which only capture the
175 // stable MarkdownComponentModel passed by the library at invoke time), so keying on
176 // stable MarkdownComponentModel passed by the library at invoke time), so keying on
176 // imageContext is sufficient for stability.
177 // imageContext is sufficient for stability.
177 remember(imageContext) {
178 remember(imageContext) {
178 markdownComponents(
179 markdownComponents(
179 codeFence = { CodeWithCopy(it) },
180 codeFence = { CodeWithCopy(it) },
180 codeBlock = { CodeWithCopy(it) },
181 codeBlock = { CodeWithCopy(it) },
181− paragraph = { ParagraphWithInlineCodeCopy(it) },
182+ paragraph = { MarkdownTextNoAnim(it, it.typography.paragraph) },
182 image = { MarkdownImage(it, imageContext) },
183 image = { MarkdownImage(it, imageContext) },
184+ heading1 = { MarkdownTextNoAnim(it, it.typography.h1, Modifier.semantics { heading() }) },
185+ heading2 = { MarkdownTextNoAnim(it, it.typography.h2, Modifier.semantics { heading() }) },
186+ heading3 = { MarkdownTextNoAnim(it, it.typography.h3, Modifier.semantics { heading() }) },
187+ heading4 = { MarkdownTextNoAnim(it, it.typography.h4, Modifier.semantics { heading() }) },
188+ heading5 = { MarkdownTextNoAnim(it, it.typography.h5, Modifier.semantics { heading() }) },
189+ heading6 = { MarkdownTextNoAnim(it, it.typography.h6, Modifier.semantics { heading() }) },
190+ setextHeading1 = { MarkdownTextNoAnim(it, it.typography.h1, Modifier.semantics { heading() }) },
191+ setextHeading2 = { MarkdownTextNoAnim(it, it.typography.h2, Modifier.semantics { heading() }) },
@@ -187,7 +196,15 @@ private fun MarkdownBody(
187 markdownComponents(
196 markdownComponents(
188 codeFence = { CodeWithCopy(it) },
197 codeFence = { CodeWithCopy(it) },
189 codeBlock = { CodeWithCopy(it) },
198 codeBlock = { CodeWithCopy(it) },
190− paragraph = { ParagraphWithInlineCodeCopy(it) },
199+ paragraph = { MarkdownTextNoAnim(it, it.typography.paragraph) },
200+ heading1 = { MarkdownTextNoAnim(it, it.typography.h1, Modifier.semantics { heading() }) },
201+ heading2 = { MarkdownTextNoAnim(it, it.typography.h2, Modifier.semantics { heading() }) },
202+ heading3 = { MarkdownTextNoAnim(it, it.typography.h3, Modifier.semantics { heading() }) },
203+ heading4 = { MarkdownTextNoAnim(it, it.typography.h4, Modifier.semantics { heading() }) },
204+ heading5 = { MarkdownTextNoAnim(it, it.typography.h5, Modifier.semantics { heading() }) },
205+ heading6 = { MarkdownTextNoAnim(it, it.typography.h6, Modifier.semantics { heading() }) },
206+ setextHeading1 = { MarkdownTextNoAnim(it, it.typography.h1, Modifier.semantics { heading() }) },
207+ setextHeading2 = { MarkdownTextNoAnim(it, it.typography.h2, Modifier.semantics { heading() }) },
@@ -223,7 +240,7 @@ private fun MarkdownBody(
223 val inlineCodeTypography = LocalMarkdownTypography.current
240 val inlineCodeTypography = LocalMarkdownTypography.current
224 val inlineCodeColors = LocalMarkdownColors.current
241 val inlineCodeColors = LocalMarkdownColors.current
225 // Inline-code tag annotator: stamps a MARKDOWN_TAG_INLINE_CODE string annotation onto each
242 // Inline-code tag annotator: stamps a MARKDOWN_TAG_INLINE_CODE string annotation onto each
226− // CODE_SPAN range so [ParagraphWithInlineCodeCopy] can resolve a long-press offset to the
243+ // CODE_SPAN range so [MarkdownTextNoAnim] can resolve a long-press offset to the
227 // code text and copy it. The library's default CODE_SPAN branch in buildMarkdownAnnotatedString
244 // code text and copy it. The library's default CODE_SPAN branch in buildMarkdownAnnotatedString
228 // is bypassed (the annotator returns true) so the span is rendered exactly once, with the tag.
245 // is bypassed (the annotator returns true) so the span is rendered exactly once, with the tag.
229 // The CODE_SPAN rendering (padding spaces, inlineCode typography span, colors) is inlined here
246 // The CODE_SPAN rendering (padding spaces, inlineCode typography span, colors) is inlined here
@@ -485,25 +502,45 @@ private fun LinkActionDialog(
505+ * Custom markdown `paragraph` / `heading` component that renders via [BasicText] directly,
506+ * bypassing the library's [com.mikepenz.markdown.compose.elements.MarkdownText].
508+ * The library's `MarkdownText` wraps every text segment in
509+ * [animateContentSize][androidx.compose.animation.animateContentSize], which triggers draw-phase
510+ * remeasures through the nested SubcomposeLayout (BasicText-with-links uses SubcomposeLayout for
511+ * link hit-testing). During streaming and during the Loading→Success state transition, these
512+ * remeasures crash with an NPE in the innermost MeasurePolicy.measure (an R8-rewritten frame
513+ * throwing NullPointerException in ChildData.isTarget). [BasicText] handles link taps natively
514+ * (via LayoutWithLinksAndInlineContent), so the library's wrapper adds nothing but the
515+ * crash-prone animateContentSize.
488− * Custom markdown `paragraph` component that adds a long-press-to-copy affordance for inline
517+ * Also adds a long-press-to-copy affordance for inline code spans (single-backtick `` `code` ``).
489− * code spans (single-backtick `` `code` ``). The library renders CODE_SPAN inline as a styled
518+ * The library renders CODE_SPAN inline as a styled run within the paragraph's [AnnotatedString];
490− * run within the paragraph's [AnnotatedString]; there's no discrete `inlineCode` component to
519+ * there's no discrete `inlineCode` component to override. Instead, [MarkdownBody] installs an
491− * override. Instead, [MarkdownBody] installs an annotator that stamps each CODE_SPAN range with
492− * a [MARKDOWN_TAG_INLINE_CODE] string annotation; this component builds the paragraph's
520+ * annotator that stamps each CODE_SPAN range with a [MARKDOWN_TAG_INLINE_CODE] string annotation;
493− * [AnnotatedString] via the library's [buildMarkdownAnnotatedString] (so the annotator runs and
521+ * this component builds the [AnnotatedString] via the library's [buildMarkdownAnnotatedString]
494− * the tags land), then renders it with a `pointerInput` that hit-tests a long-press against the
522+ * (so the annotator runs and the tags land), then renders it with a `pointerInput` that hit-tests
495− * tagged ranges and copies the span's text on hit — mirroring the copy affordance fenced/indented
523+ * a long-press against the tagged ranges and copies the span's text on hit — mirroring the copy
496− * code blocks get via [CodeWithCopy], for the common case of short inline commands/identifiers.
524+ * affordance fenced/indented code blocks get via [CodeWithCopy], for the common case of short
497− * A long-press inside a [SelectionContainer] is consumed by the selection machinery first, so
525+ * inline commands/identifiers. A long-press inside a [SelectionContainer] is consumed by the
498− * the copy fires from non-selectable contexts (e.g. while streaming, where no SelectionContainer
526+ * selection machinery first, so the copy fires from non-selectable contexts (e.g. while streaming,
499− * wraps the text); when selection is active the user can still copy via the selection handles.
527+ * where no SelectionContainer wraps the text); when selection is active the user can still copy
500− * Haptic feedback matches every other copy affordance.
528+ * via the selection handles. Haptic feedback matches every other copy affordance.
530+ * The color resolution mirrors MarkdownBasicText's logic (style.color →
531+ * LocalMarkdownColors.current.text). The [modifier] is applied to the BasicText (used by headings
532+ * to add `heading()` semantics). Used for both the `paragraph` component (style =
533+ * typography.paragraph, no modifier) and the `heading1`–`heading6` / `setextHeading` components
534+ * (style = typography.h1–h6, `heading()` semantics modifier).
502@Composable
536@Composable
537+private fun MarkdownTextNoAnim(
503−private fun ParagraphWithInlineCodeCopy(model: MarkdownComponentModel) {
538+ model: MarkdownComponentModel,
540+ modifier: Modifier = Modifier,
504 val context = LocalContext.current
542 val context = LocalContext.current
505 val haptics = LocalHapticFeedback.current
543 val haptics = LocalHapticFeedback.current
506− val style = model.typography.paragraph
507 val typography = LocalMarkdownTypography.current
544 val typography = LocalMarkdownTypography.current
508 val annotator = LocalMarkdownAnnotator.current
545 val annotator = LocalMarkdownAnnotator.current
509 // buildMarkdownAnnotatedString needs an explicit AnnotatorSettings in 0.43+ (the old
546 // buildMarkdownAnnotatedString needs an explicit AnnotatorSettings in 0.43+ (the old
@@ -531,10 +568,10 @@ private fun ParagraphWithInlineCodeCopy(model: MarkdownComponentModel) {
531 // Capture the layout result so a long-press position can be mapped to a character offset,
568 // Capture the layout result so a long-press position can be mapped to a character offset,
532 // then to a tagged range — the same hit-testing pattern the library uses for link taps.
569 // then to a tagged range — the same hit-testing pattern the library uses for link taps.
533 var layoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
570 var layoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
534− LibraryMarkdownText(
571+ val baseColor = LocalMarkdownColors.current.text
535− content = styledText,
572+ BasicText(
536− node = model.node,
573+ text = styledText,
537− modifier = Modifier.pointerInput(styledText) {
574+ modifier = modifier.pointerInput(styledText) {
538 detectTapGestures(
575 detectTapGestures(
539 onLongPress = { pos ->
576 onLongPress = { pos ->
540 val result = layoutResult ?: return@detectTapGestures
577 val result = layoutResult ?: return@detectTapGestures
@@ -549,7 +586,8 @@ private fun ParagraphWithInlineCodeCopy(model: MarkdownComponentModel) {
551 style = style,
588 style = style,
589+ color = { baseColor },
552− onTextLayout = { result, _ -> layoutResult = result },
590+ onTextLayout = { result -> layoutResult = result },