diff --git a/PLAN.md b/PLAN.md index 2e27f13..2817db8 100644 --- a/PLAN.md +++ b/PLAN.md @@ -624,9 +624,12 @@ dev-updater (Kotlin 2.4.x, CMP 1.11.x, JDK 21). Screens: output; a spinner while `ToolStart` has no matching `ToolEnd`). - Question cards inline: option buttons for AskUserQuestion, allow/deny for permissions, free-text where allowed. - - Expanding a row **opens downwards**: whichever end the reader pressed — - a group's heading or the bar at its foot — is the end that stays put, - and the row grows away from it. The transcript is laid out from the + - 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 that everything that opens + behaves alike whether or not it has a control at each end — a group's + heading and foot bar simply fall in the halves they already occupy. The transcript is laid out from the bottom, so a row's bottom edge is anchored for free and the top one has to be arranged. The correction lives in the *layout* phase (`Modifier.holdTopEdge`): the measurement that discovers the row's new diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt index ff4ad7a..e2b3ef3 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/PeerMessage.kt @@ -1,6 +1,5 @@ package com.example.aiapp -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -32,10 +31,10 @@ import androidx.compose.ui.unit.dp fun PeerMessageRow( item: TranscriptItem.PeerNote, expanded: Boolean, - onToggle: () -> Unit, + onToggle: (Float) -> Unit, modifier: Modifier = Modifier, ) { - Card(modifier.fillMaxWidth().clickable(onClick = onToggle)) { + Card(modifier.fillMaxWidth().clickableAt(onToggle)) { Column(Modifier.padding(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Text("Message from ${item.from}", style = MaterialTheme.typography.titleSmall) 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 a5bc392..1a21078 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -47,7 +47,9 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -94,6 +96,21 @@ private class TopEdgeHold { var key: Any? = null } +/** + * Where a row is on screen, so a tap on it can be told which half it landed in. + * + * Not snapshot state, for the same reason as [TopEdgeHold]: written from layout, read from a click, + * and observed by nothing. + */ +private class RowBounds { + var top = 0f + var height = 0f + + /** Above this is the row's top half, below it the bottom half. */ + val middle + get() = top + height / 2 +} + /** One row's height between layouts, so a change in it can be noticed. See [holdTopEdge]. */ private class LastHeight { var value: Int? = null @@ -718,21 +735,25 @@ fun SessionScreen( } /** - * Changes a row's height while the end the reader pressed stays where it is. + * Changes a row's height while the end 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 right for the bar at the foot of an open - * group -- shut it from there and what follows it does not move, which is what the reader is - * looking at. It is exactly wrong for a heading: opening a group from the top sends the heading - * up off the screen and fills the space above it, so the calls appear on the far side of the - * control that produced them. + * holds still and all growth goes upward. That is what a tap in a row's lower half already + * gets, so it needs nothing: shut a group from the bar at its foot and what follows it does not + * move, which is what the reader is looking at down there. 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 fills + * the space above it, so the calls appear on the far side of the control that produced them -- + * and that one asks the row to hold its top edge instead. * - * So [RowEdge.Bottom] is the list's own behaviour and needs nothing, while [RowEdge.Top] marks - * the row as holding its top edge the next time it is measured. The correction itself belongs - * to the measurement -- see [holdTopEdge]. + * Which half decides it, rather than which control was pressed, so that everything that opens + * behaves the same way whether or not it happens to have a control at each end. A group has two + * and its heading and foot bar land in the halves they are already in; a single call is one + * card, and tapping low on an open one shuts it downward exactly as the bar does. + * + * The correction itself belongs to the measurement -- see [holdTopEdge]. */ - fun toggleAnchored(key: Any, edge: RowEdge, toggle: () -> Unit) { - if (edge == RowEdge.Top) topEdgeHeld.key = key + fun toggleAnchored(key: Any, row: RowBounds, at: Float, toggle: () -> Unit) { + if (at < row.middle) topEdgeHeld.key = key toggle() } @@ -1187,24 +1208,29 @@ fun SessionScreen( // at the same end. Paging older history is the opposite insertion and was // already fine, and stays fine, because a key survives both. items(rows.asReversed(), key = { it.key }) { row -> + val bounds = remember { RowBounds() } Box( - Modifier.holdTopEdge(row.key, topEdgeHeld) { grew -> - // Requested rather than scrolled. Scrolling forces a remeasure, - // and forcing one from inside a measure throws; this is the form - // built to be asked for during layout and applied in that pass. - listState.requestScrollToItem( - listState.firstVisibleItemIndex, - listState.firstVisibleItemScrollOffset + grew, - ) - } + Modifier.onGloballyPositioned { + bounds.top = it.positionInRoot().y + bounds.height = it.size.height.toFloat() + } + .holdTopEdge(row.key, topEdgeHeld) { grew -> + // Requested rather than scrolled. Scrolling forces a remeasure, + // and forcing one from inside a measure throws; this is the form + // built to be asked for during layout and applied in that pass. + listState.requestScrollToItem( + listState.firstVisibleItemIndex, + listState.firstVisibleItemScrollOffset + grew, + ) + } ) { when (row) { is TranscriptRow.Tools -> ToolGroup( group = row, expanded = row.id in expandedGroups, - onToggle = { edge -> - toggleAnchored(row.key, edge) { + onToggle = { at -> + toggleAnchored(row.key, bounds, at) { expandedGroups = if (row.id in expandedGroups) expandedGroups - row.id @@ -1215,8 +1241,8 @@ fun SessionScreen( // Anchored on the group, not the call: opening one call makes // the whole group taller, and the heading the reader is under // is the group's. - onToolToggle = { id -> - toggleAnchored(row.key, RowEdge.Top) { + onToolToggle = { id, at -> + toggleAnchored(row.key, bounds, at) { expandedTools = if (id in expandedTools) expandedTools - id else expandedTools + id @@ -1248,8 +1274,8 @@ fun SessionScreen( ToolCard( tool = item, expanded = item.id in expandedTools, - onToggle = { - toggleAnchored(row.key, RowEdge.Top) { + onToggle = { at -> + toggleAnchored(row.key, bounds, at) { expandedTools = if (item.id in expandedTools) expandedTools - item.id @@ -1302,8 +1328,8 @@ fun SessionScreen( PeerMessageRow( item = item, expanded = item.seq in expandedNotes, - onToggle = { - toggleAnchored(row.key, RowEdge.Top) { + onToggle = { at -> + toggleAnchored(row.key, bounds, at) { expandedNotes = if (item.seq in expandedNotes) expandedNotes - item.seq diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt index d7bae76..d8a946e 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt @@ -2,6 +2,8 @@ package com.example.aiapp import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -18,6 +20,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow @@ -99,18 +105,36 @@ fun groupToolRuns(items: List): List { return rows } +/** Where a click went down, and what it went down on. See [clickableAt]. */ +private class TapPoint { + var coords: LayoutCoordinates? = null + var y = 0f +} + /** - * Which end of a row a reader acted on, and therefore which end must not move. + * Clickable, and tells the click where on the screen the finger went down. * - * A row has two controls at opposite ends -- the heading that opens it and the bar that shuts it - * again -- and the reader's finger is on one of them. Whichever it is has to stay where it is while - * the row changes size, or the thing they just pressed slides out from under them. The list anchors - * every row's bottom edge by default (see the transcript's `reverseLayout`), so [Bottom] is what - * happens on its own and [Top] is what has to be arranged. + * A row holds still the end nearest the tap when it changes height, so the toggle has to say where + * it was touched. It cannot say which *end* it was: a group is one row with a control at each end + * and a call in the middle, and only the row knows where its own ends are. So this reports a + * position in root coordinates and leaves the meaning to whoever owns the row. + * + * Built on `clickable` rather than replacing it, because `clickable` is what draws the ripple and + * what puts a click action in front of assistive technology. The position is read on the initial + * pass and nothing is consumed, so the click still happens exactly as it would have. */ -enum class RowEdge { - Top, - Bottom, +@Composable +fun Modifier.clickableAt(onClick: (Float) -> Unit): Modifier { + val tap = remember { TapPoint() } + return onGloballyPositioned { tap.coords = it } + .pointerInput(Unit) { + awaitEachGesture { + val down = + awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + tap.y = tap.coords?.localToRoot(down.position)?.y ?: 0f + } + } + .clickable { onClick(tap.y) } } /** @@ -127,15 +151,17 @@ enum class RowEdge { fun ToolGroup( group: TranscriptRow.Tools, expanded: Boolean, - /** Told which end was pressed, because this row has a control at each -- see [RowEdge]. */ - onToggle: (RowEdge) -> Unit, + /** + * Told where it was pressed, because this row has a control at each end -- see [clickableAt]. + */ + onToggle: (Float) -> Unit, isToolExpanded: (String) -> Boolean, - onToolToggle: (String) -> Unit, + onToolToggle: (String, Float) -> Unit, onAnswer: (questionId: String, answers: List) -> Unit, image: @Composable (String) -> Unit, ) { if (!expanded) { - Card(Modifier.fillMaxWidth().clickable { onToggle(RowEdge.Top) }) { + Card(Modifier.fillMaxWidth().clickableAt(onToggle)) { Text( "Called ${group.calls.size} tools", style = MaterialTheme.typography.titleSmall, @@ -148,30 +174,30 @@ fun ToolGroup( Text( "Called ${group.calls.size} tools", style = MaterialTheme.typography.titleSmall, - modifier = Modifier.fillMaxWidth().clickable { onToggle(RowEdge.Top) }.padding(12.dp), + modifier = Modifier.fillMaxWidth().clickableAt(onToggle).padding(12.dp), ) group.calls.forEach { call -> ToolCard( tool = call, expanded = isToolExpanded(call.id), - onToggle = { onToolToggle(call.id) }, + onToggle = { at -> onToolToggle(call.id, at) }, onAnswer = onAnswer, image = image, ) } // Shutting it from here anchors the other end: the reader is at the bottom of a long // group, and what they are looking at is what follows it. - CollapseBar { onToggle(RowEdge.Bottom) } + CollapseBar(onToggle) } } /** The bottom half of a group's toggle: an arrow back up to its heading. */ @Composable -private fun CollapseBar(onToggle: () -> Unit) { +private fun CollapseBar(onToggle: (Float) -> Unit) { val colour = MaterialTheme.colorScheme.onSurfaceVariant Row( Modifier.fillMaxWidth() - .clickable(onClick = onToggle) + .clickableAt(onToggle) .semantics { contentDescription = "Collapse these tool calls" } .padding(vertical = 10.dp), horizontalArrangement = Arrangement.Center, @@ -198,14 +224,14 @@ private fun CollapseBar(onToggle: () -> Unit) { fun ToolCard( tool: TranscriptItem.ToolRun, expanded: Boolean, - onToggle: () -> Unit, + onToggle: (Float) -> Unit, onAnswer: (questionId: String, answers: List) -> Unit, image: @Composable (String) -> Unit = {}, ) { val parsed = remember(tool.tool, tool.input) { parseToolInput(tool.tool, tool.input) } val deciding = tool.asks.any { it.answers.isEmpty() } val open = expanded || deciding - Card(Modifier.fillMaxWidth().clickable(onClick = onToggle)) { + Card(Modifier.fillMaxWidth().clickableAt(onToggle)) { Column(Modifier.padding(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Text(tool.tool, style = MaterialTheme.typography.titleSmall)