diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt index e54b0c7..14790e8 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Markdown.kt @@ -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, - ) -} - -@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, - ) + 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, + ) + }, + ) + } + } } /** diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownLinks.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownLinks.kt index 6e454f7..ca8cb5c 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownLinks.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownLinks.kt @@ -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() } 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"