Files
ai-app/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownPieces.kt
T
irisandClaude Opus 5 edc39c7371 Thin the app's comments
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>
2026-09-04 16:20:16 -04:00

245 lines
9.8 KiB
Kotlin

package com.example.aiapp
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicText
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.mikepenz.markdown.compose.LocalMarkdownComponents
import com.mikepenz.markdown.compose.LocalMarkdownPadding
import com.mikepenz.markdown.compose.LocalMarkdownTypography
import com.mikepenz.markdown.compose.MarkdownElement
import com.mikepenz.markdown.compose.components.MarkdownComponentModel
import com.mikepenz.markdown.model.State
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.ast.getTextInNode
import org.intellij.markdown.flavours.gfm.GFMTokenTypes
/**
* One drawable piece of a parsed message: a top-level block, or one item of a top-level list.
*
* The point is the draw phase and the lazy list. A reply's display list holds every glyph of it and
* is re-recorded whenever drawing is invalidated, so one long message costs as much to draw as a
* hundred short ones; and the list composes an item whole in the frame it scrolls into. Measured on
* a Pixel 9 Pro XL, the tallest row still being drawn was 36,982px -- twenty-five screens in one
* message. A piece is a paragraph, a fence, a table, one bullet: bounded, so both costs are.
*
* Cut where the parser says the blocks are, which is what makes it safe: a fence, a table and a
* nested list are each one node whatever is inside them. A list is the one block that is not
* bounded -- a reply's list of sources can be forty items -- so it is cut once more, into its
* items.
*
* A piece is an *address* into the message's one parse rather than a substring of it. Every piece
* is drawn from the same tree, so a message is parsed once however many pieces it is drawn as, and
* a reference definition at its foot still resolves the links above it.
*/
@Immutable
data class Piece(val block: Int, val item: Int = WHOLE_BLOCK) {
companion object {
const val WHOLE_BLOCK = -1
}
}
/**
* The pieces of [parse], in reading order. Blank nodes between blocks are not pieces.
*
* A parse that failed yields one piece, so [MarkdownPiece] can still say what the message was: a
* message that drew as nothing would be a hole in the transcript with no sign of what fell out.
*/
fun pieces(parse: State): List<Piece> {
val success = parse as? State.Success ?: return listOf(Piece(0))
val out = ArrayList<Piece>()
success.node.children.forEachIndexed { at, node ->
when {
node.getTextInNode(success.content).isBlank() -> {}
node.isList -> repeat(node.listItems().size) { out += Piece(at, it) }
else -> out += Piece(at)
}
}
return out
}
/**
* The room above [piece] when it follows [previous] in the same message: none between two items of
* one list, whose own padding already separates them, and a block's gap otherwise. The first piece
* of a message takes the message's gap, which is the caller's to know.
*/
fun gapBefore(previous: Piece?, piece: Piece): Dp =
if (previous != null && previous.block == piece.block) 0.dp else BLOCK_SPACING
/** The gap between one block of a reply and the next, wherever a reply is drawn in pieces. */
val BLOCK_SPACING: Dp = 6.dp
/**
* [piece] of [parse], drawn. Must be inside [MarkdownRoot] for the parse, which carries the theme,
* the components and the reference links to the renderer's element composables.
*
* A whole block goes to the renderer's own dispatch with this app's component table. Only the list
* item is drawn directly, because a list item is the one piece the renderer has no element for.
*
* [continuesList] and [listContinues] are for a list cut across the segments of a live reply: an
* item that is the first or last of its own parse but not of the list the reader sees keeps an
* inner item's padding, so nothing moves when the seam between segments does.
*/
@Composable
fun MarkdownPiece(
parse: State,
text: String,
piece: Piece,
modifier: Modifier = Modifier,
continuesList: Boolean = false,
listContinues: Boolean = false,
) {
if (parse !is State.Success) {
// The parser threw. Nothing else in the app has seen this happen; if it does, the words are
// still worth more than a blank.
Text(text, modifier, style = MaterialTheme.typography.bodyLarge)
return
}
val node = parse.node.children[piece.block]
if (piece.item == Piece.WHOLE_BLOCK) {
Box(modifier) {
MarkdownElement(
node,
LocalMarkdownComponents.current,
parse.content,
includeSpacer = false,
)
}
} else {
val items = node.listItems()
MarkdownListItem(
content = parse.content,
list = node,
item = items[piece.item],
index = piece.item,
first = piece.item == 0 && !continuesList,
last = piece.item == items.lastIndex && !listContinues,
depth = 0,
modifier = modifier,
)
}
}
/**
* A whole list, for the places the renderer's dispatch reaches one it cannot hand to a piece: a
* list inside a quote, and the nested lists an item holds. Top-level lists never come here.
*/
@Composable
fun MarkdownList(content: String, list: ASTNode, depth: Int, modifier: Modifier = Modifier) {
val items = list.listItems()
Column(modifier) {
items.forEachIndexed { index, item ->
MarkdownListItem(
content,
list,
item,
index,
first = index == 0,
last = index == items.lastIndex,
depth = depth,
)
}
}
}
/**
* One item: its marker beside its content, laid out the way the renderer's own list does so that a
* list drawn as pieces looks exactly like one drawn whole. The list's own padding goes on its first
* and last items, since there is no list column to carry it.
*
* The marker is drawn here rather than by a handler because it is the thing a reader might one day
* want styled -- a different glyph per depth, a colour -- and this is the one place it is drawn.
*/
@Composable
private fun MarkdownListItem(
content: String,
list: ASTNode,
item: ASTNode,
index: Int,
first: Boolean,
last: Boolean,
depth: Int,
modifier: Modifier = Modifier,
) {
val padding = LocalMarkdownPadding.current
val typography = LocalMarkdownTypography.current
val components = LocalMarkdownComponents.current
// A task item's box sits right after the bullet: `- [ ] text`.
val checkbox = item.children.getOrNull(1)?.takeIf { it.type == GFMTokenTypes.CHECK_BOX }
Row(
modifier
.semantics { isTraversalGroup = true }
.fillMaxWidth()
.padding(
start = padding.listIndent * depth,
top = padding.listItemTop + if (first) padding.list else 0.dp,
bottom = padding.listItemBottom + if (last) padding.list else 0.dp,
)
) {
if (checkbox != null) {
components.checkbox(MarkdownComponentModel(content, checkbox, typography))
} else if (list.type == MarkdownElementTypes.ORDERED_LIST) {
Marker("${list.startNumber(content) + index}. ", typography.ordered)
} else {
Marker(BULLETS[depth % BULLETS.size], typography.bullet)
}
Column {
item.children.forEach { child ->
when (child.type) {
MarkdownTokenTypes.LIST_BULLET,
MarkdownTokenTypes.LIST_NUMBER,
GFMTokenTypes.CHECK_BOX -> {}
MarkdownElementTypes.ORDERED_LIST,
MarkdownElementTypes.UNORDERED_LIST -> MarkdownList(content, child, depth + 1)
else -> MarkdownElement(child, components, content, includeSpacer = false)
}
}
}
}
}
/** The marker in [listMarkerColor]; the renderer's styles carry no colour of their own. */
@Composable
private fun Marker(text: String, style: TextStyle) {
BasicText(text, style = style.copy(color = listMarkerColor))
}
/**
* The bullet at each depth, cycling past the third: a disc, a ring, a square -- the ladder a
* browser draws, so a nested list is told from its parent by the glyph as well as by the indent.
* Checked on the emulator's system fonts; a glyph the platform lacks draws as a box, and that check
* is the price of adding one here.
*/
private val BULLETS = listOf("• ", "◦ ", "▪ ")
internal val ASTNode.isList: Boolean
get() = type == MarkdownElementTypes.ORDERED_LIST || type == MarkdownElementTypes.UNORDERED_LIST
internal fun ASTNode.listItems(): List<ASTNode> = children.filter {
it.type == MarkdownElementTypes.LIST_ITEM
}
/** Where an ordered list counts from: the number its first item was written with. */
private fun ASTNode.startNumber(content: String): Int =
findChildOfType(MarkdownElementTypes.LIST_ITEM)
?.findChildOfType(MarkdownTokenTypes.LIST_NUMBER)
?.getTextInNode(content)
?.takeWhile(Char::isDigit)
?.toString()
?.toIntOrNull() ?: 1