Close a card on the point that was touched

Collapsing held one of the row's edges -- whichever the tap was nearer -- which
is right for opening and wrong for closing: the row that shuts leaves a heading
where a screenful of card was, and both its old edges can be a screen's length
from the finger that shut it. It now keeps the touched point itself, which for a
closed card is the same thing as landing under the hand that closed it. Opening
is unchanged and deliberately so: those rows are small, every point in them is
within a heading's height of both edges, and the edge pressed is what the reader
wants held rather than a fraction of an unbounded expansion.

One number carries both readings -- the share of the row's height above the
touch, spent as it is on a close and rounded to the nearer edge on an open.

The scroll offset the correction asks for goes negative on a close, and has to:
that is the list being asked for the rows below what it has composed, which is
where the newer content comes from when a card gives a screenful back. It was
clamped at zero, which was invisible while every correction was a row growing
and is what left closes uncorrected.

Checked with ktfmtFormat, compileDebugKotlin, lintDebug and testDebugUnitTest,
and on the emulator against the sandbox: a 1441px card closed at a quarter of
its height put the collapsed card's top at 743px against 743 predicted, and
opening a card by its heading still holds the heading still.
This commit is contained in:
iris-ai committed 2026-09-16 01:50:33 -04:00
1 parent 827a30768c
commit 463acb28fa
2 files changed
+108 -46

No files matched your search

