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

This commit is contained in:
m57 authored and GitHub committed 2026-09-28 18:04:05 +03:00
1 parent cc98f3ba89
commit 580f3b9697
7 files changed
+257 -93

No files matched your search

@@ -1,8 +1,10 @@
package com.m57.hermescontrol.ui.chat
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
@@ -30,4 +32,33 @@ class MarkdownHeadingRenderingTest {
composeTestRule.onNodeWithText("Set foo now").assertIsDisplayed()
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
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -32,4 +40,26 @@ class MarkdownTableRenderingTest {
composeTestRule.onNodeWithText("**Name**").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 -> {
val fontSize =
val headingStyle =
when (block.level) {
1 -> 22.sp
2 -> 20.sp
3 -> 18.sp
4 -> 16.sp
5 -> 15.sp
else -> 14.sp
1 -> MaterialTheme.typography.headlineMedium
2 -> MaterialTheme.typography.headlineSmall
3 -> MaterialTheme.typography.titleMedium
4 -> MaterialTheme.typography.titleSmall
5 -> MaterialTheme.typography.labelLarge
else -> MaterialTheme.typography.labelMedium
}
val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) }
val blockDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
@@ -169,12 +169,10 @@ fun MarkdownText(
textColor = textColor,
latexMeasurer = latexMeasurer,
style =
MaterialTheme.typography.bodyMedium
.copy(
fontSize = fontSize,
fontWeight = FontWeight.Bold,
textDirection = bidiTextDirection(isRtl),
),
headingStyle.copy(
fontWeight = FontWeight.Bold,
textDirection = bidiTextDirection(isRtl),
),
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
@@ -209,20 +207,30 @@ fun MarkdownText(
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(end = 6.dp),
)
MarkdownInlineText(
text = block.text,
textColor = textColor,
latexMeasurer = latexMeasurer,
style =
MaterialTheme.typography.bodyMedium.copy(
textDirection = bidiTextDirection(isRtl),
),
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.weight(1f),
)
Column(modifier = Modifier.weight(1f)) {
MarkdownInlineText(
text = block.text,
textColor = textColor,
latexMeasurer = latexMeasurer,
style =
MaterialTheme.typography.bodyMedium.copy(
textDirection = bidiTextDirection(isRtl),
),
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
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),
)
MarkdownInlineText(
text = block.text,
textColor = textColor,
latexMeasurer = latexMeasurer,
style =
MaterialTheme.typography.bodyMedium.copy(
textDirection = bidiTextDirection(isRtl),
),
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.weight(1f),
)
Column(modifier = Modifier.weight(1f)) {
MarkdownInlineText(
text = block.text,
textColor = textColor,
latexMeasurer = latexMeasurer,
style =
MaterialTheme.typography.bodyMedium.copy(
textDirection = bidiTextDirection(isRtl),
),
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
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,
modifier = Modifier.padding(end = 6.dp),
)
MarkdownInlineText(
text = block.text,
textColor = textColor,
latexMeasurer = latexMeasurer,
style =
MaterialTheme.typography.bodyMedium.copy(
textDirection = bidiTextDirection(isRtl),
),
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.weight(1f),
)
Column(modifier = Modifier.weight(1f)) {
MarkdownInlineText(
text = block.text,
textColor = textColor,
latexMeasurer = latexMeasurer,
style =
MaterialTheme.typography.bodyMedium.copy(
textDirection = bidiTextDirection(isRtl),
),
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
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)),
)
Spacer(modifier = Modifier.width(8.dp))
MarkdownInlineText(
text = block.text,
textColor = textColor,
latexMeasurer = latexMeasurer,
style =
MaterialTheme.typography.bodyMedium.copy(
fontStyle = FontStyle.Italic,
textDirection = bidiTextDirection(isRtl),
),
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
modifier = Modifier.weight(1f),
)
val nestedBlocks = remember(block.text) { parseBlocks(block.text) }
if (nestedBlocks.size == 1 && nestedBlocks.single() is MdBlock.Paragraph) {
MarkdownInlineText(
text = block.text,
textColor = textColor,
latexMeasurer = latexMeasurer,
style =
MaterialTheme.typography.bodyMedium.copy(
fontStyle = FontStyle.Italic,
textDirection = bidiTextDirection(isRtl),
),
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
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(
val text: String,
val level: Int = 0,
val nestedSource: String = "",
) : MdBlock
data class Task(
val checked: Boolean,
val text: String,
val level: Int = 0,
val nestedSource: String = "",
) : MdBlock
data class Ordered(
val index: Int,
val text: String,
val level: Int = 0,
val nestedSource: String = "",
) : MdBlock
data class Image(
@@ -164,7 +164,7 @@ fun parseBlocks(src: String): List<MdBlock> {
line.startsWith(">") -> {
val quote = mutableListOf<String>()
while (i < lines.size && lines[i].startsWith(">")) {
quote.add(lines[i].removePrefix(">").trim())
quote.add(lines[i].removePrefix(">").removePrefix(" "))
i++
}
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])) {
j = lookahead
} else if (items.isNotEmpty() && lookahead < lines.size &&
computeIndent(lines[lookahead].takeWhile { it == ' ' || it == '\t' }) >= 2
) {
items.last().continuationLines.add("")
j = lookahead
} else {
break
}
@@ -334,32 +339,34 @@ private fun parseList(
level = indentStack.size - 1
}
val fullText =
if (item.continuationLines.isEmpty()) {
when (item) {
is ParsedItem.Bullet -> item.text
is ParsedItem.Task -> item.text
is ParsedItem.Ordered -> item.text
}
val base =
when (item) {
is ParsedItem.Bullet -> item.text
is ParsedItem.Task -> item.text
is ParsedItem.Ordered -> item.text
}
val nestedSource =
if (item.continuationLines.any(::startsNestedBlock)) {
item.continuationLines.joinToString("\n")
} else {
val base =
when (item) {
is ParsedItem.Bullet -> item.text
is ParsedItem.Task -> item.text
is ParsedItem.Ordered -> item.text
}
base + " " + item.continuationLines.joinToString(" ")
""
}
val fullText =
if (nestedSource.isNotEmpty()) {
base
} else {
(listOf(base) + item.continuationLines).joinToString(" ").trim()
}
when (item) {
is ParsedItem.Bullet -> {
blocks.add(MdBlock.Bullet(fullText, level = level))
blocks.add(MdBlock.Bullet(fullText, level = level, nestedSource = nestedSource))
lastItemWasOrdered = false
lastLevel = level
}
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
lastLevel = level
}
@@ -377,7 +384,7 @@ private fun parseList(
currentSequenceNumber = item.rawNumber
currentSequenceNumber
}
blocks.add(MdBlock.Ordered(indexToUse, fullText, level = level))
blocks.add(MdBlock.Ordered(indexToUse, fullText, level = level, nestedSource = nestedSource))
lastItemWasOrdered = true
lastLevel = level
}
@@ -387,6 +394,15 @@ private fun parseList(
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 {
val trimmed = line.trim()
if (trimmed.length < 3) return false
@@ -16,8 +16,11 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.LayoutDirection
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.util.BidiUtils
private val TABLE_COL_WIDTH = 140.dp
@Composable
fun MarkdownTable(
block: MdBlock.Table,
@@ -46,6 +47,25 @@ fun MarkdownTable(
val tableDirection = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
val headerBg = textColor.copy(alpha = 0.08f)
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) {
Column(
modifier =
@@ -72,14 +92,15 @@ fun MarkdownTable(
highlights = highlights,
modifier =
Modifier
.width(TABLE_COL_WIDTH)
.width(columnWidths[idx])
.testTag("markdown_table_header_$idx")
.padding(6.dp),
)
}
}
HorizontalDivider(color = textColor.copy(alpha = 0.25f))
// Body rows
block.rows.forEach { row ->
block.rows.forEachIndexed { rowIndex, row ->
Row(verticalAlignment = Alignment.Top) {
row.forEachIndexed { idx, cell ->
MarkdownInlineText(
@@ -96,7 +117,8 @@ fun MarkdownTable(
highlights = highlights,
modifier =
Modifier
.width(TABLE_COL_WIDTH)
.width(columnWidths[idx])
.testTag("markdown_table_cell_${rowIndex}_$idx")
.padding(6.dp),
)
}
@@ -459,6 +459,28 @@ class MarkdownTextFeatureTest {
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
@Test
fun testNestedCodeBlock_fourBackticksWrappingThreeBackticks() {