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 6c91c1d9..aa2c60d6 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 @@ -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 = diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownBulletRun.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownBulletRun.kt new file mode 100644 index 00000000..3fbe8b6f --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownBulletRun.kt @@ -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 + +/** 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, + minRun: Int = MIN_BULLET_RUN, +): List { + if (blocks.size < minRun) return blocks + val out = ArrayList(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') + } + } + } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineText.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineText.kt index 6270472b..6a96c1a5 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineText.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/markdown/MarkdownInlineText.kt @@ -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( diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownBulletRunBenchmark.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownBulletRunBenchmark.kt new file mode 100644 index 00000000..73526acd --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownBulletRunBenchmark.kt @@ -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() + // 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") } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownBulletRunTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownBulletRunTest.kt new file mode 100644 index 00000000..f42b8f1a --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownBulletRunTest.kt @@ -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().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) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextBenchmark.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextBenchmark.kt index cbe5511e..917ae076 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextBenchmark.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/MarkdownTextBenchmark.kt @@ -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") + } }