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(
|
||||
|
||||
Reference in new issue
Block a user