fix(#1331, #1332): render nested markdown blocks and content-sized tables (#1360)

This commit is contained in:
m57
2026-09-28 18:04:05 +03:00
committed by GitHub
parent cc98f3ba89
commit 580f3b9697
7 changed files with 257 additions and 93 deletions
@@ -1,8 +1,10 @@
package com.m57.hermescontrol.ui.chat package com.m57.hermescontrol.ui.chat
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest import androidx.test.filters.MediumTest
@@ -30,4 +32,33 @@ class MarkdownHeadingRenderingTest {
composeTestRule.onNodeWithText("Set foo now").assertIsDisplayed() composeTestRule.onNodeWithText("Set foo now").assertIsDisplayed()
composeTestRule.onNodeWithText("**Set `foo` now**").assertDoesNotExist() composeTestRule.onNodeWithText("**Set `foo` now**").assertDoesNotExist()
} }
@Test
fun quoteAndListContinuationRenderNestedCodeBlocks() {
composeTestRule.setContent {
MaterialTheme {
MarkdownText(
text =
"""
> # Quote heading
> ```kotlin
> val quoted = 1
> ```
- Item
```kotlin
val listed = 2
```
""".trimIndent(),
textColor = MaterialTheme.colorScheme.onSurface,
)
}
}
composeTestRule.onNodeWithText("Quote heading").assertIsDisplayed()
composeTestRule.onNodeWithText("Item").assertIsDisplayed()
composeTestRule.onAllNodesWithTag("code_block").assertCountEquals(2)
composeTestRule.onNodeWithText("val quoted = 1", substring = true).assertIsDisplayed()
composeTestRule.onNodeWithText("val listed = 2", substring = true).assertIsDisplayed()
}
} }
@@ -1,11 +1,19 @@
package com.m57.hermescontrol.ui.chat package com.m57.hermescontrol.ui.chat
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest import androidx.test.filters.MediumTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@@ -32,4 +40,26 @@ class MarkdownTableRenderingTest {
composeTestRule.onNodeWithText("**Name**").assertDoesNotExist() composeTestRule.onNodeWithText("**Name**").assertDoesNotExist()
composeTestRule.onNodeWithText("`code`").assertDoesNotExist() composeTestRule.onNodeWithText("`code`").assertDoesNotExist()
} }
@Test
fun columnsSizeToContentAndStayAlignedAcrossRows() {
composeTestRule.setContent {
MaterialTheme {
Box(Modifier.width(220.dp)) {
MarkdownText(
text = "| ID | Description |\n| --- | --- |\n| A | A considerably longer table value |",
textColor = MaterialTheme.colorScheme.onSurface,
)
}
}
}
val idHeader = composeTestRule.onNodeWithTag("markdown_table_header_0").getUnclippedBoundsInRoot()
val descHeader = composeTestRule.onNodeWithTag("markdown_table_header_1").getUnclippedBoundsInRoot()
val idCell = composeTestRule.onNodeWithTag("markdown_table_cell_0_0").getUnclippedBoundsInRoot()
val descCell = composeTestRule.onNodeWithTag("markdown_table_cell_0_1").getUnclippedBoundsInRoot()
assertTrue(descHeader.right - descHeader.left > idHeader.right - idHeader.left)
assertEquals(idHeader.right - idHeader.left, idCell.right - idCell.left)
assertEquals(descHeader.right - descHeader.left, descCell.right - descCell.left)
}
} }
@@ -152,14 +152,14 @@ fun MarkdownText(
} }
is MdBlock.Heading -> { is MdBlock.Heading -> {
val fontSize = val headingStyle =
when (block.level) { when (block.level) {
1 -> 22.sp 1 -> MaterialTheme.typography.headlineMedium
2 -> 20.sp 2 -> MaterialTheme.typography.headlineSmall
3 -> 18.sp 3 -> MaterialTheme.typography.titleMedium
4 -> 16.sp 4 -> MaterialTheme.typography.titleSmall
5 -> 15.sp 5 -> MaterialTheme.typography.labelLarge
else -> 14.sp else -> MaterialTheme.typography.labelMedium
} }
val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) } val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) }
val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
@@ -169,12 +169,10 @@ fun MarkdownText(
textColor = textColor, textColor = textColor,
latexMeasurer = latexMeasurer, latexMeasurer = latexMeasurer,
style = style =
MaterialTheme.typography.bodyMedium headingStyle.copy(
.copy( fontWeight = FontWeight.Bold,
fontSize = fontSize, textDirection = bidiTextDirection(isRtl),
fontWeight = FontWeight.Bold, ),
textDirection = bidiTextDirection(isRtl),
),
searchQuery = searchQuery, searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch, isCurrentMatch = isCurrentMatch,
linkColor = linkColor, linkColor = linkColor,
@@ -209,20 +207,30 @@ fun MarkdownText(
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(end = 6.dp), modifier = Modifier.padding(end = 6.dp),
) )
MarkdownInlineText( Column(modifier = Modifier.weight(1f)) {
text = block.text, MarkdownInlineText(
textColor = textColor, text = block.text,
latexMeasurer = latexMeasurer, textColor = textColor,
style = latexMeasurer = latexMeasurer,
MaterialTheme.typography.bodyMedium.copy( style =
textDirection = bidiTextDirection(isRtl), MaterialTheme.typography.bodyMedium.copy(
), textDirection = bidiTextDirection(isRtl),
searchQuery = searchQuery, ),
isCurrentMatch = isCurrentMatch, searchQuery = searchQuery,
linkColor = linkColor, isCurrentMatch = isCurrentMatch,
highlights = highlights, linkColor = linkColor,
modifier = Modifier.weight(1f), highlights = highlights,
) )
if (block.nestedSource.isNotEmpty()) {
MarkdownText(
text = block.nestedSource,
textColor = textColor,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
onImageClick = onImageClick,
)
}
}
} }
} }
} }
@@ -258,20 +266,30 @@ fun MarkdownText(
}, },
modifier = Modifier.size(18.dp).padding(top = 1.dp, end = 6.dp), modifier = Modifier.size(18.dp).padding(top = 1.dp, end = 6.dp),
) )
MarkdownInlineText( Column(modifier = Modifier.weight(1f)) {
text = block.text, MarkdownInlineText(
textColor = textColor, text = block.text,
latexMeasurer = latexMeasurer, textColor = textColor,
style = latexMeasurer = latexMeasurer,
MaterialTheme.typography.bodyMedium.copy( style =
textDirection = bidiTextDirection(isRtl), MaterialTheme.typography.bodyMedium.copy(
), textDirection = bidiTextDirection(isRtl),
searchQuery = searchQuery, ),
isCurrentMatch = isCurrentMatch, searchQuery = searchQuery,
linkColor = linkColor, isCurrentMatch = isCurrentMatch,
highlights = highlights, linkColor = linkColor,
modifier = Modifier.weight(1f), highlights = highlights,
) )
if (block.nestedSource.isNotEmpty()) {
MarkdownText(
text = block.nestedSource,
textColor = textColor,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
onImageClick = onImageClick,
)
}
}
} }
} }
} }
@@ -295,20 +313,30 @@ fun MarkdownText(
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(end = 6.dp), modifier = Modifier.padding(end = 6.dp),
) )
MarkdownInlineText( Column(modifier = Modifier.weight(1f)) {
text = block.text, MarkdownInlineText(
textColor = textColor, text = block.text,
latexMeasurer = latexMeasurer, textColor = textColor,
style = latexMeasurer = latexMeasurer,
MaterialTheme.typography.bodyMedium.copy( style =
textDirection = bidiTextDirection(isRtl), MaterialTheme.typography.bodyMedium.copy(
), textDirection = bidiTextDirection(isRtl),
searchQuery = searchQuery, ),
isCurrentMatch = isCurrentMatch, searchQuery = searchQuery,
linkColor = linkColor, isCurrentMatch = isCurrentMatch,
highlights = highlights, linkColor = linkColor,
modifier = Modifier.weight(1f), highlights = highlights,
) )
if (block.nestedSource.isNotEmpty()) {
MarkdownText(
text = block.nestedSource,
textColor = textColor,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
onImageClick = onImageClick,
)
}
}
} }
} }
} }
@@ -330,21 +358,33 @@ fun MarkdownText(
.background(textColor.copy(alpha = 0.35f)), .background(textColor.copy(alpha = 0.35f)),
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
MarkdownInlineText( val nestedBlocks = remember(block.text) { parseBlocks(block.text) }
text = block.text, if (nestedBlocks.size == 1 && nestedBlocks.single() is MdBlock.Paragraph) {
textColor = textColor, MarkdownInlineText(
latexMeasurer = latexMeasurer, text = block.text,
style = textColor = textColor,
MaterialTheme.typography.bodyMedium.copy( latexMeasurer = latexMeasurer,
fontStyle = FontStyle.Italic, style =
textDirection = bidiTextDirection(isRtl), MaterialTheme.typography.bodyMedium.copy(
), fontStyle = FontStyle.Italic,
searchQuery = searchQuery, textDirection = bidiTextDirection(isRtl),
isCurrentMatch = isCurrentMatch, ),
linkColor = linkColor, searchQuery = searchQuery,
highlights = highlights, isCurrentMatch = isCurrentMatch,
modifier = Modifier.weight(1f), linkColor = linkColor,
) highlights = highlights,
modifier = Modifier.weight(1f),
)
} else {
MarkdownText(
text = block.text,
textColor = textColor,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
modifier = Modifier.weight(1f),
onImageClick = onImageClick,
)
}
} }
} }
} }
@@ -18,18 +18,21 @@ sealed interface MdBlock {
data class Bullet( data class Bullet(
val text: String, val text: String,
val level: Int = 0, val level: Int = 0,
val nestedSource: String = "",
) : MdBlock ) : MdBlock
data class Task( data class Task(
val checked: Boolean, val checked: Boolean,
val text: String, val text: String,
val level: Int = 0, val level: Int = 0,
val nestedSource: String = "",
) : MdBlock ) : MdBlock
data class Ordered( data class Ordered(
val index: Int, val index: Int,
val text: String, val text: String,
val level: Int = 0, val level: Int = 0,
val nestedSource: String = "",
) : MdBlock ) : MdBlock
data class Image( data class Image(
@@ -164,7 +164,7 @@ fun parseBlocks(src: String): List<MdBlock> {
line.startsWith(">") -> { line.startsWith(">") -> {
val quote = mutableListOf<String>() val quote = mutableListOf<String>()
while (i < lines.size && lines[i].startsWith(">")) { while (i < lines.size && lines[i].startsWith(">")) {
quote.add(lines[i].removePrefix(">").trim()) quote.add(lines[i].removePrefix(">").removePrefix(" "))
i++ i++
} }
blocks.add(MdBlock.Quote(quote.joinToString("\n"))) blocks.add(MdBlock.Quote(quote.joinToString("\n")))
@@ -287,6 +287,11 @@ private fun parseList(
} }
if (lookahead < lines.size && LIST_ITEM_LINE_RE.matches(lines[lookahead])) { if (lookahead < lines.size && LIST_ITEM_LINE_RE.matches(lines[lookahead])) {
j = lookahead j = lookahead
} else if (items.isNotEmpty() && lookahead < lines.size &&
computeIndent(lines[lookahead].takeWhile { it == ' ' || it == '\t' }) >= 2
) {
items.last().continuationLines.add("")
j = lookahead
} else { } else {
break break
} }
@@ -334,32 +339,34 @@ private fun parseList(
level = indentStack.size - 1 level = indentStack.size - 1
} }
val fullText = val base =
if (item.continuationLines.isEmpty()) { when (item) {
when (item) { is ParsedItem.Bullet -> item.text
is ParsedItem.Bullet -> item.text is ParsedItem.Task -> item.text
is ParsedItem.Task -> item.text is ParsedItem.Ordered -> item.text
is ParsedItem.Ordered -> item.text }
} val nestedSource =
if (item.continuationLines.any(::startsNestedBlock)) {
item.continuationLines.joinToString("\n")
} else { } else {
val base = ""
when (item) { }
is ParsedItem.Bullet -> item.text val fullText =
is ParsedItem.Task -> item.text if (nestedSource.isNotEmpty()) {
is ParsedItem.Ordered -> item.text base
} } else {
base + " " + item.continuationLines.joinToString(" ") (listOf(base) + item.continuationLines).joinToString(" ").trim()
} }
when (item) { when (item) {
is ParsedItem.Bullet -> { is ParsedItem.Bullet -> {
blocks.add(MdBlock.Bullet(fullText, level = level)) blocks.add(MdBlock.Bullet(fullText, level = level, nestedSource = nestedSource))
lastItemWasOrdered = false lastItemWasOrdered = false
lastLevel = level lastLevel = level
} }
is ParsedItem.Task -> { is ParsedItem.Task -> {
blocks.add(MdBlock.Task(item.checked, fullText, level = level)) blocks.add(MdBlock.Task(item.checked, fullText, level = level, nestedSource = nestedSource))
lastItemWasOrdered = false lastItemWasOrdered = false
lastLevel = level lastLevel = level
} }
@@ -377,7 +384,7 @@ private fun parseList(
currentSequenceNumber = item.rawNumber currentSequenceNumber = item.rawNumber
currentSequenceNumber currentSequenceNumber
} }
blocks.add(MdBlock.Ordered(indexToUse, fullText, level = level)) blocks.add(MdBlock.Ordered(indexToUse, fullText, level = level, nestedSource = nestedSource))
lastItemWasOrdered = true lastItemWasOrdered = true
lastLevel = level lastLevel = level
} }
@@ -387,6 +394,15 @@ private fun parseList(
return blocks to j return blocks to j
} }
/** Preserve block syntax in indented list continuations instead of flattening it into prose. */
private fun startsNestedBlock(line: String): Boolean =
parseCodeFenceStart(line) != null ||
isValidHeading(line) ||
line.startsWith(">") ||
isHorizontalRule(line) ||
line.startsWith("$$") ||
line.startsWith("\\[")
private fun isHorizontalRule(line: String): Boolean { private fun isHorizontalRule(line: String): Boolean {
val trimmed = line.trim() val trimmed = line.trim()
if (trimmed.length < 3) return false if (trimmed.length < 3) return false
@@ -16,8 +16,11 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -25,8 +28,6 @@ import com.hrm.latex.renderer.measure.LatexMeasurerState
import com.m57.hermescontrol.theme.SearchHighlightColors import com.m57.hermescontrol.theme.SearchHighlightColors
import com.m57.hermescontrol.util.BidiUtils import com.m57.hermescontrol.util.BidiUtils
private val TABLE_COL_WIDTH = 140.dp
@Composable @Composable
fun MarkdownTable( fun MarkdownTable(
block: MdBlock.Table, block: MdBlock.Table,
@@ -46,6 +47,25 @@ fun MarkdownTable(
val tableDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current val tableDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
val headerBg = textColor.copy(alpha = 0.08f) val headerBg = textColor.copy(alpha = 0.08f)
val alignments = block.alignments val alignments = block.alignments
val cellStyle = MaterialTheme.typography.bodySmall
val textMeasurer = rememberTextMeasurer()
val density = LocalDensity.current
val columnWidths =
remember(block, cellStyle, density, textMeasurer) {
val padding = with(density) { 12.dp.roundToPx() }
val minimum = with(density) { 48.dp.roundToPx() }
val maximum = with(density) { 280.dp.roundToPx() }
val columnCount = maxOf(block.header.size, block.rows.maxOfOrNull { it.size } ?: 0)
List(columnCount) { column ->
val cells =
listOfNotNull(block.header.getOrNull(column)) + block.rows.mapNotNull { it.getOrNull(column) }
val contentWidth =
cells.maxOfOrNull { cell ->
textMeasurer.measure(text = cell.take(128), style = cellStyle, maxLines = 1).size.width
} ?: 0
with(density) { (contentWidth + padding).coerceIn(minimum, maximum).toDp() }
}
}
CompositionLocalProvider(LocalLayoutDirection provides tableDirection) { CompositionLocalProvider(LocalLayoutDirection provides tableDirection) {
Column( Column(
modifier = modifier =
@@ -72,14 +92,15 @@ fun MarkdownTable(
highlights = highlights, highlights = highlights,
modifier = modifier =
Modifier Modifier
.width(TABLE_COL_WIDTH) .width(columnWidths[idx])
.testTag("markdown_table_header_$idx")
.padding(6.dp), .padding(6.dp),
) )
} }
} }
HorizontalDivider(color = textColor.copy(alpha = 0.25f)) HorizontalDivider(color = textColor.copy(alpha = 0.25f))
// Body rows // Body rows
block.rows.forEach { row -> block.rows.forEachIndexed { rowIndex, row ->
Row(verticalAlignment = Alignment.Top) { Row(verticalAlignment = Alignment.Top) {
row.forEachIndexed { idx, cell -> row.forEachIndexed { idx, cell ->
MarkdownInlineText( MarkdownInlineText(
@@ -96,7 +117,8 @@ fun MarkdownTable(
highlights = highlights, highlights = highlights,
modifier = modifier =
Modifier Modifier
.width(TABLE_COL_WIDTH) .width(columnWidths[idx])
.testTag("markdown_table_cell_${rowIndex}_$idx")
.padding(6.dp), .padding(6.dp),
) )
} }
@@ -459,6 +459,28 @@ class MarkdownTextFeatureTest {
assertEquals(0, o16.level) assertEquals(0, o16.level)
} }
@Test
fun quotedBlocksRetainNestedHeadingsListsAndFences() {
val source = "> # Heading\n> - item\n> ```kotlin\n> val x = 1\n> ```"
val quote = parseBlocks(source).single() as MdBlock.Quote
val nested = parseBlocks(quote.text)
assertTrue(nested.any { it is MdBlock.Heading && it.text == "Heading" })
val bullet = nested.filterIsInstance<MdBlock.Bullet>().single()
assertEquals("item", bullet.text)
assertEquals("val x = 1", (parseBlocks(bullet.nestedSource).single() as MdBlock.Code).code)
}
@Test
fun listItemsRetainFencedCodeAndHeadingsAsNestedBlocks() {
val source = "- intro\n ```kotlin\n val x = 1\n ```\n # Inside\n- next"
val bullets = parseBlocks(source).filterIsInstance<MdBlock.Bullet>()
assertEquals(listOf("intro", "next"), bullets.map { it.text })
val nested = parseBlocks(bullets.first().nestedSource)
assertTrue(nested.any { it is MdBlock.Code && it.code == "val x = 1" })
assertTrue(nested.any { it is MdBlock.Heading && it.text == "Inside" })
assertEquals("", bullets.last().nestedSource)
}
// 19. NESTED CODEBLOCKS & EXTENDED CODE FENCES // 19. NESTED CODEBLOCKS & EXTENDED CODE FENCES
@Test @Test
fun testNestedCodeBlock_fourBackticksWrappingThreeBackticks() { fun testNestedCodeBlock_fourBackticksWrappingThreeBackticks() {