diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt index 27aa622..cdff8c3 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FileLines.kt @@ -23,6 +23,15 @@ private constructor( val lines: List, /** Per line, the spans that fall in it, with offsets relative to that line's start. */ private val spans: List>, + /** + * The longest line, in character columns -- what the viewer sizes every row to. + * + * Every row has to be the *same* width or they scroll sideways by different amounts; see + * [FileViewer]. Columns rather than measured pixels because the face is monospace, so one + * number and one character's advance give the width of the widest line without measuring twenty + * thousand strings. + */ + val columns: Int, ) { val size: Int get() = lines.size @@ -66,9 +75,23 @@ private constructor( val lines = body.split('\n') val rules = language?.let { rulesOf(it) } val scanned = if (rules == null) emptyList() else scan(body, rules) - FileLines(lines, bucket(lines, scanned)) + FileLines(lines, bucket(lines, scanned), lines.maxOf(::columnsOf)) } + /** + * How many columns a line occupies. + * + * A tab counts as eight rather than as one, and deliberately upwards: this decides how far + * the viewer can scroll, and over-estimating leaves a little empty space past the longest + * line where under-estimating makes the end of that line unreachable. Compose draws a tab + * as a single advance, so eight is the generous reading rather than the accurate one. + */ + private fun columnsOf(line: String): Int { + var count = 0 + for (character in line) count += if (character == '\t') 8 else 1 + return count + } + /** * The scanner's spans, in file offsets, as spans per line in line offsets. * diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt index 808c36d..32201de 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/FileViewer.kt @@ -75,6 +75,16 @@ fun ScannedFile(content: String, language: Language?, modifier: Modifier = Modif * 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. * + * **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 at all while a long one beside it does. Each row + * also writes `maxValue` on the shared state as it measures, so how far the file could be dragged + * was decided by whichever row happened to measure last and changed as the list scrolled. Both + * disappear once every row is [FileLines.columns] wide: one range, one maximum, and the file moves + * as the block this comment always claimed it was. Reported by Iris on 2026-09-04 as "it seems to + * affect different rows differently", which is exactly what a per-row range looks like. + * * 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 @@ -85,6 +95,7 @@ fun FileViewer(lines: FileLines, modifier: Modifier = Modifier) { val style = codeStyle() val scroll = rememberScrollState() val gutter = gutterWidth(lines.size, style) + val content = contentWidth(lines.columns, 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) { @@ -96,7 +107,9 @@ fun FileViewer(lines: FileLines, modifier: Modifier = Modifier) { lines.line(index), style = style, softWrap = false, - modifier = Modifier.horizontalScroll(scroll), + // 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. + modifier = Modifier.horizontalScroll(scroll).width(content), ) } } @@ -143,6 +156,35 @@ fun gutterWidth(lineCount: Int, style: TextStyle): Dp { } } +/** + * 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 -- every + * advance is the same -- 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 asking to lay 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. * diff --git a/app/androidApp/src/test/kotlin/com/example/aiapp/FileLinesTest.kt b/app/androidApp/src/test/kotlin/com/example/aiapp/FileLinesTest.kt index ebe6652..0b054a5 100644 --- a/app/androidApp/src/test/kotlin/com/example/aiapp/FileLinesTest.kt +++ b/app/androidApp/src/test/kotlin/com/example/aiapp/FileLinesTest.kt @@ -54,6 +54,20 @@ class FileLinesTest { } } + /** + * The number every row in the viewer is sized to. It has to be the widest line, because rows of + * their natural widths scroll sideways by different amounts -- see [FileViewer]. + */ + @Test + fun `the column count is the widest line, counting a tab as eight`() { + assertEquals(5, FileLines.of("one\nthree\nx\n", null).columns) + // A tab counts up to eight, and upwards on purpose: over-estimating leaves empty space + // past the longest line, under-estimating puts its end out of reach. + assertEquals(9, FileLines.of("\tx\nshort\n", null).columns) + // An empty file is one empty line, which is no columns at all rather than an error. + assertEquals(0, FileLines.of("", null).columns) + } + @Test fun `a file with no language is plain`() { val lines = FileLines.of("fn main() {}\n", null)