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 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-04 00:39:37 -04:00
1 parent ffc266bf3e
commit a074975d6f
3 files changed
+81 -2

No files matched your search

@@ -23,6 +23,15 @@ private constructor(
val lines: List<String>,
/** Per line, the spans that fall in it, with offsets relative to that line's start. */
private val spans: List<List<Span>>,
/**
* 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.
*