@@ -82,6 +82,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import java.util.concurrent.atomic.AtomicLong
import kotlin.math.roundToInt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.cancelAndJoin
@@ -168,25 +169,34 @@ private data class HistoryLoadSignal(
)
/**
* Which row was asked to hold its top edge, and how tall it was when it last measured.
* Which row was asked to hold a point of itself still, and what share of its height change that
* point is worth; see [holdTouchedPoint].
*
* Deliberately *not* snapshot state, which is the point of the class. Both fields are written from
* the layout phase; a snapshot write there that composition reads would schedule another
* recomposition, and the correction has to land inside the frame already being laid out. [key] is
* cleared by the resize it was set for, so it cannot be spent on an unrelated one.
*/
private class TopEdgeHold {
private class TouchHold {
var key: Any? = null
/**
* 1 holds the row's top edge, 0 its bottom, and anything between the point that share names.
*/
var share = 0f
}
/** One row's height between layouts, so a change in it can be noticed. See [holdTopEdge]. */
/** One row's height between layouts, so a change in it can be noticed. See [holdTouchedPoint]. */
private class LastHeight {
var value: Int? = null
}
/**
* Which row the last touch landed in, and whether it landed in the row's top half -- the end that
* row should hold when it changes height; see [holdTopEdge].
* Which row the last touch landed in, and where down that row it landed -- as the share of the
* row's height that sits above the touch; see [toggleAnchored].
*
* A share rather than a distance because that is what both readings of the touch want: a close
* spends it as it is, and an open rounds it to the nearer edge.
*
* One slot rather than a map, because only the touch about to toggle something matters:
* [toggleAnchored] reads it in the same gesture that wrote it. Written from a detector on each
@@ -194,22 +204,23 @@ private class LastHeight {
*/
private class LastTouch {
var key: Any? = null
var high = false
var share = 0f
}
/**
* Keeps this row's top edge where it is when the row changes height, if it was asked to.
* Keeps a point of this row still when the row changes height, if it was asked to.
*
* This runs in the *layout* phase, from the measurement that discovers the new height, and that is
* why it is a modifier rather than an effect. A correction posted to a coroutine arrives a frame or
* more after the layout it is correcting, so the wrong position is drawn once first -- visible as a
* flick, and worse the faster the screen refreshes.
*
* [hold] is given the change in height. The row's bottom edge is held by the list, so a scroll of
* exactly that much leaves the top edge where it was.
* [hold] is given how far the list has to scroll. The row's bottom edge is held by the list and all
* growth goes upward, so scrolling by the whole change keeps the top edge where it was, by none of
* it the bottom edge, and by [TouchHold.share] of it the point that share names.
*/
@Composable
private fun Modifier.holdTopEdge(key: Any, held: TopEdgeHold, hold: (Int) -> Unit): Modifier {
private fun Modifier.holdTouchedPoint(key: Any, held: TouchHold, hold: (Int) -> Unit): Modifier {
val last = remember { LastHeight() }
return onSizeChanged { size ->
val previous = last.value
@@ -217,8 +228,9 @@ private fun Modifier.holdTopEdge(key: Any, held: TopEdgeHold, hold: (Int) -> Uni
// A first measurement has no previous height to have moved from, and a row that came back
// after being scrolled away is a first measurement again.
if (previous == null || previous == size.height || held.key != key) return@onSizeChanged
val share = held.share
held.key = null
hold(size.height - previous)
hold(((size.height - previous) * share).roundToInt())
}
}
@@ -253,7 +265,7 @@ fun SessionScreen(
val isSubagent = subagent != null
val address = TranscriptAddress(summary.id, subagent?.id)
val scope = rememberCoroutineScope()
val topEdgeHeld = remember { TopEdgeHold() }
val touchHeld = remember { TouchHold() }
var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
var status by remember { mutableStateOf(subagent?.status ?: summary.status) }
var backgroundTasks by
@@ -575,20 +587,35 @@ fun SessionScreen(
}
/**
* Changes a row's height while the end the reader touched stays where it is.
* Changes a row's height while what the reader touched stays where it is.
*
* The transcript is laid out from the bottom, so every row's *bottom* edge is what the list
* holds still and all growth goes upward. That is what a tap in a row's lower half already
* gets. A tap in the upper half is the other case -- left alone it sends the heading under the
* reader's finger up off the screen -- and that one asks the row to hold its top edge instead.
* holds still and all growth goes upward. A tap anywhere in a row that is about to grow or
* shrink therefore travels, and how far it travels depends on where down the row it landed.
*
* Which half decides it, rather than which control was pressed, so everything that opens
* [closing] is the difference between the two ends of that, and they want different answers.
* Shutting a row leaves almost nothing of it, so the point under the finger is the only place
* the closed row can sensibly appear -- a row whose top is far off the screen is the case that
* settles it, since holding either edge throws the card the length of the screen away from the
* hand that shut it. Opening one is the opposite: the row is small, every point in it is within
* a heading's height of both edges, and what the reader wants held is the edge they pressed --
* so the touch is rounded to the nearer of the two rather than splitting the difference and
* carrying the heading part of the way up an unbounded expansion.
*
* Where the touch landed decides it, rather than which control was pressed, so everything
* behaves the same way whether or not it has a control at each end.
*
* The correction itself belongs to the measurement -- see [holdTopEdge].
* The correction itself belongs to the measurement -- see [holdTouchedPoint].
*/
fun toggleAnchored(row: TranscriptRow, toggle: () -> Unit) = expanding {
if (lastTouch.key == row.key && lastTouch.high) topEdgeHeld.key = row.key
fun toggleAnchored(row: TranscriptRow, closing: Boolean, toggle: () -> Unit) = expanding {
if (lastTouch.key == row.key) {
val share = if (closing) lastTouch.share else if (lastTouch.share > 0.5f) 1f else 0f
// Nothing to correct at zero: the bottom edge is what the list holds on its own.
if (share > 0f) {
touchHeld.key = row.key
touchHeld.share = share
}
}
toggle()
}
@@ -1584,25 +1611,33 @@ fun SessionScreen(
is TranscriptUnit.Whole -> {
val row = unit.row
Box(
Modifier.holdTopEdge(row.key, topEdgeHeld) { grew ->
// A *request*, not a raw scroll delta: this runs inside
// the measure pass that discovered the new height, and
// a raw delta forces a synchronous remeasure from
// within measure, which is fatal. The request is
// applied by the same frame's next remeasure, so the
// correction still lands before anything is drawn.
// Reads unobserved, or this row's measure would inherit
// the scroll position as a dependency and remeasure on
// every frame.
Modifier.holdTouchedPoint(row.key, touchHeld) { by ->
// A *request*, not a raw scroll delta: this runs
// inside the measure pass that discovered the new
// height, and a raw delta forces a synchronous
// remeasure from within measure, which is fatal. The
// request is applied by the same frame's next
// remeasure, so the correction still lands before
// anything is drawn. Reads unobserved, or this row's
// measure would inherit the scroll position as a
// dependency and remeasure on every frame.
//
// The offset goes negative on a close, and has to:
// that is the list being asked for room below what it
// has composed, which is where the newer rows come
// from when a card gives a screenful back. Clamping it
// at zero -- which is what this did while every
// correction was a row *growing* -- leaves a close
// uncorrected, and the card lands wherever the list
// felt like putting it.
Snapshot.withoutReadObservation {
listState.requestScrollToItem(
listState.firstVisibleItemIndex,
(listState.firstVisibleItemScrollOffset + grew)
.coerceAtLeast(0),
listState.firstVisibleItemScrollOffset + by,
)
}
}
// Which half of this row the touch landed in, for
// Where down this row the touch landed, for
// [toggleAnchored]. On the initial pass and consuming
// nothing, so every control inside still gets the gesture;
// only visible rows have one, which is what makes a
@@ -1615,7 +1650,12 @@ fun SessionScreen(
pass = PointerEventPass.Initial,
)
lastTouch.key = row.key
lastTouch.high = down.position.y < size.height / 2f
lastTouch.share =
1f -
(down.position.y / size.height).coerceIn(
0f,
1f,
)
}
}
) {
@@ -1625,7 +1665,10 @@ fun SessionScreen(
group = row,
expanded = row.key in expandedGroups,
onToggle = {
toggleAnchored(row) {
toggleAnchored(
row,
closing = row.key in expandedGroups,
) {
expandedGroups =
if (row.key in expandedGroups)
expandedGroups - row.key
@@ -1637,7 +1680,10 @@ fun SessionScreen(
// call makes the whole group taller, and the
// heading the reader is under is the group's.
onToolToggle = { id ->
toggleAnchored(row) {
toggleAnchored(
row,
closing = id in expandedTools,
) {
expandedTools =
if (id in expandedTools)
expandedTools - id
@@ -1682,7 +1728,10 @@ fun SessionScreen(
tool = item,
expanded = item.id in expandedTools,
onToggle = {
toggleAnchored(row) {
toggleAnchored(
row,
closing = item.id in expandedTools,
) {
expandedTools =
if (item.id in expandedTools)
expandedTools - item.id