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:
1 parent
3c3b4e62fd
commit
a9ab9b3e5a
2 files changed
+90
-59
No files matched your search
@@ -1,5 +1,8 @@
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -8,18 +11,22 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
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.TextStyle
|
||||
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.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.mikepenz.markdown.compose.LocalMarkdownDimens
|
||||
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.MarkdownTableHeader
|
||||
import com.mikepenz.markdown.compose.elements.MarkdownTableRow
|
||||
import com.mikepenz.markdown.m3.Markdown
|
||||
import com.mikepenz.markdown.m3.elements.MarkdownCheckBox
|
||||
import com.mikepenz.markdown.m3.markdownColor
|
||||
@@ -31,6 +38,7 @@ import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
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.
|
||||
@@ -162,8 +170,12 @@ fun MarkdownText(text: String, replies: ParsedReplies, modifier: Modifier = Modi
|
||||
it.content,
|
||||
it.node,
|
||||
style = it.typography.table,
|
||||
headerBlock = ::WrappingTableHeader,
|
||||
rowBlock = ::WrappingTableRow,
|
||||
headerBlock = { content, row, width, style ->
|
||||
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
|
||||
* a table is simply not readable: a column is 160dp at its narrowest, so anything past about twenty
|
||||
* characters ends in "..." with no way to see the rest. Nothing about the value says it was cut,
|
||||
* either -- an elided cell looks like a short one, so a table of measurements reads as a table of
|
||||
* plausible shorter measurements.
|
||||
* 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
|
||||
* 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
|
||||
* 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
|
||||
* never has anything to hide since the height grows to fit. Cells align 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 the reader was reading across.
|
||||
* 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
|
||||
* the reader was reading across. What the wrapping does *not* do is make a wide table fit. The
|
||||
* 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
|
||||
* a 160dp minimum and scrolls the whole table sideways when they do not fit the screen, 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. 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.
|
||||
* The semantics are the renderer's: each cell is an item of the table's collection, and a header
|
||||
* cell is a heading.
|
||||
*/
|
||||
@Composable
|
||||
private fun WrappingTableHeader(
|
||||
private fun LinkedTableRow(
|
||||
content: String,
|
||||
header: ASTNode,
|
||||
row: ASTNode,
|
||||
tableWidth: Dp,
|
||||
style: TextStyle,
|
||||
header: Boolean,
|
||||
) {
|
||||
MarkdownTableHeader(
|
||||
content = content,
|
||||
header = header,
|
||||
tableWidth = tableWidth,
|
||||
style = style,
|
||||
verticalAlignment = Alignment.Top,
|
||||
maxLines = Int.MAX_VALUE,
|
||||
overflow = TextOverflow.Clip,
|
||||
val padding = LocalMarkdownDimens.current.tableCellPadding
|
||||
val rowIndex = if (header) 0 else LocalTableRowIndex.current
|
||||
val cellStyle = if (header) style.copy(fontWeight = FontWeight.Bold) else style
|
||||
Row(verticalAlignment = Alignment.Top, modifier = Modifier.widthIn(tableWidth)) {
|
||||
row.children
|
||||
.filter { it.type == GFMTokenTypes.CELL }
|
||||
.forEachIndexed { column, cell ->
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
* offers no slot for them, so a link in a cell keeps the renderer's path -- correct, and dearer.
|
||||
* Reference-style links stay there too; the renderer resolves those against its definitions.
|
||||
* paragraphs inside lists, quotes and alerts, and so does every table cell through
|
||||
* [LinkedTableRow]. Reference-style links are the one kind still drawn the renderer's way; it
|
||||
* resolves those against its definitions.
|
||||
*/
|
||||
@Composable
|
||||
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 text =
|
||||
remember(model.content, model.node, style) {
|
||||
model.content.buildMarkdownAnnotatedString(model.node, style, settings)
|
||||
remember(content, node, style) {
|
||||
content.buildMarkdownAnnotatedString(node, style, settings)
|
||||
}
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val layout = remember { Ref<TextLayoutResult>() }
|
||||
MarkdownText(
|
||||
content = text,
|
||||
node = model.node,
|
||||
node = node,
|
||||
modifier =
|
||||
Modifier.then(if (heading) Modifier.semantics { heading() } else Modifier).pointerInput(
|
||||
text
|
||||
) {
|
||||
modifier.pointerInput(text) {
|
||||
detectTapGestures { position ->
|
||||
val url = text.linkAt(layout.value, position) ?: return@detectTapGestures
|
||||
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? {
|
||||
layout ?: return null
|
||||
val offset = layout.getOffsetForPosition(position)
|
||||
if (offset >= length || !layout.getBoundingBox(offset).contains(position)) return null
|
||||
return getStringAnnotations(LINK_URL, offset, offset).firstOrNull()?.item
|
||||
val caret = layout.getOffsetForPosition(position)
|
||||
val glyph =
|
||||
(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"
|
||||
|
||||
Reference in new issue
Block a user