Stretch the whole file at the ends, not one line

`Modifier.horizontalScroll` builds its own overscroll effect per node, so
with a node per row only the line under the finger bent when the file was
dragged past either end and the rest sat still beside it -- the same
complaint as the offsets, one layer out.

There is an overload that takes the effect instead of making one, and it
leaves the rendering to the caller. So the viewer makes one effect, hands
it to every row, and renders it once on a box around the list: the file
bends as the block it scrolls as. This only works now that every row is
the same width -- rows that disagreed about where the end was would
disagree about when to stretch.

**Not seen working.** Measured on the emulator here: over-dragging well
past the end and capturing mid-gesture produces a frame with no stretch in
it at all, and the list's own vertical overscroll does not appear either,
so this VM cannot show the effect for any scrollable. Noted in
~/.claude/MACHINE.md so the next session does not spend the same half hour
on it. What was checked here is everything either side: the scroll still
reaches both ends, the position survives scrolling vertically, and nothing
else moved. The stretch itself wants a look 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:38:21 -04:00
1 parent 457907087c
commit 7b08a71e64
2 files changed
+41 -13

No files matched your search

+9
View File
@@ -181,6 +181,15 @@ can carry, so a minified file is a scroll that stops early rather than a
crash. Reported by Iris on 2026-09-04 as "it seems to affect different rows crash. Reported by Iris on 2026-09-04 as "it seems to affect different rows
differently", which is precisely what a per-row range looks like. differently", which is precisely what a per-row range looks like.
**The stretch at the ends is one effect too**, shared by every row and
rendered once on the box around the list -- `horizontalScroll` makes its
own per node otherwise, so only the line under the finger bent and the
rest of the file sat still beside it. That is the same complaint one layer
further out, and it is only fixable now that every row agrees where the
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.
Line numbers are a gutter in each row, right-aligned, with the gutter 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 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 style -- so a 9-line file and a 12,000-line file each get exactly the
@@ -1,13 +1,16 @@
package com.example.aiapp package com.example.aiapp
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.overscroll
import androidx.compose.foundation.rememberOverscrollEffect
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -21,6 +24,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@@ -85,6 +89,14 @@ fun ScannedFile(content: String, language: Language?, modifier: Modifier = Modif
* as the block this comment always claimed it was. Reported by Iris on 2026-09-04 as "it seems to * 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. * affect different rows differently", which is exactly what a per-row range looks like.
* *
* The stretch at the ends of the travel is **one** effect for the whole file, rendered on the box
* around the list rather than by each row. `horizontalScroll` makes its own per node otherwise, so
* only the line under the finger stretched and the rest of the file sat still beside it -- the same
* complaint as the offsets above, one layer further out. Handing every row the same effect and
* rendering it once is what makes the file bend as the block it scrolls as. Only possible because
* 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 * 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 * 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 * style it is drawn in, so a nine-line file and a twelve-thousand-line file each get exactly what
@@ -94,23 +106,30 @@ fun ScannedFile(content: String, language: Language?, modifier: Modifier = Modif
fun FileViewer(lines: FileLines, modifier: Modifier = Modifier) { fun FileViewer(lines: FileLines, modifier: Modifier = Modifier) {
val style = codeStyle() val style = codeStyle()
val scroll = rememberScrollState() val scroll = rememberScrollState()
val overscroll = rememberOverscrollEffect()
val gutter = gutterWidth(lines.size, style) val gutter = gutterWidth(lines.size, style)
val content = contentWidth(lines.columns, style) val content = contentWidth(lines.columns, style)
// One container around the whole file rather than one per line, so a selection can run across // One container around the whole file rather than one per line, so a selection can run across
// lines -- the same arrangement the transcript uses. // lines -- the same arrangement the transcript uses.
SelectionContainer(modifier) { SelectionContainer(modifier) {
LazyColumn(Modifier.fillMaxWidth()) { // The stretch is drawn here, once, over everything the viewport holds; the rows below only
items(lines.size) { index -> // feed it. `clipToBounds` because a stretch draws outside the box it came from.
Row(verticalAlignment = Alignment.Top) { Box(Modifier.fillMaxSize().clipToBounds().overscroll(overscroll)) {
LineNumber(index + 1, gutter, style) LazyColumn(Modifier.fillMaxSize()) {
Text( items(lines.size) { index ->
lines.line(index), Row(verticalAlignment = Alignment.Top) {
style = style, LineNumber(index + 1, gutter, style)
softWrap = false, Text(
// The scroll outside the width: the scrolling node's viewport is what lines.line(index),
// the row has room for, and its content is the whole file's widest line. style = style,
modifier = Modifier.horizontalScroll(scroll).width(content), 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),
)
}
} }
} }
} }