From 270f1d8224daabd31eb584882ab86ecc82789a18 Mon Sep 17 00:00:00 2001 From: Brill Pappin Date: Wed, 27 May 2026 14:04:48 -0400 Subject: [PATCH 1/3] #569 fixed talkBack skipping paragraphs with link by marking them merged. --- .../markdown/compose/elements/MarkdownText.kt | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/compose/elements/MarkdownText.kt b/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/compose/elements/MarkdownText.kt index 43f6a127..265593fb 100644 --- a/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/compose/elements/MarkdownText.kt +++ b/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/compose/elements/MarkdownText.kt @@ -1,6 +1,7 @@ package com.mikepenz.markdown.compose.elements import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.text.InlineTextContent import androidx.compose.runtime.Composable @@ -196,9 +197,15 @@ fun MarkdownText( val segmentDrawModifier = if (extendedSpans != null) { segmentModifier.drawBehind(extendedSpans) } else segmentModifier + val hasSegmentLinks = segment.getLinkAnnotations(0, segment.length).isNotEmpty() + val finalModifier = if (hasSegmentLinks) { + segmentDrawModifier.semantics(mergeDescendants = true) { } + } else { + segmentDrawModifier + } MarkdownBasicText( text = extended, - modifier = segmentDrawModifier.let { animations.animateTextSize(it) }, + modifier = finalModifier.let { animations.animateTextSize(it) }, style = style, inlineContent = resolvedInlineContent, onTextLayout = { result -> @@ -210,7 +217,22 @@ fun MarkdownText( } if (blockImageRanges.isEmpty()) { - textSegment(content, containerModifier(modifier)) + val hasLinks = content.getLinkAnnotations(0, content.length).isNotEmpty() + if (hasLinks) { + Box(modifier = Modifier.semantics { isTraversalGroup = true }.onPlaced { + it.parentLayoutCoordinates?.also { coordinates -> + containerSize.value = coordinates.size.toSize() + } + }) { + textSegment(content, modifier) + } + } else { + textSegment(content, modifier.onPlaced { + it.parentLayoutCoordinates?.also { coordinates -> + containerSize.value = coordinates.size.toSize() + } + }) + } } else { val components = LocalMarkdownComponents.current val typography = LocalMarkdownTypography.current From 011708155f137122ddb01b798413153c04cb5c6f Mon Sep 17 00:00:00 2001 From: Brill Pappin Date: Sun, 31 May 2026 17:08:14 -0400 Subject: [PATCH 2/3] - Resolve PR review comments by passing the caller-provided modifier to the outer `Box` container of text segments with links, and passing `Modifier` to the internal `textSegment`. - Add the `inline_links_are_individually_discoverable_and_readable` regression test to `MarkdownA11yTest.kt` to verify that `mergeDescendants = true` does not hide paragraph text or prune link annotations. --- .../markdown/m3/a11y/MarkdownA11yTest.kt | 22 +++++++++++++++++++ .../markdown/compose/elements/MarkdownText.kt | 8 ++----- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/multiplatform-markdown-renderer-m3/src/jvmTest/kotlin/com/mikepenz/markdown/m3/a11y/MarkdownA11yTest.kt b/multiplatform-markdown-renderer-m3/src/jvmTest/kotlin/com/mikepenz/markdown/m3/a11y/MarkdownA11yTest.kt index 282c24d7..4d07353a 100644 --- a/multiplatform-markdown-renderer-m3/src/jvmTest/kotlin/com/mikepenz/markdown/m3/a11y/MarkdownA11yTest.kt +++ b/multiplatform-markdown-renderer-m3/src/jvmTest/kotlin/com/mikepenz/markdown/m3/a11y/MarkdownA11yTest.kt @@ -48,6 +48,28 @@ class MarkdownA11yTest { ) } + @Test + fun inline_links_are_individually_discoverable_and_readable() = runComposeUiTest { + setContent { Wrap { Markdown(docLinks) } } + val root = onRoot().fetchSemanticsNode() + val textNode = findNodeWhoseTextContains(root, "Anthropic") + ?: error("Could not locate paragraph text node containing the link copy") + + // Verify that the paragraph text is exposed/readable + val textList = textNode.config[SemanticsProperties.Text] + val fullText = textList.joinToString(" ") { it.text } + assertTrue(fullText.contains("Visit Anthropic and then Claude here.")) + + // Verify that the link annotations are still present on the AnnotatedString + val annotatedString = textList.first() + val linkAnnotations = annotatedString.getLinkAnnotations(0, annotatedString.length) + assertTrue(linkAnnotations.size >= 2, "There should be at least two inline links in the AnnotatedString") + + val urls = linkAnnotations.mapNotNull { (it.item as? androidx.compose.ui.text.LinkAnnotation.Url)?.url } + assertTrue(urls.contains("https://anthropic.com"), "Should contain Anthropic link") + assertTrue(urls.contains("https://claude.ai"), "Should contain Claude link") + } + @Test fun headings_expose_heading_property() = runComposeUiTest { setContent { Wrap { Markdown("# Title\n\nBody") } } diff --git a/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/compose/elements/MarkdownText.kt b/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/compose/elements/MarkdownText.kt index 265593fb..8cea35d1 100644 --- a/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/compose/elements/MarkdownText.kt +++ b/multiplatform-markdown-renderer/src/commonMain/kotlin/com/mikepenz/markdown/compose/elements/MarkdownText.kt @@ -219,12 +219,8 @@ fun MarkdownText( if (blockImageRanges.isEmpty()) { val hasLinks = content.getLinkAnnotations(0, content.length).isNotEmpty() if (hasLinks) { - Box(modifier = Modifier.semantics { isTraversalGroup = true }.onPlaced { - it.parentLayoutCoordinates?.also { coordinates -> - containerSize.value = coordinates.size.toSize() - } - }) { - textSegment(content, modifier) + Box(modifier = containerModifier(modifier)) { + textSegment(content, Modifier) } } else { textSegment(content, modifier.onPlaced { From 8d0e72d855404e30145b24ad1c023113920cee2e Mon Sep 17 00:00:00 2001 From: Mike Penz Date: Sun, 7 Jun 2026 16:27:14 +0200 Subject: [PATCH 3/3] test(a11y): replace inline-link a11y test with merge-flag assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove inline_links_are_individually_discoverable_and_readable: it only asserted the AnnotatedString retains link annotations, which is true with or without the fix (merging never mutates the string), so it cannot detect a #569/#487 regression. Replace with link_paragraph_merges_yet_keeps_both_links, asserting the paragraph's isMergingSemanticsOfDescendants flag on the unmerged semantics tree — false without the fix, true with it — so the #569 fix is guarded deterministically on the JVM. mergeDescendants maps to the ANI isScreenReaderFocusable flag and leaves rendered output byte-identical, so a pixel/legend snapshot cannot catch it. Also assert both inline links survive as LinkAnnotations on that node, guarding #487. --- .../markdown/m3/a11y/MarkdownA11yTest.kt | 50 ++++++++++++------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/multiplatform-markdown-renderer-m3/src/jvmTest/kotlin/com/mikepenz/markdown/m3/a11y/MarkdownA11yTest.kt b/multiplatform-markdown-renderer-m3/src/jvmTest/kotlin/com/mikepenz/markdown/m3/a11y/MarkdownA11yTest.kt index 4d07353a..b83bd882 100644 --- a/multiplatform-markdown-renderer-m3/src/jvmTest/kotlin/com/mikepenz/markdown/m3/a11y/MarkdownA11yTest.kt +++ b/multiplatform-markdown-renderer-m3/src/jvmTest/kotlin/com/mikepenz/markdown/m3/a11y/MarkdownA11yTest.kt @@ -13,8 +13,10 @@ import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.v2.runComposeUiTest +import androidx.compose.ui.text.LinkAnnotation import com.mikepenz.markdown.m3.Markdown import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertTrue /** @@ -48,26 +50,40 @@ class MarkdownA11yTest { ) } + /** + * Issue #569 fix (#570): the paragraph carrying inline links must be a screen-reader + * merge boundary so TalkBack reads it as one unit instead of skipping it. In Compose + * `mergeDescendants` maps to the ANI `isScreenReaderFocusable` flag, so we assert the + * flag directly on the unmerged tree — it is `false` without the fix and `true` with it. + * + * Simultaneously guards #487: the two inline links must survive as addressable + * `LinkAnnotation`s on that same text node (merging must not prune them). + * + * Unlike a pixel snapshot, this distinguishes the patched from the unpatched renderer: + * the merge flag flips while the rendered output stays byte-identical. + */ @Test - fun inline_links_are_individually_discoverable_and_readable() = runComposeUiTest { + fun link_paragraph_merges_yet_keeps_both_links() = runComposeUiTest { setContent { Wrap { Markdown(docLinks) } } - val root = onRoot().fetchSemanticsNode() - val textNode = findNodeWhoseTextContains(root, "Anthropic") + val root = onRoot(useUnmergedTree = true).fetchSemanticsNode() + val paragraph = findNodeWhoseTextContains(root, "Anthropic") ?: error("Could not locate paragraph text node containing the link copy") - - // Verify that the paragraph text is exposed/readable - val textList = textNode.config[SemanticsProperties.Text] - val fullText = textList.joinToString(" ") { it.text } - assertTrue(fullText.contains("Visit Anthropic and then Claude here.")) - - // Verify that the link annotations are still present on the AnnotatedString - val annotatedString = textList.first() - val linkAnnotations = annotatedString.getLinkAnnotations(0, annotatedString.length) - assertTrue(linkAnnotations.size >= 2, "There should be at least two inline links in the AnnotatedString") - - val urls = linkAnnotations.mapNotNull { (it.item as? androidx.compose.ui.text.LinkAnnotation.Url)?.url } - assertTrue(urls.contains("https://anthropic.com"), "Should contain Anthropic link") - assertTrue(urls.contains("https://claude.ai"), "Should contain Claude link") + + // #569: paragraph is a screen-reader merge boundary (false on develop, true on #570). + assertTrue( + paragraph.config.isMergingSemanticsOfDescendants, + "Paragraph with inline links must merge descendants so TalkBack reads it — regression of #569" + ) + + // #487: both inline links remain individually addressable after the merge. + val annotatedString = paragraph.config[SemanticsProperties.Text].first() + val urls = annotatedString.getLinkAnnotations(0, annotatedString.length) + .mapNotNull { (it.item as? LinkAnnotation.Url)?.url } + .toSet() + assertEquals( + setOf("https://anthropic.com", "https://claude.ai"), urls, + "Both inline links must survive merging — regression of #487" + ) } @Test