Draw text on the platform directly, own the table's outer box, and parse a live reply a block at a time

Three costs left in the renderer's composition layer, taken one at a time:

The text leaf. Every paragraph went through the renderer's text composable,
which exists to place inline images and charged each text for the
possibility: a placement callback, a derived map of inline content, a
semantics group and a size animation. A paragraph with no image -- nearly
all of them -- now goes straight to BasicText, with the renderer's own rule
for a style that names no colour. One with an image keeps the old path.

The table. The renderer decided "spread or scroll" with a BoxWithConstraints,
a subcomposition. LinkedTable does it with one layout modifier placed after
the horizontal scroll: fillMaxWidth fixes the minimum to the room, the scroll
passes that minimum through while lifting the maximum, and the modifier sizes
the rows to the larger of the room and the columns' floor. Rows no longer
need a width handed to them or a row index from a composition local.

The live reply. Every delta reparsed the whole message off-thread; for a
long reply that was tens of milliseconds hundreds of times, every core busy
while the frame's thread waited. LiveParse freezes every top-level block that
a later block has started after -- markdown's block rules make that safe --
and reparses only the tail. Each piece is keyed by where it starts in the
message, so a block keeps its composition when it freezes.

Verified on the emulator: the block-kind fixture draws the same with links
opening from a paragraph and a bullet and plain text opening nothing; a
six-column table still scrolls sideways; a 58-word mixed stream of list,
fence, table and quote drew every block as it arrived, 47 tail reparses at
1.7ms mean against whole-message parses before. ktfmt, build and lint clean
but for the AGP 9.4.0 notice.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-03 00:00:01 -04:00
1 parent 48bb7de304
commit 4bc69e8f9c
2 files changed
+251 -125

No files matched your search

