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>
243 lines
12 KiB
Kotlin
243 lines
12 KiB
Kotlin
package com.example.aiapp
|
|
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.horizontalScroll
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.Spacer
|
|
import androidx.compose.foundation.layout.fillMaxHeight
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.layout.width
|
|
import androidx.compose.foundation.lazy.LazyColumn
|
|
import androidx.compose.foundation.lazy.LazyListState
|
|
import androidx.compose.foundation.lazy.items
|
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
import androidx.compose.foundation.overscroll
|
|
import androidx.compose.foundation.rememberOverscrollEffect
|
|
import androidx.compose.foundation.rememberScrollState
|
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
|
import androidx.compose.material3.CircularProgressIndicator
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.draw.clipToBounds
|
|
import androidx.compose.ui.layout.SubcomposeLayout
|
|
import androidx.compose.ui.platform.LocalDensity
|
|
import androidx.compose.ui.text.AnnotatedString
|
|
import androidx.compose.ui.text.TextStyle
|
|
import androidx.compose.ui.text.font.FontFamily
|
|
import androidx.compose.ui.text.rememberTextMeasurer
|
|
import androidx.compose.ui.text.style.TextAlign
|
|
import androidx.compose.ui.unit.Constraints
|
|
import androidx.compose.ui.unit.Dp
|
|
import androidx.compose.ui.unit.dp
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.withContext
|
|
|
|
/** The face every verbatim thing in this app is drawn in, and the one the gutter has to match. */
|
|
@Composable
|
|
fun codeStyle(): TextStyle =
|
|
MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace)
|
|
|
|
/**
|
|
* [content] scanned off the main thread, then drawn.
|
|
*
|
|
* Measured on the emulator 2026-09-04: [FileLines.of] takes **460ms** on a 1 MiB Rust file (28,660
|
|
* lines) and 11ms on 32 kB. Called from a `remember` inside the composition, as it was first
|
|
* written, that is 460ms of frozen screen at the size the server is willing to send -- long enough
|
|
* that the accessibility tree cannot be read, which is what "the app has stopped" looks like.
|
|
*
|
|
* Keyed on the text and the language, so re-reading the same file does not rescan it.
|
|
*/
|
|
@Composable
|
|
fun ScannedFile(content: String, language: Language?, modifier: Modifier = Modifier) {
|
|
var lines by remember(content, language) { mutableStateOf<FileLines?>(null) }
|
|
LaunchedEffect(content, language) {
|
|
lines = withContext(Dispatchers.Default) { FileLines.of(content, language) }
|
|
}
|
|
when (val ready = lines) {
|
|
null -> CircularProgressIndicator(Modifier.padding(8.dp))
|
|
else -> FileViewer(ready, modifier)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A file, one line per row, coloured by the same scanner that colours a reply's code fences.
|
|
*
|
|
* A `LazyColumn` of lines rather than one `Text`, because text layout is linear in the text: a
|
|
* twenty-thousand-line file in a single `Text` measures all of it to draw a screenful. The cost is
|
|
* that each row needs its own colours, which is what [FileLines] works out once and off this
|
|
* thread.
|
|
*
|
|
* Lines do not wrap. They share one horizontal scroll state, so the whole file moves sideways as a
|
|
* block and a long line does not silently become three -- which would put the gutter's numbers
|
|
* against the wrong text.
|
|
*
|
|
* **Every row is given the same content width**, and that is what makes the shared scroll state
|
|
* behave. `Modifier.horizontalScroll` is a node per row, and each one coerces the shared offset
|
|
* into *its own* range -- `content width - viewport` -- so with rows of their natural widths a
|
|
* short line's range is zero and it never moves while a long one beside it does. Each row also
|
|
* writes `maxValue` as it measures, so how far the file could be dragged was decided by whichever
|
|
* row measured last. Both disappear once every row is [FileLines.columns] wide. Reported by Iris on
|
|
* 2026-09-04 as "it seems to affect different rows differently", which is what a per-row range
|
|
* looks like.
|
|
*
|
|
* The stretch at the ends of the travel is **one** effect for the whole file, rendered on the box
|
|
* around the list rather than by each row -- `horizontalScroll` makes its own per node otherwise,
|
|
* so only the line under the finger stretched. Only possible because every row now has the same
|
|
* range.
|
|
*
|
|
* The gutter is **beside** the scrolling box rather than inside its rows, which is what keeps the
|
|
* numbers out of both effects. The rows leave a spacer and [LineGutter] draws them there; its width
|
|
* is measured from the digit count of the line count in the style it is drawn in.
|
|
*
|
|
* Moving them out also takes them out of the [SelectionContainer], so selecting part of a file and
|
|
* copying it gives the code rather than the code with a number in front of every line.
|
|
*/
|
|
@Composable
|
|
fun FileViewer(lines: FileLines, modifier: Modifier = Modifier) {
|
|
val style = codeStyle()
|
|
val scroll = rememberScrollState()
|
|
val overscroll = rememberOverscrollEffect()
|
|
val rows = rememberLazyListState()
|
|
val gutter = gutterWidth(lines.size, style)
|
|
val content = contentWidth(lines.columns, style)
|
|
Box(modifier.fillMaxSize()) {
|
|
// One container around the whole file rather than one per line, so a selection can run
|
|
// across lines -- the same arrangement the transcript uses.
|
|
SelectionContainer {
|
|
// The stretch is drawn here, once, over everything this box holds; the rows below only
|
|
// feed it. `clipToBounds` because a stretch draws outside the box it came from.
|
|
Box(Modifier.fillMaxSize().clipToBounds().overscroll(overscroll)) {
|
|
LazyColumn(state = rows, modifier = Modifier.fillMaxSize()) {
|
|
items(lines.size) { index ->
|
|
Row(verticalAlignment = Alignment.Top) {
|
|
// Where the numbers go, drawn from outside this box.
|
|
Spacer(Modifier.width(gutter + GUTTER_GAP))
|
|
Text(
|
|
lines.line(index),
|
|
style = style,
|
|
softWrap = false,
|
|
// The scroll outside the width: the scrolling node's viewport is
|
|
// what the row has room for, and its content is the whole file's
|
|
// widest line. The shared effect is given to every row and rendered
|
|
// by none of them -- see the box above.
|
|
modifier =
|
|
Modifier.horizontalScroll(scroll, overscroll).width(content),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
LineGutter(rows, gutter, style)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The line numbers, drawn beside the file rather than in it.
|
|
*
|
|
* They have to be outside the box the stretch is rendered on, or they bend with the text; and they
|
|
* have to stay exactly level with the lines they number. Those two pull in opposite directions.
|
|
*
|
|
* A [SubcomposeLayout] is what settles it. *Which* numbers exist and *where* each goes both come
|
|
* from the list's own `layoutInfo`, read in the measure block -- and subcomposition happens during
|
|
* measurement, so this composes from the answer the list has just produced rather than one it read
|
|
* a frame ago. A `Column` translated by the scroll position could not: the translation would be
|
|
* current while the set of numbers was a composition behind, so during a fling the numbers would
|
|
* slide against their lines.
|
|
*
|
|
* The list is measured before this is -- they are siblings in a `Box` and it is declared first.
|
|
*
|
|
* `onSurfaceVariant`, because a number is not part of the file. The background is painted because
|
|
* the stretch can carry the text sideways under this column, and a digit with a smear of code
|
|
* behind it reads as a rendering fault.
|
|
*/
|
|
@Composable
|
|
private fun LineGutter(rows: LazyListState, width: Dp, style: TextStyle) {
|
|
val colour = MaterialTheme.colorScheme.onSurfaceVariant
|
|
val surface = rawSurface
|
|
SubcomposeLayout(Modifier.fillMaxHeight().width(width).background(surface).clipToBounds()) {
|
|
constraints ->
|
|
val visible = rows.layoutInfo.visibleItemsInfo
|
|
val numbers = visible.map { item ->
|
|
subcompose(item.index) {
|
|
Text(
|
|
(item.index + 1).toString(),
|
|
style = style,
|
|
color = colour,
|
|
textAlign = TextAlign.End,
|
|
maxLines = 1,
|
|
)
|
|
}
|
|
.first()
|
|
.measure(Constraints.fixedWidth(constraints.maxWidth))
|
|
}
|
|
layout(constraints.maxWidth, constraints.maxHeight) {
|
|
numbers.forEachIndexed { index, number -> number.place(0, visible[index].offset) }
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* How wide the widest line number is, measured rather than guessed.
|
|
*
|
|
* `9` repeated, because digits in a monospace face are all one width -- what matters is how many
|
|
* there are. Measuring in the style the numbers are drawn in is what makes this survive a font
|
|
* size, a density or a display scale nobody here chose.
|
|
*/
|
|
@Composable
|
|
fun gutterWidth(lineCount: Int, style: TextStyle): Dp {
|
|
val measurer = rememberTextMeasurer()
|
|
val density = LocalDensity.current
|
|
val digits = maxOf(1, lineCount.toString().length)
|
|
return remember(digits, style, density) {
|
|
with(density) {
|
|
measurer.measure(AnnotatedString("9".repeat(digits)), style).size.width.toDp()
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* How wide to make every row: the widest line in the file, in this style.
|
|
*
|
|
* One character measured rather than the line itself, because the face is monospace and measuring
|
|
* the actual widest line of a twenty-thousand-line file is work for an answer arithmetic already
|
|
* has. Sixty-four of them, divided, so the answer does not carry a whole character's worth of
|
|
* rounding.
|
|
*
|
|
* Capped, because this becomes a fixed width in a layout and Compose cannot represent an arbitrary
|
|
* one: a minified file is a single line of a hundred thousand characters, and laying that out as
|
|
* one row is a crash rather than a slow scroll. Past the cap the far end of such a line cannot be
|
|
* reached, which is the tolerable half of that trade.
|
|
*/
|
|
@Composable
|
|
private fun contentWidth(columns: Int, style: TextStyle): Dp {
|
|
val measurer = rememberTextMeasurer()
|
|
val density = LocalDensity.current
|
|
return remember(columns, style, density) {
|
|
val advance = measurer.measure(AnnotatedString("0".repeat(64)), style).size.width / 64f
|
|
with(density) { (columns * advance).coerceAtMost(MAX_CONTENT_PX).toDp() }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The widest a row may be laid out, in pixels. Well under what `Constraints` can carry, and far
|
|
* past any line anybody reads.
|
|
*/
|
|
private const val MAX_CONTENT_PX = 100_000f
|
|
|
|
/**
|
|
* The space between the numbers and the code. A gap, not an alignment: the two are already aligned
|
|
* by the row, and this is only so the digits and the first character are not touching.
|
|
*/
|
|
val GUTTER_GAP = 8.dp
|