Draw the line numbers beside the file, not inside it

The stretch is rendered on the box around the list, so everything in that
box bent with it -- the numbers included. `Modifier.overscroll` wraps the
effect's single node, so one effect cannot be rendered in two places and
the numbers cannot be excluded while they live in the rows. They had to
leave.

They now sit beside the scrolling box: the rows leave a spacer where the
numbers go and a `SubcomposeLayout` draws them there. That is the one
arrangement that keeps them level with their lines, which is the thing a
numbered listing may never get wrong. Which numbers exist *and* where each
one goes both come from the list's own `layoutInfo`, read in the measure
block -- and subcomposition happens during measurement, so it composes from
the answer the list has just produced rather than from one it read a frame
ago. A column translated by the scroll position could not do that: the
translation would be a layout read and current while the set of numbers was
a composition behind it, and during a fling the numbers would slide against
their lines.

Checked by sampling the screen at about 1kHz through a fling: 23,520 row
observations over 552 frames, every one with its number at exactly its own
top, no drift at any point. Also that the gutter holds its x while the text
scrolls sideways, and that a short file and an empty one still draw.

A consequence worth having: the numbers are no longer inside the
`SelectionContainer`, so selecting part of a file and copying gives the code
rather than the code with a number in front of every line. That follows from
where they are now rather than from anything asked for, and a copy was not
exercised.

The stretch itself still cannot be seen from this VM, so whether the numbers
now stay flat while the text bends is the thing to look at on the phone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-04 01:51:47 -04:00
1 parent 7b08a71e64
commit a401e6a7e3
2 files changed
+101 -37

No files matched your search

+17
View File
@@ -190,6 +190,23 @@ end is. It cannot be seen from this VM: the emulator's screenshots come
back with no stretch in them at all, for any scrollable, so this one is
checked on the phone.
**The numbers sit outside that box**, so they neither travel with the text
nor bend with it. The rows leave a spacer where the numbers go and a
`SubcomposeLayout` beside the list draws them. That is the one arrangement
that keeps them level: which numbers exist *and* where each goes both come
from the list's own `layoutInfo`, read in the measure block, and
subcomposition happens during measurement -- so it composes from the answer
the list has just produced rather than from one it read a frame ago. A
column translated by the scroll position could not, since the translation
would be current while the set of numbers was a composition behind, and
during a fling the numbers would slide against their lines. Checked at
about 1kHz through a fling: 23,520 row observations over 552 frames, every
one of them with its number at exactly its own top.
A consequence worth having: the numbers are no longer inside the
`SelectionContainer`, so selecting part of a file and copying it gives the
code rather than the code with a number in front of every line.
Line numbers are a gutter in each row, right-aligned, with the gutter
width taken from the digit count of the line count in the same monospace
style -- so a 9-line file and a 12,000-line file each get exactly the
@@ -1,14 +1,18 @@
package com.example.aiapp
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.overscroll
import androidx.compose.foundation.rememberOverscrollEffect
import androidx.compose.foundation.rememberScrollState
@@ -25,12 +29,14 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.layout.SubcomposeLayout
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.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
@@ -97,62 +103,103 @@ fun ScannedFile(content: String, language: Language?, modifier: Modifier = Modif
* every row now has the same range: rows that disagreed about where the end was would disagree
* about when to stretch.
*
* 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.
* The gutter is **beside** the scrolling box rather than inside its rows, which is what keeps the
* numbers out of both effects: they do not travel with the text and they do not bend with it. The
* rows leave a spacer where the numbers will go and [LineGutter] draws them there. 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.
*
* Moving them out also takes them out of the [SelectionContainer], so selecting part of a file and
* copying it gives the code rather than the code with a number in front of every line.
*/
@Composable
fun FileViewer(lines: FileLines, modifier: Modifier = Modifier) {
val style = codeStyle()
val scroll = rememberScrollState()
val overscroll = rememberOverscrollEffect()
val rows = rememberLazyListState()
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) {
// The stretch is drawn here, once, over everything the viewport holds; the rows below only
// feed it. `clipToBounds` because a stretch draws outside the box it came from.
Box(Modifier.fillMaxSize().clipToBounds().overscroll(overscroll)) {
LazyColumn(Modifier.fillMaxSize()) {
items(lines.size) { index ->
Row(verticalAlignment = Alignment.Top) {
LineNumber(index + 1, gutter, style)
Text(
lines.line(index),
style = style,
softWrap = false,
// 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. The shared effect is given to every row and rendered by none
// of them -- see the box above.
modifier = Modifier.horizontalScroll(scroll, overscroll).width(content),
)
Box(modifier.fillMaxSize()) {
// 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 {
// The stretch is drawn here, once, over everything this box holds; the rows below only
// feed it. `clipToBounds` because a stretch draws outside the box it came from.
Box(Modifier.fillMaxSize().clipToBounds().overscroll(overscroll)) {
LazyColumn(state = rows, modifier = Modifier.fillMaxSize()) {
items(lines.size) { index ->
Row(verticalAlignment = Alignment.Top) {
// Where the numbers go, drawn from outside this box.
Spacer(Modifier.width(gutter + GUTTER_GAP))
Text(
lines.line(index),
style = style,
softWrap = false,
// 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. The shared effect is given to every row and
// rendered by none of them -- see the box above.
modifier =
Modifier.horizontalScroll(scroll, overscroll).width(content),
)
}
}
}
}
}
LineGutter(rows, gutter, style)
}
}
/**
* One line's number, right-aligned in the gutter.
* The line numbers, drawn beside the file rather than in it.
*
* `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.
* They have to be outside the box the stretch is rendered on, or they bend with the text; and they
* have to stay exactly level with the lines they number, which is the one thing a numbered listing
* may never get wrong. Those two pull in opposite directions -- out of the list, but pinned to it.
*
* A [SubcomposeLayout] is what settles it. *Which* numbers exist and *where* each goes both come
* from the list's own `layoutInfo`, read in the measure block -- and subcomposition happens during
* measurement, so this is not composing from a value it read a frame ago, it is composing from the
* answer the list has just produced. A `Column` translated by the scroll position could not do
* that: the translation would be a layout read and current while the set of numbers would be a
* composition behind it, so during a fling the numbers would slide against their lines.
*
* The list is measured before this is -- they are siblings in a `Box` and it is declared first --
* and a scroll that remeasures the list on its own does so synchronously, ahead of the layout pass,
* which is the same reason a lazy list does not lag its own content.
*
* `onSurfaceVariant`, because a number is not part of the file: it is this app numbering it, and
* the text's own colour would put it in the same voice as the code. The background is painted
* because the stretch can carry the text sideways under this column, and a digit with a smear of
* code behind it reads as a rendering fault.
*/
@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))
private fun LineGutter(rows: LazyListState, width: Dp, style: TextStyle) {
val colour = MaterialTheme.colorScheme.onSurfaceVariant
val surface = rawSurface
SubcomposeLayout(Modifier.fillMaxHeight().width(width).background(surface).clipToBounds()) {
constraints ->
val visible = rows.layoutInfo.visibleItemsInfo
val numbers = visible.map { item ->
subcompose(item.index) {
Text(
(item.index + 1).toString(),
style = style,
color = colour,
textAlign = TextAlign.End,
maxLines = 1,
)
}
.first()
.measure(Constraints.fixedWidth(constraints.maxWidth))
}
layout(constraints.maxWidth, constraints.maxHeight) {
numbers.forEachIndexed { index, number -> number.place(0, visible[index].offset) }
}
}
}
/**