diff --git a/PLAN.md b/PLAN.md index cd285b8..498fcd6 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1178,15 +1178,28 @@ dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21). question *was*, and "Deny" alone does not say Allow was the alternative. One rule in two places (`AskedQuestion` and `PermissionAsk`). An answer typed into **Other** matches no option, so that one is still written out. - - **Expanding a row keeps still the end nearest the tap**: touch a row's - upper half and its top edge holds, so it opens downwards; touch its - lower half and the bottom edge holds, as the list does by default. Which - half, rather than which control, so everything that opens behaves alike - whether or not it has a control at each end. The transcript is laid out - from the bottom, so a bottom edge is anchored for free and the top one - has to be arranged: `Modifier.holdTopEdge` asks the list to shift during - the *layout* phase, before anything is drawn. From an effect instead, - the wrong position is drawn once first, which reads as a flick. + - **Opening a row keeps still the end nearest the tap, and closing one + keeps still the point touched** (2026-09-16). A row about to open is + small, so both its edges are within a heading's height of the finger and + the wanted one is the edge pressed: touch the upper half and the top + edge holds, so it opens downwards; touch the lower half and the bottom + edge holds, as the list does by default. A row about to close leaves + almost nothing of itself, and the only place the closed card can + sensibly appear is under the finger that closed it — the case that + settles it is a card taller than the screen, where holding either edge + throws the card a screen's length from the hand. One number carries + both: 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. Which half, rather than + which control, so everything behaves alike whether or not it has a + control at each end. The transcript is laid out from the bottom, so a + bottom edge is anchored for free and anything else has to be arranged: + `Modifier.holdTouchedPoint` asks the list to shift during the *layout* + phase, before anything is drawn. From an effect instead, the wrong + position is drawn once first, which reads as a flick. The scroll offset + that asks for it **goes negative on a close** — that is the list being + asked for the rows below what it has composed — and clamping it at zero, + which is what the growing-only version did, leaves every close + uncorrected. - **The full-screen image lives on the screen, not in the row that drew the thumbnail** (`SessionImageViewer`). A `Read` whose result is an image is a row of one call until the next call arrives and makes it a group — a diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt index 4f97eb0..91ba83c 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -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()) } 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