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(