Draw table cells as span-linked text, and hit-test links by glyph

Table rows are now ours: each cell is a LinkedText, so a link in a cell is a
span with a string annotation and one tap detector per cell rather than the
layout node Compose builds for every LinkAnnotation -- the cost the paragraph
change removed everywhere else. The renderer's outer table (width, sideways
scroll, corners, dividers) stays; the row and cell were the only parts it
offered no slot for. Cells still wrap and align to the top, with the same
semantics the renderer gave them.

Found while checking it: the hit test took the layout's nearest caret as the
glyph under the finger, so a tap on the right half of any link glyph named
the character after it and opened nothing. That was flaky in paragraphs
already; it now checks the glyph on either side of the caret.

Verified on the emulator: a linked cell and an autolink cell open their
addresses, a plain cell opens nothing, a six-column table still scrolls
sideways, and a link-free table draws as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-02 18:58:37 -04:00
1 parent 3c3b4e62fd
commit a9ab9b3e5a
2 files changed
+90 -59

No files matched your search

@@ -1,5 +1,8 @@
package com.example.aiapp package com.example.aiapp
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
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
@@ -8,18 +11,22 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.CollectionItemInfo
import androidx.compose.ui.semantics.collectionItemInfo
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextLinkStyles
import androidx.compose.ui.text.TextStyle 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.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp 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.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.MarkdownTable import com.mikepenz.markdown.compose.elements.MarkdownTable
import com.mikepenz.markdown.compose.elements.MarkdownTableHeader
import com.mikepenz.markdown.compose.elements.MarkdownTableRow
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
import com.mikepenz.markdown.m3.markdownColor import com.mikepenz.markdown.m3.markdownColor
@@ -31,6 +38,7 @@ 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.flavours.gfm.GFMTokenTypes
/** /**
* An assistant's reply, rendered as the markdown it is written in. * An assistant's reply, rendered as the markdown it is written in.
@@ -162,8 +170,12 @@ fun MarkdownText(text: String, replies: ParsedReplies, modifier: Modifier = Modi
it.content, it.content,
it.node, it.node,
style = it.typography.table, style = it.typography.table,
headerBlock = ::WrappingTableHeader, headerBlock = { content, row, width, style ->
rowBlock = ::WrappingTableRow, LinkedTableRow(content, row, width, style, header = true)
},
rowBlock = { content, row, width, style ->
LinkedTableRow(content, row, width, style, header = false)
},
) )
}, },
), ),
@@ -172,58 +184,58 @@ fun MarkdownText(text: String, replies: ParsedReplies, modifier: Modifier = Modi
} }
/** /**
* A table header, and a table row, whose cells wrap rather than being cut off. * One row of a table -- the header when [header] -- with every cell a [LinkedText].
* *
* The renderer draws every cell at `maxLines = 1` with an ellipsis, which on a phone means most of * The renderer's own rows draw each cell at `maxLines = 1` with an ellipsis, which on a phone means
* a table is simply not readable: a column is 160dp at its narrowest, so anything past about twenty * most of a table is simply not readable: anything past about twenty characters ends in "..." with
* characters ends in "..." with no way to see the rest. Nothing about the value says it was cut, * no way to see the rest, and an elided cell looks like a short one, so a table of measurements
* either -- an elided cell looks like a short one, so a table of measurements reads as a table of * reads as a table of plausible shorter measurements. And they draw a link in a cell as its own
* plausible shorter measurements. * layout node, the cost [LinkedText] exists to avoid. Both are decided inside the cell, where the
* 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, and [TextOverflow.Clip] rather than an ellipsis, which now * So: as many lines as the cell needs, cells aligned to the top of the row, because a two-line cell
* never has anything to hide since the height grows to fit. Cells align to the top of the row, * beside a one-line one centred the short one against the middle of the tall one and lost the line
* because a two-line cell beside a one-line one centred the short one against the middle of the * the reader was reading across. What the wrapping does *not* do is make a wide table fit. The
* tall one and lost the line the reader was reading across. * table gives each column its minimum width and scrolls sideways when they do not fit, which is the
* right answer for too many columns -- wrapping a six-column table into the width of a phone would
* give every cell one word per line.
* *
* What the wrapping does *not* do is make a wide table fit. The renderer already gives each column * The semantics are the renderer's: each cell is an item of the table's collection, and a header
* a 160dp minimum and scrolls the whole table sideways when they do not fit the screen, which is * cell is a heading.
* the right answer for too many columns -- wrapping a six-column table into the width of a phone
* would give every cell one word per line. The two work together: the width is what the columns
* need, and the wrapping is what fills the space that width provides.
*
* Two functions rather than one because the renderer's header and row are separate composables --
* the header is bold and sizes itself to its tallest cell -- and the parameters that matter here
* are the same three in both.
*/ */
@Composable @Composable
private fun WrappingTableHeader( private fun LinkedTableRow(
content: String, content: String,
header: ASTNode, row: ASTNode,
tableWidth: Dp, tableWidth: Dp,
style: TextStyle, style: TextStyle,
header: Boolean,
) { ) {
MarkdownTableHeader( val padding = LocalMarkdownDimens.current.tableCellPadding
content = content, val rowIndex = if (header) 0 else LocalTableRowIndex.current
header = header, val cellStyle = if (header) style.copy(fontWeight = FontWeight.Bold) else style
tableWidth = tableWidth, Row(verticalAlignment = Alignment.Top, modifier = Modifier.widthIn(tableWidth)) {
style = style, row.children
verticalAlignment = Alignment.Top, .filter { it.type == GFMTokenTypes.CELL }
maxLines = Int.MAX_VALUE, .forEachIndexed { column, cell ->
overflow = TextOverflow.Clip, LinkedText(
content,
cell,
cellStyle,
Modifier.padding(padding).weight(1f).semantics {
if (header) heading()
collectionItemInfo =
CollectionItemInfo(
rowIndex = rowIndex,
rowSpan = 1,
columnIndex = column,
columnSpan = 1,
)
},
) )
} }
}
@Composable
private fun WrappingTableRow(content: String, row: ASTNode, tableWidth: Dp, style: TextStyle) {
MarkdownTableRow(
content = content,
header = row,
tableWidth = tableWidth,
style = style,
verticalAlignment = Alignment.Top,
maxLines = Int.MAX_VALUE,
overflow = TextOverflow.Clip,
)
} }
/** /**
@@ -44,26 +44,35 @@ import org.intellij.markdown.flavours.gfm.GFMTokenTypes
* style never defined a pressed style, so nothing visible changes. * style never defined a pressed style, so nothing visible changes.
* *
* Every block the renderer dispatches through its component table comes here, which includes the * Every block the renderer dispatches through its component table comes here, which includes the
* paragraphs inside lists, quotes and alerts. Table cells do not: the table draws its own cells and * paragraphs inside lists, quotes and alerts, and so does every table cell through
* offers no slot for them, so a link in a cell keeps the renderer's path -- correct, and dearer. * [LinkedTableRow]. Reference-style links are the one kind still drawn the renderer's way; it
* Reference-style links stay there too; the renderer resolves those against its definitions. * resolves those against its definitions.
*/ */
@Composable @Composable
fun LinkedText(model: MarkdownComponentModel, style: TextStyle, heading: Boolean = false) { fun LinkedText(model: MarkdownComponentModel, style: TextStyle, heading: Boolean = false) {
LinkedText(
model.content,
model.node,
style,
if (heading) Modifier.semantics { heading() } else Modifier,
)
}
/** The inline content of [node] within [content], drawn as [LinkedText] describes. */
@Composable
fun LinkedText(content: String, node: ASTNode, style: TextStyle, modifier: Modifier = Modifier) {
val settings = plainLinkSettings() val settings = plainLinkSettings()
val text = val text =
remember(model.content, model.node, style) { remember(content, node, style) {
model.content.buildMarkdownAnnotatedString(model.node, style, settings) content.buildMarkdownAnnotatedString(node, style, settings)
} }
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val layout = remember { Ref<TextLayoutResult>() } val layout = remember { Ref<TextLayoutResult>() }
MarkdownText( MarkdownText(
content = text, content = text,
node = model.node, node = node,
modifier = modifier =
Modifier.then(if (heading) Modifier.semantics { heading() } else Modifier).pointerInput( modifier.pointerInput(text) {
text
) {
detectTapGestures { position -> detectTapGestures { position ->
val url = text.linkAt(layout.value, position) ?: return@detectTapGestures val url = text.linkAt(layout.value, position) ?: return@detectTapGestures
uriHandler.openUri(url) uriHandler.openUri(url)
@@ -74,12 +83,22 @@ fun LinkedText(model: MarkdownComponentModel, style: TextStyle, heading: Boolean
) )
} }
/** 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.
*
* The layout answers with a caret, the boundary nearest the finger, so a tap on the right half of a
* glyph names the character after it; the glyph under the finger is the one on either side of that
* boundary whose box holds the point. Checked with the box rather than assumed, so a tap past the
* end of a line ending in a link opens nothing.
*/
private fun AnnotatedString.linkAt(layout: TextLayoutResult?, position: Offset): String? { private fun AnnotatedString.linkAt(layout: TextLayoutResult?, position: Offset): String? {
layout ?: return null layout ?: return null
val offset = layout.getOffsetForPosition(position) val caret = layout.getOffsetForPosition(position)
if (offset >= length || !layout.getBoundingBox(offset).contains(position)) return null val glyph =
return getStringAnnotations(LINK_URL, offset, offset).firstOrNull()?.item (caret - 1..caret).firstOrNull {
it in 0 until length && layout.getBoundingBox(it).contains(position)
} ?: return null
return getStringAnnotations(LINK_URL, glyph, glyph + 1).firstOrNull()?.item
} }
private const val LINK_URL = "url" private const val LINK_URL = "url"