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

@@ -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),
)
}