Draw a long user message in slices, through a shared card-piece surface

The occasional bump left in an otherwise smooth transcript was the last
unbounded item: a pasted log in a user bubble is one Text whose layout runs
in the frame the row scrolls into -- 93,808px on the fixture, reported from
the phone as a 112ms worst measure. Per frame it was already cheap (one
node); the cost was entirely the entry.

A message past USER_SPLIT_CHARS is now cut at line starts into slices of
roughly 2,500 characters, each its own list unit. Lines lay out
independently, so slices that own whole lines stack back into exactly the
lines the single Text drew; the threshold is also what guarantees the
bubble was at full width, which the slices must share to read as one card.
Measured on the emulator, same fixture and gestures: worst transcript
measure 57.6ms -> 12.6ms. Fill continuity across slice seams and uniform
63px line pitch verified from full-resolution screenshots; a short message
keeps the ordinary wrapping bubble.

The corner-and-padding geometry that lets one visual card be several list
items now lives once, in Modifier.cardPiece -- Bryan asked for exactly this
generalization so future row types are cheap to add. An opened peer message
and a long user message are its two users; a new sliced kind needs only a
unit type, a flatten branch, and a body wrapped in cardPiece.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5 committed 2026-09-01 17:57:41 -04:00
1 parent 7a48f8ff1f
commit 333d2de92b
4 files changed
+163 -15

No files matched your search

@@ -16,6 +16,7 @@ import androidx.compose.runtime.Composable
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -44,7 +45,14 @@ fun PeerHeadRow(
onToggle: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier.peerSurface(top = true, bottom = !open, onToggle = onToggle)) {
Column(
modifier.cardPiece(
top = true,
bottom = !open,
fill = CardDefaults.cardColors().containerColor,
onPress = onToggle,
)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Message from ${item.from}", style = MaterialTheme.typography.titleSmall)
if (!open) {
@@ -77,7 +85,14 @@ fun PeerBlockRow(
onToggle: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier.peerSurface(top = false, bottom = last, onToggle = onToggle)) {
Column(
modifier.cardPiece(
top = false,
bottom = last,
fill = CardDefaults.cardColors().containerColor,
onPress = onToggle,
)
) {
// The gap the card's own column used to provide between its heading and its prose, and
// between one block and the next. Uniform, because both of those were 6dp already.
MarkdownText(text, replies, Modifier.padding(top = BLOCK_SPACING))
@@ -85,19 +100,26 @@ fun PeerBlockRow(
}
/**
* One piece of a peer message's card: the fill, the corners it owns, and the room inside it.
* One piece of a card drawn in slices: the fill, the corners it owns, and the room inside it.
*
* A filled Material card is elevation zero ([CardDefaults] takes it from `FilledCardTokens`, which
* is `Level0`), so there is no shadow that a seam would show through -- which is the whole reason
* the card can be cut up at all. Each piece paints the same container colour a
* is `Level0`), so there is no shadow that a seam would show through -- which is the whole reason a
* card can be cut up at all. Each piece paints the caller's container colour the way a
* [androidx.compose .material3.Card] would and rounds only the corners at the ends of the message,
* so the pieces abut into one continuous card.
* so the pieces abut into one continuous card. Shared by the two rows that are cut this way -- an
* opened peer message and a long user message -- because two copies of the corner logic is how one
* of them grows a seam.
*
* The padding is the other half of it: 12dp all round was the card's own, so the top piece keeps
* the top of it, the bottom piece the bottom, and the middle pieces neither.
*/
@Composable
private fun Modifier.peerSurface(top: Boolean, bottom: Boolean, onToggle: () -> Unit): Modifier {
fun Modifier.cardPiece(
top: Boolean,
bottom: Boolean,
fill: Color,
onPress: (() -> Unit)? = null,
): Modifier {
val square = CornerSize(0.dp)
val shape =
MaterialTheme.shapes.medium.copy(
@@ -108,15 +130,15 @@ private fun Modifier.peerSurface(top: Boolean, bottom: Boolean, onToggle: () ->
)
return fillMaxWidth()
.clip(shape)
.background(CardDefaults.cardColors().containerColor)
.clickable(onClick = onToggle)
.background(fill)
.then(if (onPress == null) Modifier else Modifier.clickable(onClick = onPress))
.padding(
start = PEER_PADDING,
end = PEER_PADDING,
top = if (top) PEER_PADDING else 0.dp,
bottom = if (bottom) PEER_PADDING else 0.dp,
start = CARD_PADDING,
end = CARD_PADDING,
top = if (top) CARD_PADDING else 0.dp,
bottom = if (bottom) CARD_PADDING else 0.dp,
)
}
/** The room inside a peer message's card, which was `Card { Column(padding(12.dp)) }`. */
private val PEER_PADDING = 12.dp
/** The room inside a sliced card, which was `Card { Column(padding(12.dp)) }`. */
private val CARD_PADDING = 12.dp