package com.example.aiapp import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color 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 annotation * 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. * * 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, and so does every * table cell. Reference-style links are the one kind still drawn the renderer's way. * * 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. The link says what was there and where, and opens it. */ @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. */ @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 onPlainTap = LocalMarkdownTap.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 val chips = remember(text) { text.getStringAnnotations(CODE_CHIP, 0, text.length) } val chipColor = LocalMarkdownColors.current.inlineCodeBackground // Filled in by `onTextLayout`, which runs in the layout phase, so the draw of the same frame // finds it set -- no state needed, and a relayout redraws the node anyway. val chipFills = remember { Ref>() } val chipFill = if (chips.isEmpty()) Modifier else Modifier.drawBehind { chipFills.value?.forEach { drawRect(chipColor, it.topLeft, it.size) } } BasicText( text = text, modifier = // A tap here is either a link or the card's; see [LocalMarkdownTap] for why the second // one has to be answered from inside the text rather than left to the card. modifier.then(chipFill).pointerInput(text, onPlainTap) { awaitEachGesture { // Unconsumed is not required: something outside may already be tracking this // press, and it is still the press that may land on a link. awaitFirstDown(requireUnconsumed = false) // A tap and nothing else. Null when the gesture became somebody else's -- a // scroll, or a press held past the long-press timeout, which is how a selection // starts. The timeout is the load-bearing half: without it a press held for a // second and released was still an up with nothing consumed, so holding a peer // message to select from it shut the card instead. val up = withTimeoutOrNull(viewConfiguration.longPressTimeoutMillis) { waitForUpOrCancellation() } ?: return@awaitEachGesture val url = text.linkAt(layout.value, up.position) when { url != null -> { up.consume() uriHandler.openUri(url) } onPlainTap != null -> { up.consume() onPlainTap() } } } }, style = style, color = { color }, onTextLayout = { layout.value = it chipFills.value = chips.flatMap { chip -> it.chipRects(chip.start, chip.end) } }, ) } /** * What a tap on markdown text means when it lands on no link -- shutting the card it is drawn in, * usually -- or null where a plain tap means nothing. * * A composition local because there is nowhere else to put it. The paragraphs of a message are * composed by the renderer's own dispatch, so nothing between a card and the text inside it is ours * to pass a parameter through. * * It exists because a pointer-input node over the glyphs takes the tap and the card's own click * handler never sees it. Measured against an opened peer message: with a handler on the text -- * consuming or not -- a tap on its words did nothing at all, and with the handler removed the same * tap shut the card. So a card whose body is markdown cannot be shut by pressing its words unless * the words do the shutting. * * Provided as a value that outlives a recomposition, since a fresh lambda per composition would * invalidate every paragraph reading it. */ val LocalMarkdownTap = compositionLocalOf<(() -> Unit)?> { null } /** * [onTap] as a stable value to provide for [LocalMarkdownTap]. The identity stays put while the * behaviour follows the latest [onTap], which is what keeps providing it from invalidating the text * under it on every recomposition of the card. */ @Composable fun rememberMarkdownTap(onTap: () -> Unit): () -> Unit { val latest = rememberUpdatedState(onTap) return remember { { latest.value() } } } /** * 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" /** * Appends [node] as inline code -- the renderer's own span, padded by a space each side as it does, * but with no background of its own -- if it is a code span; false leaves anything else to the * renderer. * * The chip's fill is drawn by [LinkedText] from the layout instead, behind the text. A span's * background is part of the text's own drawing, and the text node draws the selection first and the * glyphs over it, so a chip painted as a span background covered the selection: selecting a * sentence highlighted every word except the ones in backticks. Anything drawn by a modifier on the * text is under both, which is where a fenced block's box already is. */ private fun appendCodeChip( builder: AnnotatedString.Builder, content: String, node: ASTNode, settings: AnnotatorSettings, ): Boolean { if (node.type != MarkdownElementTypes.CODE_SPAN) return false builder.pushStringAnnotation(CODE_CHIP, "") builder.pushStyle(settings.codeSpanStyle.copy(background = Color.Unspecified)) builder.append(' ') // The backticks are the first and last children. builder.buildMarkdownAnnotatedString(content, node.children.drop(1).dropLast(1), settings) builder.append(' ') builder.pop() builder.pop() return true } private const val CODE_CHIP = "code" /** * One box per line of the text [start] until [end] covers, in the layout's own coordinates. * * Not `getPathForRange`, which is the geometry of a *selection* and runs to the right edge of every * line but the last, so a chip whose code wrapped left a full-width empty box behind on the line * above. Each line is taken as far as `visibleEnd`, which is where that line's own trailing space * stops being drawn -- the same rule the selection rectangle obeys, so the two agree. * * A run's extent is taken from the boxes of its first and last characters, which is exact while a * line reads in one direction; mixed directions inside a code span would draw one box across the * whole run, and code spans are code. */ private fun TextLayoutResult.chipRects(start: Int, end: Int): List { val rects = mutableListOf() for (line in getLineForOffset(start)..getLineForOffset(end - 1)) { val from = maxOf(start, getLineStart(line)) val to = minOf(end, getLineEnd(line, visibleEnd = true)) if (from >= to) continue val head = getBoundingBox(from) val tail = getBoundingBox(to - 1) rects += Rect( left = minOf(head.left, tail.left), top = minOf(head.top, tail.top), right = maxOf(head.right, tail.right), bottom = maxOf(head.bottom, tail.bottom), ) } return rects } /** * The renderer's annotator settings with [appendPlainLink] answering for links and [appendCodeChip] * for inline code. 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!!) || appendCodeChip(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 }