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(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