perf(markdown): coalesce long plain bullet runs (#1415)
This commit is contained in:
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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user