diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownHeadingRenderingTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownHeadingRenderingTest.kt index ec36d0dc..9758bc49 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownHeadingRenderingTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownHeadingRenderingTest.kt @@ -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() + } } diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownTableRenderingTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownTableRenderingTest.kt index 559a5fc8..4272cbb5 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownTableRenderingTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/MarkdownTableRenderingTest.kt @@ -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) + } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt index e52b742f..03bf817d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/MarkdownText.kt @@ -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, + ) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownModels.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownModels.kt index 87bfed26..e357a206 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownModels.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownModels.kt @@ -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( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownParser.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownParser.kt index d7879285..5044775d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownParser.kt @@ -164,7 +164,7 @@ fun parseBlocks(src: String): List { line.startsWith(">") -> { val quote = mutableListOf() 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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownTableView.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownTableView.kt index 2d2cc26c..5951d53f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownTableView.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownTableView.kt @@ -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), ) } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt index 91bed590..36e8dbfa 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextFeatureTest.kt @@ -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().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() + 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() {