Draw an inline code chip behind the text instead of under it

The chip was the renderer's span background, and a span's background is
part of the text's own drawing: the text node paints the selection first
and the glyphs over it, so an opaque chip covered the selection and
selecting a sentence highlighted every word of it except the ones in
backticks. The previous fix let the selection show through by taking the
chip to 60% alpha, which is a compromise on both sides -- the chip is a
weaker step down from the page, and selected it reached #3C344F where the
words around it reached #776394.

There is a place that is under both, and a fenced block was already in it:
a modifier on the text rather than a style inside it. So `appendCodeChip`
takes the code span from the renderer's inline builder, keeps its style and
its space of padding either side but drops the background, and marks the
range; `LinkedText` draws those ranges in a `drawBehind`. The chip is back
to the full `rawSurface` fill (measured #11111B against a #1E1E2E page) and
a selection over it now lands at #776394, the same as the rest of the
sentence -- the fenced block's numbers exactly.

The geometry is one box per line, from the bounding boxes of the run's
first and last characters, taken as far as the line's `visibleEnd`. Not
`getPathForRange`: that is the shape of a *selection*, which runs to the
right edge of every line but the last, and a code span that wrapped left a
full-width empty chip behind on the line above -- twice in one fixture.
`visibleEnd` is the same rule the selection rectangle obeys, so the chip
stops where the selection stops instead of sticking its padding space out
past the end of a selected line.

Checked on the emulator against a fixture with chips in a heading, three
kinds of list item, a quote, a table cell and a link label, unselected and
under Select All, and a link with a chip in its label still opens. Cost,
against the same build without the change, streaming sixty paragraphs of
three chips each: measure 755ms against 776ms, record 327ms against 321ms,
transcript draw 0.22ms in both.
This commit is contained in:
iris committed 2026-09-03 23:32:17 -04:00
1 parent 9c4df43951
commit 3bb178363d
4 files changed
+119 -24

No files matched your search

@@ -9,7 +9,10 @@ import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.node.Ref
@@ -96,12 +99,23 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
val layout = remember { Ref<TextLayoutResult>() }
// 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
val chips = remember(text) { text.getStringAnnotations(CODE_CHIP, 0, text.length) }
val chipColor = LocalMarkdownColors.current.inlineCodeBackground
// Filled in by `onTextLayout`, which runs in the layout phase, so the draw of the same frame
// finds it set -- no state needed, and a relayout redraws the node anyway.
val chipFills = remember { Ref<List<Rect>>() }
val chipFill =
if (chips.isEmpty()) Modifier
else
Modifier.drawBehind {
chipFills.value?.forEach { drawRect(chipColor, it.topLeft, it.size) }
}
BasicText(
text = text,
modifier =
// A tap here is either a link or the card's; see [LocalMarkdownTap] for why the
// second one has to be answered from inside the text rather than left to the card.
modifier.pointerInput(text, onPlainTap) {
modifier.then(chipFill).pointerInput(text, onPlainTap) {
awaitEachGesture {
// Unconsumed is not required: something outside may already be tracking this
// press, and it is still the press that may land on a link.
@@ -130,7 +144,10 @@ fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modif
},
style = style,
color = { color },
onTextLayout = { layout.value = it },
onTextLayout = {
layout.value = it
chipFills.value = chips.flatMap { chip -> it.chipRects(chip.start, chip.end) }
},
)
}
@@ -188,15 +205,83 @@ private fun AnnotatedString.linkAt(layout: TextLayoutResult?, position: Offset):
private const val LINK_URL = "url"
/**
* The renderer's annotator settings with [appendPlainLink] answering for links. The annotator needs
* the settings to draw a link's label, and the settings hold the annotator, so the reference goes
* through a cell filled in once both exist.
* Appends [node] as inline code -- the renderer's own span, padded by a space each side as it does,
* but with no background of its own -- if it is a code span; false leaves anything else to the
* renderer.
*
* The chip's fill is drawn by [LinkedText] from the layout instead, behind the text. A span's
* background is part of the text's own drawing, and the text node draws the selection first and the
* glyphs over it, so a chip painted as a span background covered the selection: selecting a
* sentence highlighted every word of it except the ones in backticks. Anything drawn by a modifier
* on the text is under both, which is where a fenced block's box already is and why one of those
* always looked right. The [CODE_CHIP] annotation is what says where the fill goes.
*/
private fun appendCodeChip(
builder: AnnotatedString.Builder,
content: String,
node: ASTNode,
settings: AnnotatorSettings,
): Boolean {
if (node.type != MarkdownElementTypes.CODE_SPAN) return false
builder.pushStringAnnotation(CODE_CHIP, "")
builder.pushStyle(settings.codeSpanStyle.copy(background = Color.Unspecified))
builder.append(' ')
// The backticks are the first and last children.
builder.buildMarkdownAnnotatedString(content, node.children.drop(1).dropLast(1), settings)
builder.append(' ')
builder.pop()
builder.pop()
return true
}
private const val CODE_CHIP = "code"
/**
* One box per line of the text [start] until [end] covers, in the layout's own coordinates.
*
* Not `getPathForRange`, which is the geometry of a *selection* and runs to the right edge of every
* line but the last, so a chip whose code wrapped left a full-width empty box behind on the line
* above. Each line is taken as far as `visibleEnd`, which is where that line's own trailing space
* stops being drawn: the same rule the selection rectangle obeys, so the two agree rather than the
* chip sticking a space out past the end of a selected line. It is also what leaves nothing behind
* when the only thing to reach a line is the space a chip is padded with.
*
* A run's extent is taken from the boxes of its first and last characters, which is exact while a
* line reads in one direction; mixed directions inside a code span would draw one box across the
* whole run rather than one per direction, and code spans are code.
*/
private fun TextLayoutResult.chipRects(start: Int, end: Int): List<Rect> {
val rects = mutableListOf<Rect>()
for (line in getLineForOffset(start)..getLineForOffset(end - 1)) {
val from = maxOf(start, getLineStart(line))
val to = minOf(end, getLineEnd(line, visibleEnd = true))
if (from >= to) continue
val head = getBoundingBox(from)
val tail = getBoundingBox(to - 1)
rects +=
Rect(
left = minOf(head.left, tail.left),
top = minOf(head.top, tail.top),
right = maxOf(head.right, tail.right),
bottom = maxOf(head.bottom, tail.bottom),
)
}
return rects
}
/**
* The renderer's annotator settings with [appendPlainLink] answering for links and [appendCodeChip]
* for inline code. The annotator needs the settings to draw a link's label, and the settings hold
* the annotator, so the reference goes through a cell filled in once both exist.
*/
@Composable
private fun plainLinkSettings(): AnnotatorSettings {
val cell = remember { Ref<AnnotatorSettings>() }
val annotator = remember {
markdownAnnotator { content, node -> appendPlainLink(this, content, node, cell.value!!) }
markdownAnnotator { content, node ->
appendPlainLink(this, content, node, cell.value!!) ||
appendCodeChip(this, content, node, cell.value!!)
}
}
return annotatorSettings(annotator = annotator).also { cell.value = it }
}