package com.example.aiapp import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.isSpecified import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.node.Ref import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextStyle import com.mikepenz.markdown.annotator.AnnotatorSettings import com.mikepenz.markdown.annotator.annotatorSettings import com.mikepenz.markdown.annotator.buildMarkdownAnnotatedString import com.mikepenz.markdown.compose.LocalMarkdownColors import com.mikepenz.markdown.compose.components.MarkdownComponentModel import com.mikepenz.markdown.model.markdownAnnotator import com.mikepenz.markdown.utils.getUnescapedTextInNode import com.mikepenz.markdown.utils.resolveImageAlt import com.mikepenz.markdown.utils.resolveImageLink import org.intellij.markdown.MarkdownElementTypes import org.intellij.markdown.MarkdownTokenTypes import org.intellij.markdown.ast.ASTNode import org.intellij.markdown.ast.findChildOfType import org.intellij.markdown.flavours.gfm.GFMTokenTypes /** * A paragraph, heading or bare text whose links are spans of the text rather than nodes of their * own. * * Compose turns every `LinkAnnotation` in a text into a layout node: a clipped, focusable, * hoverable, clickable box laid out against the glyphs, with its outline recomputed from the text * layout. A paragraph of eight links is therefore nine nodes, and the renderer emits one of those * annotations per link. Measured on the emulator against the same paragraphs with each link * replaced by its label and address as plain words -- *more* text, the same gestures -- the linked * version cost five times the worst measure (26.3ms against 5.2ms) and 1.7x the place time. On a * Pixel 9 Pro XL that was the bump at the list of sources in a reply, and nowhere else in it. * * Here a link is the link colour and underline, a string annotation carrying its address, and one * tap detector for the whole text that asks the layout which character was under the finger. What * that gives up is a link being its own accessibility node with a pressed state; the app's link * 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, 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. * * An image is a link too, carrying its alt text. The app has no image loader and the renderer's * transformer was the no-op one, so an image in a reply drew as nothing at all -- a hole where the * model put something, with no sign of what fell out. The link says what was there and where, and * opens it. It also means no paragraph needs the renderer's own text composable, which existed to * place inline images and charged every paragraph for the possibility. */ @Composable fun LinkedText(model: MarkdownComponentModel, style: TextStyle) { LinkedText(model.content, model.node, style) } /** * A heading. Its words are a child of the heading node -- `ATX_CONTENT` after the `#`s, or * `SETEXT_CONTENT` above the underline -- and the inline builder draws nothing for a node type it * does not know, so handed the heading node itself it draws an empty line. Which is what this did * for a week. */ @Composable fun LinkedHeading(model: MarkdownComponentModel, style: TextStyle) { val words = model.node.findChildOfType(MarkdownTokenTypes.ATX_CONTENT) ?: model.node.findChildOfType(MarkdownTokenTypes.SETEXT_CONTENT) ?: model.node LinkedText(model.content, words, style, Modifier.semantics { heading() }) } /** 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(content, node, style) { content.buildMarkdownAnnotatedString(node, style, settings) } val uriHandler = LocalUriHandler.current val layout = remember { Ref() } // 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 BasicText( text = text, modifier = modifier.pointerInput(text) { detectTapGestures { position -> val url = text.linkAt(layout.value, position) ?: return@detectTapGestures uriHandler.openUri(url) } }, style = style, color = { color }, onTextLayout = { layout.value = 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 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" /** * 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. */ @Composable private fun plainLinkSettings(): AnnotatorSettings { val cell = remember { Ref() } val annotator = remember { markdownAnnotator { content, node -> appendPlainLink(this, content, node, cell.value!!) } } return annotatorSettings(annotator = annotator).also { cell.value = it } } /** * Appends [node] as a styled, annotated span if it is a link the renderer would otherwise emit a * `LinkAnnotation` for, or an image it would place; false leaves anything else to the renderer. */ private fun appendPlainLink( builder: AnnotatedString.Builder, content: String, node: ASTNode, settings: AnnotatorSettings, ): Boolean { val destination: String /** The label's own inline nodes, when it has markup of its own to draw. */ var label: List? = null /** Plain words for the label; the address itself when there are none. */ var words: String? = null when (node.type) { MarkdownElementTypes.INLINE_LINK -> { val text = node.findChildOfType(MarkdownElementTypes.LINK_TEXT) ?: return false destination = node .findChildOfType(MarkdownElementTypes.LINK_DESTINATION) ?.getUnescapedTextInNode(content) ?.removeSurrounding("<", ">") ?: return false // The brackets are the first and last children of the label. label = text.children.drop(1).dropLast(1) } MarkdownElementTypes.AUTOLINK -> destination = node.getUnescapedTextInNode(content).removeSurrounding("<", ">") GFMTokenTypes.GFM_AUTOLINK -> destination = node.getUnescapedTextInNode(content) MarkdownElementTypes.IMAGE -> { destination = node.resolveImageLink(content, settings.referenceLinkHandler) ?: return false words = node.resolveImageAlt(content) } else -> return false } builder.pushStringAnnotation(LINK_URL, destination) builder.pushStyle(settings.linkTextSpanStyle.style ?: SpanStyle()) if (label != null) builder.buildMarkdownAnnotatedString(content, label, settings) else builder.append(words ?: destination) builder.pop() builder.pop() return true }