From a074975d6f59f3ed2bc5fc0269b08dbc0e7f9795 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 4 Sep 2026 00:39:37 -0400 Subject: [PATCH] Scroll the whole file sideways, not each row by its own amount Sharing one `ScrollState` across the viewer's rows was not enough to make them move together. `Modifier.horizontalScroll` is a node per row, and each one coerces the shared offset into *its own* range -- its content width less its viewport -- so a short line's range is zero and it stayed put while the long line beside it moved. Each row also writes `maxValue` on the shared state as it measures, so how far the file could be dragged at all was decided by whichever row happened to measure last, and changed as the list scrolled. Both go away once every row is the same width. `FileLines` now carries the longest line in columns, and the viewer turns that into one content width from a single character's advance -- arithmetic rather than twenty thousand measurements, because the face is monospace -- and gives it to every row. A tab counts as eight columns and deliberately upwards: over-estimating leaves a little empty space past the longest line, under-estimating puts the end of that line out of reach. The width is capped well under what `Constraints` can carry, so a minified file is a scroll that stops early rather than a crash. Reported by Iris on 2026-09-04. Checked on the emulator against the generated 1 MB file, whose lines run from one character to sixty-eight: the file now moves as a block, the offset survives scrolling vertically and newly composed rows arrive at it, and the far end of the longest line is reachable. Also checked on the two cases the change had no reason to touch -- a file narrower than the screen, which still does not scroll at all, and an empty one, whose zero content width draws its one numbered line. Co-Authored-By: Claude Opus 5 --- .../kotlin/com/example/aiapp/FileLines.kt | 25 ++++++++++- .../kotlin/com/example/aiapp/FileViewer.kt | 44 ++++++++++++++++++- .../kotlin/com/example/aiapp/FileLinesTest.kt | 14 ++++++ 3 files changed, 81 insertions(+), 2 deletions(-) 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)