@@ -1,10 +1,13 @@
package com.example.aiapp package com.example.aiapp
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@@ -16,7 +19,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.layout
import androidx.compose.ui.semantics.CollectionInfo
import androidx.compose.ui.semantics.CollectionItemInfo import androidx.compose.ui.semantics.CollectionItemInfo
import androidx.compose.ui.semantics.collectionInfo
import androidx.compose.ui.semantics.collectionItemInfo import androidx.compose.ui.semantics.collectionItemInfo
import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
@@ -25,13 +31,12 @@ import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.mikepenz.markdown.compose.LocalMarkdownColors
import com.mikepenz.markdown.compose.LocalMarkdownDimens import com.mikepenz.markdown.compose.LocalMarkdownDimens
import com.mikepenz.markdown.compose.components.markdownComponents import com.mikepenz.markdown.compose.components.markdownComponents
import com.mikepenz.markdown.compose.elements.LocalTableRowIndex import com.mikepenz.markdown.compose.elements.MarkdownDivider
import com.mikepenz.markdown.compose.elements.MarkdownTable
import com.mikepenz.markdown.compose.elements.listDepth import com.mikepenz.markdown.compose.elements.listDepth
import com.mikepenz.markdown.m3.Markdown import com.mikepenz.markdown.m3.Markdown
import com.mikepenz.markdown.m3.elements.MarkdownCheckBox import com.mikepenz.markdown.m3.elements.MarkdownCheckBox
@@ -44,28 +49,22 @@ import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.intellij.markdown.ast.ASTNode import org.intellij.markdown.ast.ASTNode
import org.intellij.markdown.ast.findChildOfType
import org.intellij.markdown.flavours.gfm.GFMElementTypes
import org.intellij.markdown.flavours.gfm.GFMTokenTypes import org.intellij.markdown.flavours.gfm.GFMTokenTypes
/** /**
* An assistant's reply, rendered as the markdown it is written in. * [text] drawn as its pieces, one under the other; see [Piece].
* *
* The parsing is the library's. Markdown is somebody else's specification, and a hand-written * [live] is the reply still arriving, and two things are different for it. Its parse is incremental
* subset of one disagrees with it at the edges -- which is where the bug reports come from, one * -- see [LiveParse] -- so a delta costs a parse of the block it landed in rather than of the whole
* case at a time. This file's whole job is the mapping onto the app's palette and type scale. * message. And its pieces get a layer each: when drawing is invalidated, only the piece that
* * changed is re-recorded instead of the whole reply, which is worth a great deal while every delta
* Colours come from the theme rather than from the renderer's defaults, so code, links and rules * invalidates the message and a finished one can be twenty-five screens tall. It is worth nothing
* are the same Catppuccin values the rest of the app uses. Nothing here picks a colour of its own. * once the message stops changing -- measured on a Pixel 9 Pro XL, whole rows were re-recorded 65
*/ * times in fifty seconds of reading -- and it is not free: each layer is a layout node and a
/** * display list held for the life of the row, and live node count is what the per-frame cost of the
* [text] drawn as its pieces, one under the other; see [Piece]. [live] is the reply still arriving, * transcript scales with.
* and it is the only message whose pieces get a layer each. A layer buys one thing here: when
* drawing is invalidated, only the piece that changed is re-recorded instead of the whole reply.
* That is worth a great deal while a reply is streaming, because every delta invalidates the
* message and a finished one can be twenty-five screens tall. It is worth nothing once the message
* stops changing -- measured on a Pixel 9 Pro XL, whole rows were re-recorded 65 times in fifty
* seconds of reading -- and it is not free: each layer is a layout node and a display list held for
* the life of the row, and live node count is what the per-frame cost of the transcript scales
* with.
*/ */
@Composable @Composable
fun MarkdownText( fun MarkdownText(
@@ -74,36 +73,150 @@ fun MarkdownText(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
live: Boolean = false, live: Boolean = false,
) { ) {
val parse = parsedMarkdown(text, replies) val segments =
val pieces = remember(parse) { pieces(parse) } if (live) liveSegments(text)
MarkdownRoot(parse) { else remember(text) { listOf(Segment(text, 0, replies.of(text), replies.piecesOf(text))) }
Column(modifier.fillMaxWidth()) { Column(modifier.fillMaxWidth()) {
var previous: Piece? = null var previous: Piece? = null
pieces.forEach { piece -> var previousSegment: Segment? = null
// Keyed by address rather than by position in this column, so a delta that segments.forEach { segment ->
// lands in the last paragraph leaves every other piece's composition alone. MarkdownRoot(segment.parse) {
key(piece) { segment.pieces.forEach { piece ->
MarkdownPiece( val gap =
parse, when {
text, previousSegment == null -> 0.dp
piece, previousSegment !== segment -> BLOCK_SPACING
Modifier.padding( else -> gapBefore(previous, piece)
top = if (previous == null) 0.dp else gapBefore(previous, piece) }
) // Keyed by where the piece starts in the message rather than by its position
.then(if (live) Modifier.graphicsLayer() else Modifier) // in this column, so a delta landing in the last block leaves every other
.drawWithContent { // piece's composition alone -- and a block keeps its key when it freezes.
val started = System.nanoTime() key(segment.start, piece) {
drawContent() MarkdownPiece(
DebugStats.record("record: one block", System.nanoTime() - started) segment.parse,
}, segment.text,
) piece,
Modifier.padding(top = gap)
.then(if (live) Modifier.graphicsLayer() else Modifier)
.drawWithContent {
val started = System.nanoTime()
drawContent()
DebugStats.record(
"record: one block",
System.nanoTime() - started,
)
},
)
}
previous = piece
previousSegment = segment
} }
previous = piece
} }
} }
} }
} }
/**
* A stretch of a message with a parse of its own: the whole of a settled message, or one block or
* the unfinished tail of a live one. [start] is where [text] begins in the message.
*/
private class Segment(val text: String, val start: Int, val parse: State, val pieces: List<Piece>)
/**
* The live reply's segments: parsed on the composing thread the first time the row is drawn, and
* incrementally off it for every delta afterwards.
*
* The first parse has to be inline. The renderer's own asynchronous path draws an empty loading
* slot until its result arrives, so a row is measured at nothing before it is measured at its real
* height, and the transcript above it collapses and springs back. Seen with five replies on screen
* at once, every one of them blank, the whole conversation shrunk to fit a single screen; a moment
* later it was all there again. That is the "skipping up and down" this list must never do.
*
* Every parse after the first is off the composing thread, and the row keeps drawing the parse it
* already has until the new one lands, so there is never a frame without a height. What is on
* screen is always a real prefix of the reply rather than a guess at it; it is simply one parse
* behind.
*/
@Composable
private fun liveSegments(text: String): List<Segment> {
val parsed = remember {
mutableStateOf(
DebugStats.timed("markdown parsed while composing") { LiveParse.whole(text) }
)
}
LaunchedEffect(text) {
if (parsed.value.text == text) return@LaunchedEffect
val previous = parsed.value
parsed.value =
withContext(Dispatchers.Default) {
DebugStats.timed("markdown reparsed while streaming") { previous.advanceTo(text) }
}
}
return parsed.value.segments
}
/**
* A reply still arriving, parsed a block at a time.
*
* Reparsing the whole message per delta was fine for a short reply and not for a long one: a
* twenty-five-screen reply parses in tens of milliseconds, hundreds of times, and although that ran
* off the composing thread it was every core busy while the frame's own thread waited for one.
* Markdown's blocks make the cut safe: a top-level block that another block has started *after* is
* finished -- nothing appended later can reach back into it, since a paragraph ends at the blank
* line or the block that interrupts it, a fence at its closing fence, a list at the first line that
* is neither an item nor indented under one. So every block but the last is [frozen] with the parse
* that finished it, and only the tail -- the last block and whatever has arrived since -- is parsed
* again.
*
* What the cut gives up is one thing: a reference definition arriving later than a link that uses
* it, since the frozen block's parse never sees it. The link draws as its brackets until the reply
* settles and is parsed whole by [warm], which is the same moment every other transient of
* streaming is put right.
*/
private class LiveParse(
val text: String,
private val frozen: List<Segment>,
/** How much of [text] the frozen segments cover; the tail starts here. */
private val consumed: Int,
private val tail: Segment,
) {
val segments: List<Segment>
get() = frozen + tail
fun advanceTo(next: String): LiveParse {
// Anything but an append to what was frozen -- a message replaced, a stream reset --
// starts over.
if (!next.regionMatches(0, text, 0, consumed)) return whole(next)
val tailText = next.substring(consumed)
val parse = parseMarkdown(tailText)
val all = pieces(parse)
val blocks = all.map { it.block }.distinct()
if (blocks.size <= 1 || parse !is State.Success) {
return LiveParse(next, frozen, consumed, Segment(tailText, consumed, parse, all))
}
val done =
blocks.dropLast(1).map { block ->
Segment(tailText, consumed, parse, all.filter { it.block == block })
}
val cut = parse.node.children[blocks.last()].startOffset
val rest = tailText.substring(cut)
val restParse = parseMarkdown(rest)
return LiveParse(
next,
frozen + done,
consumed + cut,
Segment(rest, consumed + cut, restParse, pieces(restParse)),
)
}
companion object {
fun whole(text: String): LiveParse {
val parse = parseMarkdown(text)
return LiveParse(text, emptyList(), 0, Segment(text, 0, parse, pieces(parse)))
}
}
}
/** One [piece] of [text], drawn on its own -- a unit of the transcript list. */ /** One [piece] of [text], drawn on its own -- a unit of the transcript list. */
@Composable @Composable
fun MarkdownPiece( fun MarkdownPiece(
@@ -254,19 +367,7 @@ private fun MarkdownRoot(parse: State, content: @Composable () -> Unit) {
// so they draw like the top-level ones the transcript cuts into items. // so they draw like the top-level ones the transcript cuts into items.
orderedList = { MarkdownList(it.content, it.node, it.listDepth) }, orderedList = { MarkdownList(it.content, it.node, it.listDepth) },
unorderedList = { MarkdownList(it.content, it.node, it.listDepth) }, unorderedList = { MarkdownList(it.content, it.node, it.listDepth) },
table = { table = { LinkedTable(it.content, it.node, it.typography.table) },
MarkdownTable(
it.content,
it.node,
style = it.typography.table,
headerBlock = { content, row, width, style ->
LinkedTableRow(content, row, width, style, header = true)
},
rowBlock = { content, row, width, style ->
LinkedTableRow(content, row, width, style, header = false)
},
)
},
), ),
modifier = Modifier, modifier = Modifier,
success = { _, _, _ -> content() }, success = { _, _, _ -> content() },
@@ -274,38 +375,76 @@ private fun MarkdownRoot(parse: State, content: @Composable () -> Unit) {
} }
/** /**
* One row of a table -- the header when [header] -- with every cell a [LinkedText]. * A table: its rows, on the renderer's tinted, rounded background, as wide as its columns need.
*
* Each column has a floor ([markdownDimens]'s `tableCellWidth`), so the table is at least
* columns-times-floor wide; narrower than the room it has, it spreads to fill it, and wider, it
* scrolls sideways rather than squeezing. The renderer decided that with a `BoxWithConstraints`,
* which is a subcomposition; here it is one layout modifier, and the trick is where it sits.
* `fillMaxWidth` fixes the minimum width to the room available, the horizontal scroll passes that
* minimum through to its content while lifting the maximum to unbounded, and the modifier after it
* reads the minimum back as the room and sizes the rows to the larger of that and the floor. The
* scroll then has exactly the overflow to scroll, which is none when the table fits.
*/
@Composable
private fun LinkedTable(content: String, node: ASTNode, style: TextStyle) {
val dimens = LocalMarkdownDimens.current
val colors = LocalMarkdownColors.current
val columns =
remember(node) {
node.findChildOfType(GFMElementTypes.HEADER)?.children?.count {
it.type == GFMTokenTypes.CELL
} ?: 0
}
val rows = remember(node) { node.children.count { it.type == GFMElementTypes.ROW } + 1 }
val floor = dimens.tableCellWidth * columns
Column(
Modifier.background(colors.tableBackground, RoundedCornerShape(dimens.tableCornerSize))
.semantics { collectionInfo = CollectionInfo(rowCount = rows, columnCount = columns) }
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.layout { measurable, constraints ->
val width = maxOf(constraints.minWidth, floor.roundToPx())
val placeable =
measurable.measure(constraints.copy(minWidth = width, maxWidth = width))
layout(width, placeable.height) { placeable.place(0, 0) }
}
) {
var rowIndex = 1
node.children.forEach { child ->
when (child.type) {
GFMElementTypes.HEADER -> LinkedTableRow(content, child, style, rowIndex = 0)
GFMElementTypes.ROW -> LinkedTableRow(content, child, style, rowIndex = rowIndex++)
GFMTokenTypes.TABLE_SEPARATOR -> MarkdownDivider()
}
}
}
}
/**
* One row of a table -- the header when [rowIndex] is zero -- with every cell a [LinkedText].
* *
* The renderer's own rows draw each cell at `maxLines = 1` with an ellipsis, which on a phone means * The renderer's own rows draw each cell at `maxLines = 1` with an ellipsis, which on a phone means
* most of a table is simply not readable: anything past about twenty characters ends in "..." with * most of a table is simply not readable: anything past about twenty characters ends in "..." with
* no way to see the rest, and an elided cell looks like a short one, so a table of measurements * no way to see the rest, and an elided cell looks like a short one, so a table of measurements
* reads as a table of plausible shorter measurements. And they draw a link in a cell as its own * reads as a table of plausible shorter measurements. And they draw a link in a cell as its own
* layout node, the cost [LinkedText] exists to avoid. Both are decided inside the cell, where the * layout node, the cost [LinkedText] exists to avoid.
* renderer offers no slot, so the row is ours: the outer table -- its width, sideways scroll,
* corners and row dividers -- is still the renderer's.
* *
* So: as many lines as the cell needs, cells aligned to the top of the row, because a two-line cell * So: as many lines as the cell needs, cells aligned to the top of the row, because a two-line cell
* beside a one-line one centred the short one against the middle of the tall one and lost the line * beside a one-line one centred the short one against the middle of the tall one and lost the line
* the reader was reading across. What the wrapping does *not* do is make a wide table fit. The * the reader was reading across. What the wrapping does *not* do is make a wide table fit;
* table gives each column its minimum width and scrolls sideways when they do not fit, which is the * [LinkedTable] scrolls it instead, which is the right answer for too many columns -- wrapping a
* right answer for too many columns -- wrapping a six-column table into the width of a phone would * six-column table into the width of a phone would give every cell one word per line.
* give every cell one word per line.
* *
* The semantics are the renderer's: each cell is an item of the table's collection, and a header * The semantics are the renderer's: each cell is an item of the table's collection, and a header
* cell is a heading. * cell is a heading.
*/ */
@Composable @Composable
private fun LinkedTableRow( private fun LinkedTableRow(content: String, row: ASTNode, style: TextStyle, rowIndex: Int) {
content: String,
row: ASTNode,
tableWidth: Dp,
style: TextStyle,
header: Boolean,
) {
val padding = LocalMarkdownDimens.current.tableCellPadding val padding = LocalMarkdownDimens.current.tableCellPadding
val rowIndex = if (header) 0 else LocalTableRowIndex.current val header = rowIndex == 0
val cellStyle = if (header) style.copy(fontWeight = FontWeight.Bold) else style val cellStyle = if (header) style.copy(fontWeight = FontWeight.Bold) else style
Row(verticalAlignment = Alignment.Top, modifier = Modifier.widthIn(tableWidth)) { Row(verticalAlignment = Alignment.Top, modifier = Modifier.fillMaxWidth()) {
row.children row.children
.filter { it.type == GFMTokenTypes.CELL } .filter { it.type == GFMTokenTypes.CELL }
.forEachIndexed { column, cell -> .forEachIndexed { column, cell ->
@@ -328,42 +467,6 @@ private fun LinkedTableRow(
} }
} }
/**
* [text] parsed: on the composing thread the first time this row is drawn, and off it every time
* afterwards.
*
* The first parse has to be inline. The renderer's own asynchronous path draws an empty loading
* slot until its result arrives, so a row is measured at nothing before it is measured at its real
* height, and the transcript above it collapses and springs back. Seen with five replies on screen
* at once, every one of them blank, the whole conversation shrunk to fit a single screen; a moment
* later it was all there again. That is the "skipping up and down" this list must never do, and no
* amount of scroll anchoring can survive a row that lies about its height first.
*
* Every parse *after* the first is a different case, and it is the one that was costing: a reply
* arrives as hundreds of deltas, each one re-parsing the whole message it has grown into. Measured
* against `/stream 200` on the emulator, that was fifty-eight parses and 78ms of main-thread work
* in three seconds, with single parses reaching 7ms -- most of a frame at 60Hz and more than one at
* 120. Those go to a background thread, and the row keeps drawing the parse it already has until
* the new one lands, so there is never a frame without a height. What is on screen is always a
* real prefix of the reply rather than a guess at it; it is simply one parse behind.
*/
@Composable
private fun parsedMarkdown(text: String, replies: ParsedReplies): State {
// The text each parse came from, so the first composition's is not immediately repeated.
val parsed = remember { mutableStateOf(text to replies.of(text)) }
LaunchedEffect(text) {
if (parsed.value.first == text) return@LaunchedEffect
// Not through [replies]: this is a reply still arriving, and every delta would leave
// another copy of a message that is about to be superseded.
parsed.value =
text to
withContext(Dispatchers.Default) {
DebugStats.timed("markdown reparsed while streaming") { parseMarkdown(text) }
}
}
return parsed.value.second
}
/** /**
* Replies parsed before the row that draws them is composed. * Replies parsed before the row that draws them is composed.
* *
@@ -1,10 +1,12 @@
package com.example.aiapp package com.example.aiapp
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.node.Ref import androidx.compose.ui.node.Ref
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
@@ -17,6 +19,7 @@ import androidx.compose.ui.text.TextStyle
import com.mikepenz.markdown.annotator.AnnotatorSettings import com.mikepenz.markdown.annotator.AnnotatorSettings
import com.mikepenz.markdown.annotator.annotatorSettings import com.mikepenz.markdown.annotator.annotatorSettings
import com.mikepenz.markdown.annotator.buildMarkdownAnnotatedString import com.mikepenz.markdown.annotator.buildMarkdownAnnotatedString
import com.mikepenz.markdown.compose.LocalMarkdownColors
import com.mikepenz.markdown.compose.components.MarkdownComponentModel import com.mikepenz.markdown.compose.components.MarkdownComponentModel
import com.mikepenz.markdown.compose.elements.MarkdownText import com.mikepenz.markdown.compose.elements.MarkdownText
import com.mikepenz.markdown.model.markdownAnnotator import com.mikepenz.markdown.model.markdownAnnotator
@@ -79,21 +82,41 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
} }
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val layout = remember { Ref<TextLayoutResult>() } val layout = remember { Ref<TextLayoutResult>() }
MarkdownText( val tapping =
content = text, modifier.pointerInput(text) {
node = node, detectTapGestures { position ->
modifier = val url = text.linkAt(layout.value, position) ?: return@detectTapGestures
modifier.pointerInput(text) { uriHandler.openUri(url)
detectTapGestures { position -> }
val url = text.linkAt(layout.value, position) ?: return@detectTapGestures }
uriHandler.openUri(url) // The renderer's text composable exists to place inline images, and it charges every text
} // for the possibility: a placement callback, a derived map of inline content, a semantics
}, // group and a size animation, per paragraph. Almost no paragraph has an image, so those go
style = style, // straight to the platform text; the few that do keep the renderer's path.
onTextLayout = { result, _ -> layout.value = result }, if (remember(node) { node.hasImage() }) {
) MarkdownText(
content = text,
node = node,
modifier = tapping,
style = style,
onTextLayout = { result, _ -> layout.value = result },
)
} else {
// The renderer's own rule for a style that names no colour: the theme's text colour.
val color = if (style.color.isSpecified) style.color else LocalMarkdownColors.current.text
BasicText(
text = text,
modifier = tapping,
style = style,
color = { color },
onTextLayout = { layout.value = it },
)
}
} }
private fun ASTNode.hasImage(): Boolean =
type == MarkdownElementTypes.IMAGE || children.any { it.hasImage() }
/** /**
* The address under [position], if a link's glyph is there rather than merely nearest to it. * The address under [position], if a link's glyph is there rather than merely nearest to it.
* *