package com.example.aiapp import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.Dp import androidx.compose.ui.unit.dp /** 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) /** * 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, and the * scroll never recovers. The cost of the choice 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, the one thing a numbered listing must never do. Because nothing wraps, a * logical line is one visual line and the two cannot drift. * * The gutter stays put while the text scrolls, so a line number is still there to read at the right * hand end of a long line. Its width is measured from the digit count of the line count in the very * style it is drawn in, so a nine-line file and a twelve-thousand-line file each get exactly what * they need and nothing is nudged by hand. */ @Composable fun FileViewer(lines: FileLines, modifier: Modifier = Modifier) { val style = codeStyle() val scroll = rememberScrollState() val gutter = gutterWidth(lines.size, style) // 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(modifier) { LazyColumn(Modifier.fillMaxWidth()) { items(lines.size) { index -> Row(verticalAlignment = Alignment.Top) { LineNumber(index + 1, gutter, style) Text( lines.line(index), style = style, softWrap = false, modifier = Modifier.horizontalScroll(scroll), ) } } } } } /** * One line's number, right-aligned in the gutter. * * `onSurfaceVariant`, because it is not part of the file: it is this app numbering it, and giving * it the text's own colour would put it in the same voice as the code. */ @Composable fun LineNumber(number: Int, width: Dp, style: TextStyle) { Text( number.toString(), style = style, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.End, maxLines = 1, modifier = Modifier.width(width), ) Spacer(Modifier.width(GUTTER_GAP)) } /** * How wide the widest line number is, measured rather than guessed. * * `9` repeated, because digits in a monospace face are all one width and the count's own digits * would measure the same -- 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() } } } /** * 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 of the line are not touching. */ val GUTTER_GAP = 8.dp