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:
1 parent
7b08a71e64
commit
a401e6a7e3
2 files changed
+87
-23
No files matched your search
+17
@@ -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
|
||||
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(Modifier.fillMaxSize()) {
|
||||
LazyColumn(state = rows, modifier = Modifier.fillMaxSize()) {
|
||||
items(lines.size) { index ->
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
LineNumber(index + 1, gutter, style)
|
||||
// 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),
|
||||
// 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) {
|
||||
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(
|
||||
number.toString(),
|
||||
(item.index + 1).toString(),
|
||||
style = style,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
color = colour,
|
||||
textAlign = TextAlign.End,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.width(width),
|
||||
)
|
||||
Spacer(Modifier.width(GUTTER_GAP))
|
||||
}
|
||||
.first()
|
||||
.measure(Constraints.fixedWidth(constraints.maxWidth))
|
||||
}
|
||||
layout(constraints.maxWidth, constraints.maxHeight) {
|
||||
numbers.forEachIndexed { index, number -> number.place(0, visible[index].offset) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user