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:
1 parent
827a30768c
commit
463acb28fa
2 files changed
+108
-46
No files matched your search
@@ -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.
|
question *was*, and "Deny" alone does not say Allow was the alternative.
|
||||||
One rule in two places (`AskedQuestion` and `PermissionAsk`). An answer
|
One rule in two places (`AskedQuestion` and `PermissionAsk`). An answer
|
||||||
typed into **Other** matches no option, so that one is still written out.
|
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
|
- **Opening a row keeps still the end nearest the tap, and closing one
|
||||||
upper half and its top edge holds, so it opens downwards; touch its
|
keeps still the point touched** (2026-09-16). A row about to open is
|
||||||
lower half and the bottom edge holds, as the list does by default. Which
|
small, so both its edges are within a heading's height of the finger and
|
||||||
half, rather than which control, so everything that opens behaves alike
|
the wanted one is the edge pressed: touch the upper half and the top
|
||||||
whether or not it has a control at each end. The transcript is laid out
|
edge holds, so it opens downwards; touch the lower half and the bottom
|
||||||
from the bottom, so a bottom edge is anchored for free and the top one
|
edge holds, as the list does by default. A row about to close leaves
|
||||||
has to be arranged: `Modifier.holdTopEdge` asks the list to shift during
|
almost nothing of itself, and the only place the closed card can
|
||||||
the *layout* phase, before anything is drawn. From an effect instead,
|
sensibly appear is under the finger that closed it — the case that
|
||||||
the wrong position is drawn once first, which reads as a flick.
|
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 full-screen image lives on the screen, not in the row that drew
|
||||||
the thumbnail** (`SessionImageViewer`). A `Read` whose result is an image
|
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
|
is a row of one call until the next call arrives and makes it a group — a
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ import androidx.lifecycle.Lifecycle
|
|||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
import kotlin.math.roundToInt
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.awaitCancellation
|
import kotlinx.coroutines.awaitCancellation
|
||||||
import kotlinx.coroutines.cancelAndJoin
|
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
|
* 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
|
* 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
|
* 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.
|
* 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
|
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 {
|
private class LastHeight {
|
||||||
var value: Int? = null
|
var value: Int? = null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Which row the last touch landed in, and whether it landed in the row's top half -- the end that
|
* Which row the last touch landed in, and where down that row it landed -- as the share of the
|
||||||
* row should hold when it changes height; see [holdTopEdge].
|
* 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:
|
* 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
|
* [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 {
|
private class LastTouch {
|
||||||
var key: Any? = null
|
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
|
* 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
|
* 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
|
* 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.
|
* 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
|
* [hold] is given how far the list has to scroll. The row's bottom edge is held by the list and all
|
||||||
* exactly that much leaves the top edge where it was.
|
* 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
|
@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() }
|
val last = remember { LastHeight() }
|
||||||
return onSizeChanged { size ->
|
return onSizeChanged { size ->
|
||||||
val previous = last.value
|
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
|
// 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.
|
// after being scrolled away is a first measurement again.
|
||||||
if (previous == null || previous == size.height || held.key != key) return@onSizeChanged
|
if (previous == null || previous == size.height || held.key != key) return@onSizeChanged
|
||||||
|
val share = held.share
|
||||||
held.key = null
|
held.key = null
|
||||||
hold(size.height - previous)
|
hold(((size.height - previous) * share).roundToInt())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,7 +265,7 @@ fun SessionScreen(
|
|||||||
val isSubagent = subagent != null
|
val isSubagent = subagent != null
|
||||||
val address = TranscriptAddress(summary.id, subagent?.id)
|
val address = TranscriptAddress(summary.id, subagent?.id)
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val topEdgeHeld = remember { TopEdgeHold() }
|
val touchHeld = remember { TouchHold() }
|
||||||
var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
|
var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
|
||||||
var status by remember { mutableStateOf(subagent?.status ?: summary.status) }
|
var status by remember { mutableStateOf(subagent?.status ?: summary.status) }
|
||||||
var backgroundTasks by
|
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
|
* 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
|
* holds still and all growth goes upward. A tap anywhere in a row that is about to grow or
|
||||||
* gets. A tap in the upper half is the other case -- left alone it sends the heading under the
|
* shrink therefore travels, and how far it travels depends on where down the row it landed.
|
||||||
* reader's finger up off the screen -- and that one asks the row to hold its top edge instead.
|
|
||||||
*
|
*
|
||||||
* 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.
|
* 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 {
|
fun toggleAnchored(row: TranscriptRow, closing: Boolean, toggle: () -> Unit) = expanding {
|
||||||
if (lastTouch.key == row.key && lastTouch.high) topEdgeHeld.key = row.key
|
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()
|
toggle()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1584,25 +1611,33 @@ fun SessionScreen(
|
|||||||
is TranscriptUnit.Whole -> {
|
is TranscriptUnit.Whole -> {
|
||||||
val row = unit.row
|
val row = unit.row
|
||||||
Box(
|
Box(
|
||||||
Modifier.holdTopEdge(row.key, topEdgeHeld) { grew ->
|
Modifier.holdTouchedPoint(row.key, touchHeld) { by ->
|
||||||
// A *request*, not a raw scroll delta: this runs inside
|
// A *request*, not a raw scroll delta: this runs
|
||||||
// the measure pass that discovered the new height, and
|
// inside the measure pass that discovered the new
|
||||||
// a raw delta forces a synchronous remeasure from
|
// height, and a raw delta forces a synchronous
|
||||||
// within measure, which is fatal. The request is
|
// remeasure from within measure, which is fatal. The
|
||||||
// applied by the same frame's next remeasure, so the
|
// request is applied by the same frame's next
|
||||||
// correction still lands before anything is drawn.
|
// remeasure, so the correction still lands before
|
||||||
// Reads unobserved, or this row's measure would inherit
|
// anything is drawn. Reads unobserved, or this row's
|
||||||
// the scroll position as a dependency and remeasure on
|
// measure would inherit the scroll position as a
|
||||||
// every frame.
|
// 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 {
|
Snapshot.withoutReadObservation {
|
||||||
listState.requestScrollToItem(
|
listState.requestScrollToItem(
|
||||||
listState.firstVisibleItemIndex,
|
listState.firstVisibleItemIndex,
|
||||||
(listState.firstVisibleItemScrollOffset + grew)
|
listState.firstVisibleItemScrollOffset + by,
|
||||||
.coerceAtLeast(0),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 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
|
// [toggleAnchored]. On the initial pass and consuming
|
||||||
// nothing, so every control inside still gets the gesture;
|
// nothing, so every control inside still gets the gesture;
|
||||||
// only visible rows have one, which is what makes a
|
// only visible rows have one, which is what makes a
|
||||||
@@ -1615,7 +1650,12 @@ fun SessionScreen(
|
|||||||
pass = PointerEventPass.Initial,
|
pass = PointerEventPass.Initial,
|
||||||
)
|
)
|
||||||
lastTouch.key = row.key
|
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,
|
group = row,
|
||||||
expanded = row.key in expandedGroups,
|
expanded = row.key in expandedGroups,
|
||||||
onToggle = {
|
onToggle = {
|
||||||
toggleAnchored(row) {
|
toggleAnchored(
|
||||||
|
row,
|
||||||
|
closing = row.key in expandedGroups,
|
||||||
|
) {
|
||||||
expandedGroups =
|
expandedGroups =
|
||||||
if (row.key in expandedGroups)
|
if (row.key in expandedGroups)
|
||||||
expandedGroups - row.key
|
expandedGroups - row.key
|
||||||
@@ -1637,7 +1680,10 @@ fun SessionScreen(
|
|||||||
// call makes the whole group taller, and the
|
// call makes the whole group taller, and the
|
||||||
// heading the reader is under is the group's.
|
// heading the reader is under is the group's.
|
||||||
onToolToggle = { id ->
|
onToolToggle = { id ->
|
||||||
toggleAnchored(row) {
|
toggleAnchored(
|
||||||
|
row,
|
||||||
|
closing = id in expandedTools,
|
||||||
|
) {
|
||||||
expandedTools =
|
expandedTools =
|
||||||
if (id in expandedTools)
|
if (id in expandedTools)
|
||||||
expandedTools - id
|
expandedTools - id
|
||||||
@@ -1682,7 +1728,10 @@ fun SessionScreen(
|
|||||||
tool = item,
|
tool = item,
|
||||||
expanded = item.id in expandedTools,
|
expanded = item.id in expandedTools,
|
||||||
onToggle = {
|
onToggle = {
|
||||||
toggleAnchored(row) {
|
toggleAnchored(
|
||||||
|
row,
|
||||||
|
closing = item.id in expandedTools,
|
||||||
|
) {
|
||||||
expandedTools =
|
expandedTools =
|
||||||
if (item.id in expandedTools)
|
if (item.id in expandedTools)
|
||||||
expandedTools - item.id
|
expandedTools - item.id
|
||||||
|
|||||||
Reference in new issue
Block a user