perf(markdown): coalesce long plain bullet runs (#1415)
This commit is contained in:
@@ -34,6 +34,7 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.LinkAnnotation
|
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.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.BaselineShift
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextDecoration
|
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.LocalHermesStatusColors
|
||||||
import com.m57.hermescontrol.theme.SearchHighlightColors
|
import com.m57.hermescontrol.theme.SearchHighlightColors
|
||||||
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.FnNote
|
||||||
import com.m57.hermescontrol.ui.chat.markdown.InlineMathSegment
|
import com.m57.hermescontrol.ui.chat.markdown.InlineMathSegment
|
||||||
import com.m57.hermescontrol.ui.chat.markdown.MarkdownInlineStyler
|
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.MarkdownTable
|
||||||
import com.m57.hermescontrol.ui.chat.markdown.MdBlock
|
import com.m57.hermescontrol.ui.chat.markdown.MdBlock
|
||||||
import com.m57.hermescontrol.ui.chat.markdown.TableAlign
|
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.parseBlocks
|
||||||
import com.m57.hermescontrol.ui.chat.markdown.splitInlineMath
|
import com.m57.hermescontrol.ui.chat.markdown.splitInlineMath
|
||||||
import com.m57.hermescontrol.util.BidiUtils
|
import com.m57.hermescontrol.util.BidiUtils
|
||||||
@@ -100,7 +105,7 @@ fun MarkdownText(
|
|||||||
val statusColors = LocalHermesStatusColors.current
|
val statusColors = LocalHermesStatusColors.current
|
||||||
val highlights = searchHighlightColors(statusColors)
|
val highlights = searchHighlightColors(statusColors)
|
||||||
val linkColor = MaterialTheme.colorScheme.primary
|
val linkColor = MaterialTheme.colorScheme.primary
|
||||||
val blocks = remember(text, isStreaming) { parseBlocks(text) }
|
val blocks = remember(text, isStreaming) { coalesceBulletRuns(parseBlocks(text)) }
|
||||||
val latexMeasurer = rememberLatexMeasurer()
|
val latexMeasurer = rememberLatexMeasurer()
|
||||||
|
|
||||||
Column(modifier = modifier.fillMaxWidth()) {
|
Column(modifier = modifier.fillMaxWidth()) {
|
||||||
@@ -151,6 +156,7 @@ fun MarkdownText(
|
|||||||
CompositionLocalProvider(LocalLayoutDirection provides blockDirection) {
|
CompositionLocalProvider(LocalLayoutDirection provides blockDirection) {
|
||||||
MarkdownInlineText(
|
MarkdownInlineText(
|
||||||
text = block.text,
|
text = block.text,
|
||||||
|
isRtlOverride = isRtl,
|
||||||
textColor = textColor,
|
textColor = textColor,
|
||||||
latexMeasurer = latexMeasurer,
|
latexMeasurer = latexMeasurer,
|
||||||
style =
|
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 -> {
|
is MdBlock.Bullet -> {
|
||||||
val indent = (block.level * 16).dp
|
val indent = (block.level * 16).dp
|
||||||
val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) }
|
val isRtl = remember(block.text) { BidiUtils.isRtlText(block.text) }
|
||||||
@@ -195,6 +242,7 @@ fun MarkdownText(
|
|||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
MarkdownInlineText(
|
MarkdownInlineText(
|
||||||
text = block.text,
|
text = block.text,
|
||||||
|
isRtlOverride = isRtl,
|
||||||
textColor = textColor,
|
textColor = textColor,
|
||||||
latexMeasurer = latexMeasurer,
|
latexMeasurer = latexMeasurer,
|
||||||
style =
|
style =
|
||||||
@@ -254,6 +302,7 @@ fun MarkdownText(
|
|||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
MarkdownInlineText(
|
MarkdownInlineText(
|
||||||
text = block.text,
|
text = block.text,
|
||||||
|
isRtlOverride = isRtl,
|
||||||
textColor = textColor,
|
textColor = textColor,
|
||||||
latexMeasurer = latexMeasurer,
|
latexMeasurer = latexMeasurer,
|
||||||
style =
|
style =
|
||||||
@@ -301,6 +350,7 @@ fun MarkdownText(
|
|||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
MarkdownInlineText(
|
MarkdownInlineText(
|
||||||
text = block.text,
|
text = block.text,
|
||||||
|
isRtlOverride = isRtl,
|
||||||
textColor = textColor,
|
textColor = textColor,
|
||||||
latexMeasurer = latexMeasurer,
|
latexMeasurer = latexMeasurer,
|
||||||
style =
|
style =
|
||||||
@@ -347,6 +397,7 @@ fun MarkdownText(
|
|||||||
if (nestedBlocks.size == 1 && nestedBlocks.single() is MdBlock.Paragraph) {
|
if (nestedBlocks.size == 1 && nestedBlocks.single() is MdBlock.Paragraph) {
|
||||||
MarkdownInlineText(
|
MarkdownInlineText(
|
||||||
text = block.text,
|
text = block.text,
|
||||||
|
isRtlOverride = isRtl,
|
||||||
textColor = textColor,
|
textColor = textColor,
|
||||||
latexMeasurer = latexMeasurer,
|
latexMeasurer = latexMeasurer,
|
||||||
style =
|
style =
|
||||||
@@ -502,6 +553,7 @@ fun MarkdownText(
|
|||||||
)
|
)
|
||||||
MarkdownInlineText(
|
MarkdownInlineText(
|
||||||
text = note.text,
|
text = note.text,
|
||||||
|
isRtlOverride = isRtl,
|
||||||
textColor = textColor,
|
textColor = textColor,
|
||||||
latexMeasurer = latexMeasurer,
|
latexMeasurer = latexMeasurer,
|
||||||
style =
|
style =
|
||||||
@@ -526,6 +578,7 @@ fun MarkdownText(
|
|||||||
CompositionLocalProvider(LocalLayoutDirection provides blockDirection) {
|
CompositionLocalProvider(LocalLayoutDirection provides blockDirection) {
|
||||||
MarkdownInlineText(
|
MarkdownInlineText(
|
||||||
text = block.text,
|
text = block.text,
|
||||||
|
isRtlOverride = isRtl,
|
||||||
textColor = textColor,
|
textColor = textColor,
|
||||||
latexMeasurer = latexMeasurer,
|
latexMeasurer = latexMeasurer,
|
||||||
style =
|
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,
|
linkColor: Color,
|
||||||
highlights: SearchHighlightColors,
|
highlights: SearchHighlightColors,
|
||||||
modifier: Modifier = Modifier,
|
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 direction = if (isRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
|
||||||
val resolvedStyle =
|
val resolvedStyle =
|
||||||
style.copy(
|
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 com.m57.hermescontrol.ui.chat.markdown.parseBlocks
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import kotlin.system.measureNanoTime
|
||||||
import kotlin.system.measureTimeMillis
|
import kotlin.system.measureTimeMillis
|
||||||
|
|
||||||
class MarkdownTextBenchmark {
|
class MarkdownTextBenchmark {
|
||||||
@@ -47,4 +48,19 @@ class MarkdownTextBenchmark {
|
|||||||
|
|
||||||
println("BENCHMARK_RESULT: parseBlocks 10000 iterations took ${time}ms")
|
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