Files
ai-app/app/androidApp/src/test/kotlin/com/example/aiapp/FileLinesTest.kt
T
irisandClaude Opus 5 a074975d6f 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>
2026-09-04 00:39:37 -04:00

88 lines
3.7 KiB
Kotlin

package com.example.aiapp
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* The line arithmetic behind the file viewer.
*
* Worth a test rather than an eye: a line number that is one out is invisible in a short file and
* obvious in a long one, and a colour that stops at a line break is invisible until the file has a
* block comment in it.
*/
class FileLinesTest {
@Test
fun `a file that ends with a newline has the number of lines its author would count`() {
assertEquals(listOf("one", "two"), FileLines.of("one\ntwo\n", null).lines)
assertEquals(listOf("one", "two"), FileLines.of("one\ntwo", null).lines)
// Only one is dropped: a blank line at the end of a file is a line somebody typed.
assertEquals(listOf("one", "two", ""), FileLines.of("one\ntwo\n\n", null).lines)
}
@Test
fun `an empty file is one empty line`() {
val lines = FileLines.of("", null)
assertEquals(1, lines.size)
assertEquals("", lines.line(0).text)
}
@Test
fun `a comment that spans lines is coloured on every line it covers`() {
val text = "fn a() {}\n/* still\n a comment */\nfn b() {}\n"
val lines = FileLines.of(text, Language.RUST)
assertEquals(4, lines.size)
val comment = catppuccinSyntax().of(Kind.COMMENT)
// The whole of the middle line, and the part of the third up to the closer.
assertTrue(lines.line(1).spanStyles.any { it.item.color == comment && it.start == 0 })
val third = lines.line(2)
assertTrue(third.spanStyles.any { it.item.color == comment && it.end == third.length })
// And the code around it is not commented.
assertTrue(lines.line(0).spanStyles.none { it.item.color == comment })
assertTrue(lines.line(3).spanStyles.none { it.item.color == comment })
}
@Test
fun `a span never runs past the line it was cut into`() {
val lines = FileLines.of("val x = \"a\nb\"\nval y = 1\n", Language.KOTLIN)
for (index in 0 until lines.size) {
val line = lines.line(index)
assertTrue(
line.spanStyles.all { it.start >= 0 && it.end <= line.length },
"line $index",
)
}
}
/**
* 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)
assertTrue(lines.line(0).spanStyles.isEmpty())
}
@Test
fun `a language comes from the extension, and only from a real one`() {
assertEquals(Language.KOTLIN, fileLanguage("Main.kt"))
assertEquals(Language.KOTLIN, fileLanguage("build.gradle.kts"))
assertEquals(Language.RUST, fileLanguage("files.rs"))
assertEquals(Language.TOML, fileLanguage("Cargo.toml"))
assertEquals(null, fileLanguage("Makefile"))
assertEquals(null, fileLanguage(".bashrc"))
assertEquals(null, fileLanguage("notes.txt"))
}
}