The same pass the server had, on the Kotlin side: comments restating what the code says are gone, and the ones recording a measurement, a constraint or an incident are kept but cut to a few lines each. 6540 comment lines to 5674, and 920 lines off the app. Two doc comments had drifted onto the item above the one they describe -- `contextAfter`'s onto `sessionWorking` in Events.kt, and `UsageMonitor`'s equivalent on the server was fixed in the previous commit. Each is back on its own item, which is the only non-comment line this diff moves. The comments are reflowed to the column limit at their own indentation: several were written wide, and ktfmt re-wrapped them into lines holding a single orphan word. `/tmp` script, not kept -- ktfmt is idempotent over the result, which is the check. Left alone deliberately: this codebase's remaining comment density is high because the comments carry things the code cannot say -- what a null means, what a number was measured against, which bug a guard exists for. Of the 238 one-line doc comments in the app, five were pure restatement of the name and were removed; the rest each say something the signature does not. ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest pass; cargo test (127), clippy --all-targets and fmt still clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
321 lines
15 KiB
Kotlin
321 lines
15 KiB
Kotlin
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<TextLayoutResult>() }
|
|
// 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<List<Rect>>() }
|
|
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<Rect> {
|
|
val rects = mutableListOf<Rect>()
|
|
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<AnnotatorSettings>() }
|
|
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<ASTNode>? = 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
|
|
}
|