perf(markdown): coalesce long plain bullet runs (#1415)

This commit is contained in:
m57 authored and GitHub committed 2026-09-30 18:12:57 +03:00
1 parent dcf62c0c02
commit 5145a3354b
6 files changed
+509 -2

No files matched your search

@@ -34,6 +34,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation
@@ -43,6 +44,7 @@ import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
@@ -61,6 +63,7 @@ import com.m57.hermescontrol.data.remote.GatewayFileClient
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.SearchHighlightColors
import com.m57.hermescontrol.theme.searchHighlightColors
import com.m57.hermescontrol.ui.chat.markdown.BulletRun
import com.m57.hermescontrol.ui.chat.markdown.FnNote
import com.m57.hermescontrol.ui.chat.markdown.InlineMathSegment
import com.m57.hermescontrol.ui.chat.markdown.MarkdownInlineStyler
@@ -69,6 +72,8 @@ import com.m57.hermescontrol.ui.chat.markdown.MarkdownMediaResolver
import com.m57.hermescontrol.ui.chat.markdown.MarkdownTable
import com.m57.hermescontrol.ui.chat.markdown.MdBlock
import com.m57.hermescontrol.ui.chat.markdown.TableAlign
import com.m57.hermescontrol.ui.chat.markdown.buildBulletRunText
import com.m57.hermescontrol.ui.chat.markdown.coalesceBulletRuns
import com.m57.hermescontrol.ui.chat.markdown.parseBlocks
import com.m57.hermescontrol.ui.chat.markdown.splitInlineMath
import com.m57.hermescontrol.util.BidiUtils
@@ -100,7 +105,7 @@ fun MarkdownText(
val statusColors = LocalHermesStatusColors.current
val highlights = searchHighlightColors(statusColors)
val linkColor = MaterialTheme.colorScheme.primary
val blocks = remember(text, isStreaming) { parseBlocks(text) }
val blocks = remember(text, isStreaming) { coalesceBulletRuns(parseBlocks(text)) }
val latexMeasurer = rememberLatexMeasurer()
Column(modifier = modifier.fillMaxWidth()) {
@@ -151,6 +156,7 @@ fun MarkdownText(
CompositionLocalProvider(LocalLayoutDirection provides blockDirection) {
MarkdownInlineText(
text = block.text,
isRtlOverride = isRtl,
textColor = textColor,
latexMeasurer = latexMeasurer,
style =
@@ -167,6 +173,47 @@ fun MarkdownText(
}
}
is BulletRun -> {
val density = LocalDensity.current
val bodyStyle = MaterialTheme.typography.bodyMedium.copy(textDirection = TextDirection.Ltr)
val textMeasurer = rememberTextMeasurer()
val runText =
remember(
block,
density,
bodyStyle,
textColor,
searchQuery,
isCurrentMatch,
linkColor,
highlights,
) {
buildBulletRunText(
run = block,
density = density,
textColor = textColor,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
prefixWidth = { prefix ->
with(density) {
textMeasurer
.measure(prefix, bodyStyle)
.size.width
.toSp()
}
},
)
}
Text(
text = runText,
color = textColor,
style = bodyStyle,
modifier = Modifier.fillMaxWidth().padding(vertical = 1.dp),
)
}
is MdBlock.Bullet -> {
val indent = (block.level * 16).dp
val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) }
@@ -195,6 +242,7 @@ fun MarkdownText(
Column(modifier = Modifier.weight(1f)) {
MarkdownInlineText(
text = block.text,
isRtlOverride = isRtl,
textColor = textColor,
latexMeasurer = latexMeasurer,
style =
@@ -254,6 +302,7 @@ fun MarkdownText(
Column(modifier = Modifier.weight(1f)) {
MarkdownInlineText(
text = block.text,
isRtlOverride = isRtl,
textColor = textColor,
latexMeasurer = latexMeasurer,
style =
@@ -301,6 +350,7 @@ fun MarkdownText(
Column(modifier = Modifier.weight(1f)) {
MarkdownInlineText(
text = block.text,
isRtlOverride = isRtl,
textColor = textColor,
latexMeasurer = latexMeasurer,
style =
@@ -347,6 +397,7 @@ fun MarkdownText(
if (nestedBlocks.size == 1 && nestedBlocks.single() is MdBlock.Paragraph) {
MarkdownInlineText(
text = block.text,
isRtlOverride = isRtl,
textColor = textColor,
latexMeasurer = latexMeasurer,
style =
@@ -502,6 +553,7 @@ fun MarkdownText(
)
MarkdownInlineText(
text = note.text,
isRtlOverride = isRtl,
textColor = textColor,
latexMeasurer = latexMeasurer,
style =
@@ -526,6 +578,7 @@ fun MarkdownText(
CompositionLocalProvider(LocalLayoutDirection provides blockDirection) {
MarkdownInlineText(
text = block.text,
isRtlOverride = isRtl,
textColor = textColor,
latexMeasurer = latexMeasurer,
style =
@@ -0,0 +1,121 @@
package com.m57.hermescontrol.ui.chat.markdown
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextIndent
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.theme.SearchHighlightColors
import com.m57.hermescontrol.util.BidiUtils
/**
* Render-time optimization, not parsed Markdown: a long run of plain bullets drawn as one text node.
* Produced only by [coalesceBulletRuns]; kept out of the parser's AST file on purpose.
*/
data class BulletRun(
val items: List<MdBlock.Bullet>,
) : MdBlock
/** Runs shorter than this keep the per-row renderer; the win only matters for very long lists. */
internal const val MIN_BULLET_RUN = 16
/**
* A bullet is eligible for coalescing when it is a plain, LTR, single-line item: no nested content
* and no inline math (math needs per-item inline content that a merged paragraph run can't host).
*/
private fun MdBlock.Bullet.isPlain(): Boolean =
nestedSource.isEmpty() &&
!BidiUtils.isRtlText(text) &&
splitInlineMath(text).none { it is InlineMathSegment.Math }
/**
* Replaces each run of at least [minRun] consecutive plain bullets with a single [MdBlock.BulletRun].
* Everything else is passed through untouched, so complex lists keep the existing renderer.
*/
internal fun coalesceBulletRuns(
blocks: List<MdBlock>,
minRun: Int = MIN_BULLET_RUN,
): List<MdBlock> {
if (blocks.size < minRun) return blocks
val out = ArrayList<MdBlock>(blocks.size)
var i = 0
while (i < blocks.size) {
val block = blocks[i]
if (block is MdBlock.Bullet && block.isPlain()) {
var end = i + 1
while (end < blocks.size) {
val next = blocks[end]
if (next is MdBlock.Bullet && next.isPlain()) end++ else break
}
if (end - i >= minRun) {
out.add(BulletRun(blocks.subList(i, end).map { it as MdBlock.Bullet }))
} else {
out.addAll(blocks.subList(i, end))
}
i = end
} else {
out.add(block)
i++
}
}
return out
}
private const val BULLET_LEVEL_INDENT_DP = 16
/** The two non-breaking spaces that separate the glyph from the body. */
internal const val BULLET_PREFIX_PAD = "\u00A0\u00A0"
internal fun bulletGlyph(level: Int): String =
when (level % 3) {
0 -> "\u2022"
1 -> "\u25E6"
else -> "\u25AA"
}
/**
* Builds the merged text for a [BulletRun].
*
* The list-level offset is dp-based (like the per-row renderer's `padding(start = (level * 16).dp)`).
* The hanging indent for wrapped lines also includes the rendered bullet prefix, whose width scales with
* the font; [prefixWidth] measures a prefix in the body text style so wrapped lines line up with the body
* at any font scale.
*/
internal fun buildBulletRunText(
run: BulletRun,
density: Density,
textColor: Color,
searchQuery: String,
isCurrentMatch: Boolean,
linkColor: Color,
highlights: SearchHighlightColors,
prefixWidth: (String) -> TextUnit,
): AnnotatedString =
buildAnnotatedString {
run.items.forEachIndexed { index, item ->
val start = with(density) { (item.level * BULLET_LEVEL_INDENT_DP).dp.toSp() }
val prefix = bulletGlyph(item.level) + BULLET_PREFIX_PAD
val rest = (start.value + prefixWidth(prefix).value).sp
withStyle(ParagraphStyle(textIndent = TextIndent(firstLine = start, restLine = rest))) {
append(prefix)
append(
MarkdownInlineStyler.parseInlineSource(
text = item.text,
textColor = textColor,
searchQuery = searchQuery,
isCurrentMatch = isCurrentMatch,
linkColor = linkColor,
highlights = highlights,
isRtl = false,
),
)
// ParagraphStyle breaks lines visually but adds no character; the newline keeps copy/select faithful.
if (index < run.items.lastIndex) append('\n')
}
}
}
@@ -30,8 +30,9 @@ fun MarkdownInlineText(
linkColor: Color,
highlights: SearchHighlightColors,
modifier: Modifier = Modifier,
isRtlOverride: Boolean? = null,
) {
val isRtl = remember(text) { BidiUtils.isRtlText(text) }
val isRtl = isRtlOverride ?: remember(text) { BidiUtils.isRtlText(text) }
val direction = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
val resolvedStyle =
style.copy(
@@ -0,0 +1,76 @@
package com.m57.hermescontrol.ui.chat
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.theme.HermesStatusColors
import com.m57.hermescontrol.theme.StatusBlue
import com.m57.hermescontrol.theme.StatusBlueContainer
import com.m57.hermescontrol.theme.StatusGreen
import com.m57.hermescontrol.theme.StatusGreenContainer
import com.m57.hermescontrol.theme.StatusRed
import com.m57.hermescontrol.theme.StatusRedContainer
import com.m57.hermescontrol.theme.StatusYellow
import com.m57.hermescontrol.theme.StatusYellowContainer
import com.m57.hermescontrol.theme.searchHighlightColors
import com.m57.hermescontrol.ui.chat.markdown.BulletRun
import com.m57.hermescontrol.ui.chat.markdown.buildBulletRunText
import com.m57.hermescontrol.ui.chat.markdown.coalesceBulletRuns
import com.m57.hermescontrol.ui.chat.markdown.parseBlocks
import org.junit.Test
import kotlin.system.measureNanoTime
/** Diagnostic only (opt-in via -PincludeBenchmarks): per-update cost while a list streams from 1 to 1000 bullets. */
class MarkdownBulletRunBenchmark {
private val highlights =
searchHighlightColors(
HermesStatusColors(
success = StatusGreen,
successContainer = StatusGreenContainer,
onSuccess = Color.White,
warning = StatusYellow,
warningContainer = StatusYellowContainer,
onWarning = Color.White,
error = StatusRed,
errorContainer = StatusRedContainer,
onError = Color.White,
info = StatusBlue,
infoContainer = StatusBlueContainer,
onInfo = Color.White,
),
)
private fun coalesce(md: String) = coalesceBulletRuns(parseBlocks(md))
private fun bullets(n: Int) = (1..n).joinToString("\n") { "- item $it" }
private fun render(run: BulletRun): AnnotatedString =
buildBulletRunText(run, Density(1f), Color.Black, "", false, Color.Blue, highlights) { (it.length * 7).sp }
@Test
fun benchmarkStreamingGrowth() {
val sizes = listOf(100, 250, 500, 750, 1000)
val sb = StringBuilder()
var done = 0
val results = mutableListOf<String>()
// warm the JIT
repeat(30) { coalesce(bullets(1000)) }
for (target in sizes) {
while (done < target) {
sb.append("- item ${++done}\n")
}
val md = sb.toString()
val n = 50
val parse = measureNanoTime { repeat(n) { parseBlocks(md) } } / n / 1e6
val full =
measureNanoTime {
repeat(n) {
(coalesce(md).firstOrNull { it is BulletRun } as? BulletRun)?.let { render(it) }
}
} / n / 1e6
results += "$target bullets: parse=${"%.2f".format(parse)}ms parse+coalesce+build=${"%.2f".format(full)}ms"
}
results.forEach { println("BENCHMARK_RESULT: stream $it") }
}
}
@@ -0,0 +1,240 @@
package com.m57.hermescontrol.ui.chat
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.theme.HermesStatusColors
import com.m57.hermescontrol.theme.StatusBlue
import com.m57.hermescontrol.theme.StatusBlueContainer
import com.m57.hermescontrol.theme.StatusGreen
import com.m57.hermescontrol.theme.StatusGreenContainer
import com.m57.hermescontrol.theme.StatusRed
import com.m57.hermescontrol.theme.StatusRedContainer
import com.m57.hermescontrol.theme.StatusYellow
import com.m57.hermescontrol.theme.StatusYellowContainer
import com.m57.hermescontrol.theme.searchHighlightColors
import com.m57.hermescontrol.ui.chat.markdown.BulletRun
import com.m57.hermescontrol.ui.chat.markdown.MIN_BULLET_RUN
import com.m57.hermescontrol.ui.chat.markdown.MdBlock
import com.m57.hermescontrol.ui.chat.markdown.buildBulletRunText
import com.m57.hermescontrol.ui.chat.markdown.coalesceBulletRuns
import com.m57.hermescontrol.ui.chat.markdown.parseBlocks
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
private val HIGHLIGHTS =
searchHighlightColors(
HermesStatusColors(
success = StatusGreen,
successContainer = StatusGreenContainer,
onSuccess = Color.White,
warning = StatusYellow,
warningContainer = StatusYellowContainer,
onWarning = Color.White,
error = StatusRed,
errorContainer = StatusRedContainer,
onError = Color.White,
info = StatusBlue,
infoContainer = StatusBlueContainer,
onInfo = Color.White,
),
)
private fun bullets(
n: Int,
text: (Int) -> String = { "item $it" },
) = (1..n).joinToString("\n") { "- ${text(it)}" }
private fun coalesce(md: String) = coalesceBulletRuns(parseBlocks(md))
/** Stand-in for TextMeasurer: prefix width grows with font size, like the real bullet glyph. */
private fun fakePrefixWidth(fontSizeSp: Float): (String) -> TextUnit =
{ prefix -> (prefix.length * fontSizeSp * 0.5f).sp }
private fun render(
run: BulletRun,
query: String = "",
density: Density = Density(1f),
fontSizeSp: Float = 14f,
) = buildBulletRunText(run, density, Color.Black, query, false, Color.Blue, HIGHLIGHTS, fakePrefixWidth(fontSizeSp))
class MarkdownBulletRunTest {
@Test
fun longPlainRunCoalescesIntoOneBlock() {
val out = coalesce(bullets(1000))
assertEquals(1, out.size)
assertEquals(1000, (out.single() as BulletRun).items.size)
}
@Test
fun shortRunStaysUncoalesced() {
val out = coalesce(bullets(MIN_BULLET_RUN - 1))
assertEquals(MIN_BULLET_RUN - 1, out.size)
assertTrue(out.all { it is MdBlock.Bullet })
}
@Test
fun runAtThresholdCoalesces() {
assertTrue(coalesce(bullets(MIN_BULLET_RUN)).single() is BulletRun)
}
@Test
fun rtlBulletBreaksTheRunAndKeepsOldRenderer() {
val md = bullets(40) { if (it == 20) "مرحبا بالعالم" else "item $it" }
val out = coalesce(md)
assertEquals(3, out.size)
assertEquals(19, (out[0] as BulletRun).items.size)
assertTrue(out[1] is MdBlock.Bullet)
assertEquals(20, (out[2] as BulletRun).items.size)
}
@Test
fun fullyRtlListNeverCoalesces() {
val out = coalesce(bullets(40) { "مرحبا $it" })
assertTrue(out.none { it is BulletRun })
}
@Test
fun inlineMathBreaksTheRun() {
val out = coalesce(bullets(40) { if (it == 20) "energy \$E=mc^2\$" else "item $it" })
assertTrue(out[1] is MdBlock.Bullet)
assertEquals(3, out.size)
}
@Test
fun nestedBulletsStayInTheRunWithLevelGlyphAndIndent() {
val md = bullets(20) + "\n - child\n - grandchild\n" + bullets(20)
val run = coalesce(md).single() as BulletRun
assertEquals(42, run.items.size)
assertEquals(
listOf(0, 1, 2, 0),
listOf(run.items[19], run.items[20], run.items[21], run.items[22]).map { it.level },
)
val text = render(run)
val lines = text.text.split("\n")
assertTrue(lines[20].startsWith("\u25E6"))
assertTrue(lines[21].startsWith("\u25AA"))
val indents =
text.paragraphStyles.map {
it.item.textIndent!!
.firstLine.value
}
assertEquals(listOf(0f, 16f, 32f, 0f), listOf(indents[19], indents[20], indents[21], indents[22]))
}
@Test
fun bulletWithNestedSourceFallsBack() {
val md = bullets(20) + "\n- parent\n ```\n code\n ```\n" + bullets(20)
val out = coalesce(md)
assertTrue(out.any { it is MdBlock.Bullet && it.nestedSource.isNotEmpty() })
assertTrue(out.filterIsInstance<BulletRun>().flatMap { it.items }.all { it.nestedSource.isEmpty() })
}
@Test
fun taskAndOrderedItemsNeverJoinARun() {
val md = bullets(20) + "\n- [ ] todo\n1. one\n2. two\n" + bullets(20)
val out = coalesce(md)
assertEquals(2, out.count { it is BulletRun })
assertTrue(out.any { it is MdBlock.Task })
assertTrue(out.any { it is MdBlock.Ordered })
}
@Test
fun surroundingBlocksAreUntouchedAndOrdered() {
val out = coalesce("# Title\n\n" + bullets(30) + "\n\nTail paragraph")
assertTrue(out.first() is MdBlock.Heading)
assertTrue(out[1] is BulletRun)
assertTrue(out.last() is MdBlock.Paragraph)
}
@Test
fun mergedTextKeepsEveryLineWithGlyphs() {
val run = coalesce(bullets(20)).single() as BulletRun
val lines = render(run).text.split("\n")
assertEquals(20, lines.size)
assertTrue(lines.all { it.startsWith("\u2022") })
assertTrue(lines.last().endsWith("item 20"))
}
@Test
fun linksSurviveMerging() {
val run =
coalesce(bullets(20) { if (it == 5) "see [docs](https://example.com/a) now" else "item $it" })
.single() as BulletRun
val text = render(run)
val links = text.getLinkAnnotations(0, text.length)
assertEquals(1, links.size)
assertEquals("https://example.com/a", (links.single().item as LinkAnnotation.Url).url)
assertEquals("docs", text.text.substring(links.single().start, links.single().end))
}
@Test
fun bareUrlsStayLinks() {
val run = coalesce(bullets(20) { if (it == 3) "https://example.com/x" else "item $it" }).single() as BulletRun
val text = render(run)
assertEquals(1, text.getLinkAnnotations(0, text.length).size)
}
@Test
fun searchHighlightLandsOnTheMatchOffsets() {
val run = coalesce(bullets(20)).single() as BulletRun
val text = render(run, query = "item 7")
val spans = text.spanStyles.filter { it.item.background != Color.Unspecified }
assertTrue(spans.isNotEmpty())
spans.forEach { assertEquals("item 7", text.text.substring(it.start, it.end)) }
}
@Test
fun noHighlightWithoutQuery() {
val run = coalesce(bullets(20)).single() as BulletRun
assertTrue(render(run).spanStyles.none { it.item.background != Color.Unspecified })
}
@Test
fun levelOffsetIsPhysicalDpAcrossFontScales() {
val run = coalesce(bullets(20) + "\n - child\n").single() as BulletRun
val normal = render(run, density = Density(2f, fontScale = 1f))
val big = render(run, density = Density(2f, fontScale = 2f))
val n =
normal.paragraphStyles[20]
.item.textIndent!!
.firstLine.value
val b =
big.paragraphStyles[20]
.item.textIndent!!
.firstLine.value
// 16dp at density 2 = 32px; sp value shrinks by fontScale so physical size stays 16dp.
assertEquals(n, b * 2f, 0.001f)
}
@Test
fun hangingIndentIncludesTheFontRelativePrefix() {
val run = coalesce(bullets(20)).single() as BulletRun
val small =
render(run, fontSizeSp = 14f)
.paragraphStyles
.first()
.item.textIndent!!
val large =
render(run, fontSizeSp = 28f)
.paragraphStyles
.first()
.item.textIndent!!
// Wrapped lines must sit right of the first line by the prefix width, which grows with the font.
assertTrue(small.restLine.value > small.firstLine.value)
assertEquals(
(small.restLine.value - small.firstLine.value) * 2f,
large.restLine.value - large.firstLine.value,
0.001f,
)
}
@Test
fun oneParagraphStylePerBullet() {
val run = coalesce(bullets(50)).single() as BulletRun
assertEquals(50, render(run).paragraphStyles.size)
}
}
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.ui.chat.markdown.parseBlocks
import org.junit.Test
import kotlin.system.measureNanoTime
import kotlin.system.measureTimeMillis
class MarkdownTextBenchmark {
@@ -47,4 +48,19 @@ class MarkdownTextBenchmark {
println("BENCHMARK_RESULT: parseBlocks 10000 iterations took ${time}ms")
}
@Test
fun benchmarkParseBlocksThousandBullets() {
val md = (1..1000).joinToString("\n") { "- $it" }
// Cold: first parse in this JVM, before any warmup.
val cold = measureNanoTime { parseBlocks(md) }
repeat(50) { parseBlocks(md) }
val iterations = 200
val warmNanos = measureNanoTime { repeat(iterations) { parseBlocks(md) } }
println("BENCHMARK_RESULT: parseBlocks 1000 bullets cold=${cold / 1_000_000.0}ms")
println("BENCHMARK_RESULT: parseBlocks 1000 bullets warm avg=${warmNanos / iterations / 1_000_000.0}ms")
}
}