Merge branch 'main' of git.arirex.me:iris/ai-app
This commit is contained in:
commit
d829a77ddf
6 files changed
+147
-257
No files matched your search
@@ -1,101 +0,0 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
/**
|
||||
* How much of a reply is drawn before the reader is offered the rest.
|
||||
*
|
||||
* A limit on the *source*, not on the height, and that is the whole point. Clipping a laid-out row
|
||||
* to a height saves nothing: Compose measures the text and then throws the overflow away, so the
|
||||
* line-breaking has already happened. Cutting the string before it is parsed is what stops the work
|
||||
* from being done -- and it stops the parse too, which the height version could never reach.
|
||||
*
|
||||
* Four thousand characters is about two screenfuls of body text on a phone. Two rather than one so
|
||||
* that a reply somewhat over the limit is not cut nearly in half, and so a reader who does not
|
||||
* press anything still gets more than they can see at once.
|
||||
*
|
||||
* The measurement that made this worth having, from the ai-app-2 session on 2026-08-30: one message
|
||||
* in a real transcript is over 14,000px tall -- seven screens -- and a single frame spent 59.6ms in
|
||||
* `measureAndLayout` when it entered the viewport. That cost is proportional to the whole row
|
||||
* however little of it is on screen, and it is paid again every time the row comes back.
|
||||
*/
|
||||
private const val REPLY_CAP_CHARS = 4000
|
||||
|
||||
/**
|
||||
* How far past the cap a reply has to be before it is worth cutting.
|
||||
*
|
||||
* Without this a message of 4,001 characters loses one character and gains a button, which is worth
|
||||
* nothing to anybody and costs a control that has to be read and decided about. The row that needs
|
||||
* this treatment is several times the limit, not just over it.
|
||||
*/
|
||||
private const val REPLY_CAP_SLACK = 1000
|
||||
|
||||
/**
|
||||
* The opening of [text] if it is long enough to be worth cutting, or null if it should be drawn
|
||||
* whole.
|
||||
*
|
||||
* Cut at a line ending, because a markdown source cut mid-line is a different document: half a
|
||||
* heading marker, a list item with no bullet, a link whose closing bracket is in the part that was
|
||||
* dropped. A whole number of lines is the coarsest cut that cannot invent syntax.
|
||||
*
|
||||
* A fence left open by the cut is closed, which is the one case a line boundary does not save. An
|
||||
* unterminated ``` swallows the rest of the reply into a code block, so the truncation would change
|
||||
* how the part still on screen is *drawn* rather than only how much of it there is -- and a reader
|
||||
* has no way to tell that from the reply genuinely having been code.
|
||||
*/
|
||||
fun shortenedReply(text: String, limit: Int = REPLY_CAP_CHARS): String? {
|
||||
if (text.length <= limit + REPLY_CAP_SLACK) return null
|
||||
val cut = text.lastIndexOf('\n', limit).let { if (it <= 0) limit else it }
|
||||
val head = text.substring(0, cut)
|
||||
// Fences are counted rather than matched: an opening and a closing one are the same token, so
|
||||
// an odd number of them is an opening that never closed.
|
||||
return if (head.split("\n").count { it.trimStart().startsWith("```") } % 2 == 1) "$head\n```"
|
||||
else head
|
||||
}
|
||||
|
||||
/**
|
||||
* A reply drawn to [REPLY_CAP_CHARS], with the rest a press away.
|
||||
*
|
||||
* The control says how much is behind it rather than only "Show more", because the two answers a
|
||||
* reader wants are different sizes of decision: another paragraph is worth opening while standing
|
||||
* in a scroll, and another twenty screens is worth knowing about first.
|
||||
*
|
||||
* Expanded is remembered by the screen rather than by this row, so scrolling away and back does not
|
||||
* shut something the reader deliberately opened -- see SessionScreen's other expansion sets.
|
||||
*/
|
||||
@Composable
|
||||
fun CappedReply(
|
||||
full: String,
|
||||
shortened: String,
|
||||
replies: ParsedReplies,
|
||||
onShowMore: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier.fillMaxWidth()) {
|
||||
AssistantMessage(shortened, replies)
|
||||
TextButton(onClick = onShowMore) {
|
||||
Text(
|
||||
"Show the rest (${remaining(full.length - shortened.length)})",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What is left, in the units a reader thinks in rather than in characters. */
|
||||
private fun remaining(chars: Int): String {
|
||||
// Against the same figure the cap is written in, so the two cannot drift: a "screenful" here is
|
||||
// whatever [REPLY_CAP_CHARS] is two of.
|
||||
val screens = chars.toDouble() / (REPLY_CAP_CHARS / 2)
|
||||
return when {
|
||||
screens < 1.5 -> "about another screen"
|
||||
screens < 20 -> "about ${Math.round(screens)} more screens"
|
||||
else -> "more than 20 screens"
|
||||
}
|
||||
}
|
||||
@@ -60,15 +60,10 @@ private fun partsOf(text: String): List<MessagePart> {
|
||||
/**
|
||||
* Every string a reply will be drawn from, for [ParsedReplies.warm] to make ready.
|
||||
*
|
||||
* Both forms of a long one, because which gets drawn is not decided here: the newest reply is drawn
|
||||
* whole and every other long one is drawn cut (see [shortenedReply]), and the reader can ask for
|
||||
* the rest of any of them. Warming only one of the two would leave the other parsing on the thread
|
||||
* that draws, in the frame the row appears -- and a string warmed under a key no row ever looks up
|
||||
* is a miss that nothing reports. The extra parse is off the composing thread, which is the only
|
||||
* place it would have cost anything.
|
||||
* A string warmed under a key no row ever looks up is a miss that nothing reports, so this has to
|
||||
* name what the rows actually draw rather than what the message contains.
|
||||
*/
|
||||
fun markdownIn(text: String): List<String> =
|
||||
partsOf(text).flatMap { part -> listOfNotNull(part.text, shortenedReply(part.text)) }
|
||||
fun markdownIn(text: String): List<String> = partsOf(text).map { it.text }
|
||||
|
||||
@Composable
|
||||
private fun MemoryNote(note: MessagePart.Remembered, replies: ParsedReplies) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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
|
||||
@@ -31,11 +32,11 @@ import androidx.compose.ui.unit.dp
|
||||
fun PeerMessageRow(
|
||||
item: TranscriptItem.PeerNote,
|
||||
expanded: Boolean,
|
||||
onToggle: (Float) -> Unit,
|
||||
onToggle: () -> Unit,
|
||||
replies: ParsedReplies,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(modifier.fillMaxWidth().clickableAt(onToggle)) {
|
||||
Card(modifier.fillMaxWidth().clickable(onClick = onToggle)) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Message from ${item.from}", style = MaterialTheme.typography.titleSmall)
|
||||
|
||||
@@ -45,9 +45,7 @@ import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
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
|
||||
@@ -142,21 +140,6 @@ 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
|
||||
@@ -643,10 +626,6 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
// by default, which is the rule for anything new in this transcript: a screen that opens
|
||||
// everything it can is one nobody can scan.
|
||||
var expandedNotes by remember { mutableStateOf(setOf<Long>()) }
|
||||
// Long replies the reader has asked to see the rest of, by the seq of the row. Held here
|
||||
// rather than in the row so that scrolling away and back does not shut something they
|
||||
// deliberately opened -- the same reason the sets above it are here.
|
||||
var expandedReplies by remember { mutableStateOf(setOf<Long>()) }
|
||||
// Uploaded-but-not-yet-sent attachment ids; sent with the next message.
|
||||
var pendingAttachments by remember { mutableStateOf(listOf<String>()) }
|
||||
// What this session is set to now, seeded from the row that opened it and
|
||||
@@ -714,12 +693,6 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
// What is actually drawn: the transcript with runs of adjacent tool
|
||||
// calls folded into one row each.
|
||||
val rows = remember(items) { groupToolRuns(items) }
|
||||
// The reply drawn whole however long it is; see the transcript list below. Recomputed with
|
||||
// `items` rather than tracked as it arrives, because "newest" moves: a reply that was the
|
||||
// last one becomes history the moment the next turn starts, and a row that kept its
|
||||
// exemption after that would be the one enormous row this exists to bound.
|
||||
val newestReply =
|
||||
remember(items) { items.filterIsInstance<TranscriptItem.AssistantMsg>().lastOrNull()?.seq }
|
||||
|
||||
/**
|
||||
* Everything the transcript list draws, from one event.
|
||||
@@ -833,8 +806,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
*
|
||||
* The correction itself belongs to the measurement -- see [holdTopEdge].
|
||||
*/
|
||||
fun toggleAnchored(key: Any, row: RowBounds, at: Float, toggle: () -> Unit) {
|
||||
if (at < row.middle) topEdgeHeld.key = key
|
||||
fun toggleAnchored(row: TranscriptRow, toggle: () -> Unit) {
|
||||
if (listState.tappedHigh(row.startSeq)) topEdgeHeld.key = row.key
|
||||
toggle()
|
||||
}
|
||||
|
||||
@@ -1423,21 +1396,14 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
}
|
||||
},
|
||||
) { row ->
|
||||
val bounds = remember { RowBounds() }
|
||||
Box(
|
||||
Modifier.onGloballyPositioned {
|
||||
bounds.top = it.positionInRoot().y
|
||||
bounds.height = it.size.height.toFloat()
|
||||
}
|
||||
.holdTopEdge(row.key, topEdgeHeld) { grew -> listState.by(grew) }
|
||||
) {
|
||||
Box(Modifier.holdTopEdge(row.key, topEdgeHeld) { grew -> listState.by(grew) }) {
|
||||
when (row) {
|
||||
is TranscriptRow.Tools ->
|
||||
ToolGroup(
|
||||
group = row,
|
||||
expanded = row.id in expandedGroups,
|
||||
onToggle = { at ->
|
||||
toggleAnchored(row.key, bounds, at) {
|
||||
onToggle = {
|
||||
toggleAnchored(row) {
|
||||
expandedGroups =
|
||||
if (row.id in expandedGroups)
|
||||
expandedGroups - row.id
|
||||
@@ -1448,8 +1414,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
// 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, at ->
|
||||
toggleAnchored(row.key, bounds, at) {
|
||||
onToolToggle = { id ->
|
||||
toggleAnchored(row) {
|
||||
expandedTools =
|
||||
if (id in expandedTools) expandedTools - id
|
||||
else expandedTools + id
|
||||
@@ -1476,43 +1442,14 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
text = item.text,
|
||||
images = item.images,
|
||||
)
|
||||
is TranscriptItem.AssistantMsg -> {
|
||||
// The newest reply is never cut. It is the one being read
|
||||
// as it arrives -- often still arriving -- and putting a
|
||||
// "show the rest" under a turn somebody is waiting for
|
||||
// hides the answer they are waiting for. Every reply
|
||||
// behind it is history, and history is what this is for.
|
||||
val shortened =
|
||||
if (
|
||||
item.seq == newestReply ||
|
||||
item.seq in expandedReplies
|
||||
)
|
||||
null
|
||||
else remember(item.text) { shortenedReply(item.text) }
|
||||
if (shortened == null) {
|
||||
is TranscriptItem.AssistantMsg ->
|
||||
AssistantMessage(item.text, replies)
|
||||
} else {
|
||||
CappedReply(
|
||||
full = item.text,
|
||||
shortened = shortened,
|
||||
replies = replies,
|
||||
onShowMore = {
|
||||
// Anchored like every other row that changes
|
||||
// height, so the edge the reader touched
|
||||
// stays where it is.
|
||||
toggleAnchored(row.key, bounds, bounds.top) {
|
||||
expandedReplies = expandedReplies + item.seq
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
is TranscriptItem.ToolRun ->
|
||||
ToolCard(
|
||||
tool = item,
|
||||
expanded = item.id in expandedTools,
|
||||
onToggle = { at ->
|
||||
toggleAnchored(row.key, bounds, at) {
|
||||
onToggle = {
|
||||
toggleAnchored(row) {
|
||||
expandedTools =
|
||||
if (item.id in expandedTools)
|
||||
expandedTools - item.id
|
||||
@@ -1566,8 +1503,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
item = item,
|
||||
expanded = item.seq in expandedNotes,
|
||||
replies = replies,
|
||||
onToggle = { at ->
|
||||
toggleAnchored(row.key, bounds, at) {
|
||||
onToggle = {
|
||||
toggleAnchored(row) {
|
||||
expandedNotes =
|
||||
if (item.seq in expandedNotes)
|
||||
expandedNotes - item.seq
|
||||
|
||||
@@ -2,8 +2,6 @@ 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
|
||||
@@ -25,10 +23,6 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
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.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
@@ -132,38 +126,6 @@ fun groupToolRuns(items: List<TranscriptItem>): List<TranscriptRow> {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Clickable, and tells the click where on the screen the finger went down.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
@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) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Several calls under one heading, closed until somebody asks.
|
||||
*
|
||||
@@ -188,17 +150,19 @@ fun ToolGroup(
|
||||
group: TranscriptRow.Tools,
|
||||
expanded: Boolean,
|
||||
/**
|
||||
* Told where it was pressed, because this row has a control at each end -- see [clickableAt].
|
||||
* Where it was pressed is the row's business rather than the control's -- a group has a control
|
||||
* at each end, and only the row knows where its own ends are, so the row records the touch
|
||||
* itself and this just says that one happened.
|
||||
*/
|
||||
onToggle: (Float) -> Unit,
|
||||
onToggle: () -> Unit,
|
||||
isToolExpanded: (String) -> Boolean,
|
||||
onToolToggle: (String, Float) -> Unit,
|
||||
onToolToggle: (String) -> Unit,
|
||||
onAnswer: (questionId: String, answers: List<String>) -> Unit,
|
||||
image: @Composable (String) -> Unit,
|
||||
) {
|
||||
val heading = "Called ${group.calls.size} tools"
|
||||
if (!expanded) {
|
||||
Card(Modifier.fillMaxWidth().clickableAt(onToggle)) {
|
||||
Card(Modifier.fillMaxWidth().clickable(onClick = onToggle)) {
|
||||
Text(
|
||||
heading,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
@@ -214,7 +178,7 @@ fun ToolGroup(
|
||||
) {
|
||||
val barHeight = groupBarHeight()
|
||||
Row(
|
||||
Modifier.fillMaxWidth().height(barHeight).clickableAt(onToggle),
|
||||
Modifier.fillMaxWidth().height(barHeight).clickable(onClick = onToggle),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
@@ -231,7 +195,7 @@ fun ToolGroup(
|
||||
ToolCard(
|
||||
tool = call,
|
||||
expanded = isToolExpanded(call.id),
|
||||
onToggle = { at -> onToolToggle(call.id, at) },
|
||||
onToggle = { onToolToggle(call.id) },
|
||||
onAnswer = onAnswer,
|
||||
image = image,
|
||||
shape = connectedShape(index, group.calls.size),
|
||||
@@ -264,10 +228,10 @@ private fun groupBarHeight(): Dp {
|
||||
* calls sit on is the same thickness at both ends. See [groupBarHeight].
|
||||
*/
|
||||
@Composable
|
||||
private fun CollapseBar(height: Dp, onToggle: (Float) -> Unit) {
|
||||
private fun CollapseBar(height: Dp, onToggle: () -> Unit) {
|
||||
val colour = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Row(
|
||||
Modifier.fillMaxWidth().height(height).clickableAt(onToggle).semantics {
|
||||
Modifier.fillMaxWidth().height(height).clickable(onClick = onToggle).semantics {
|
||||
contentDescription = "Collapse these tool calls"
|
||||
},
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
@@ -324,7 +288,7 @@ private val GROUP_GAP = 2.dp
|
||||
fun ToolCard(
|
||||
tool: TranscriptItem.ToolRun,
|
||||
expanded: Boolean,
|
||||
onToggle: (Float) -> Unit,
|
||||
onToggle: () -> Unit,
|
||||
onAnswer: (questionId: String, answers: List<String>) -> Unit,
|
||||
image: @Composable (String) -> Unit = {},
|
||||
/** Square where this card faces another in a group; see [connectedShape]. */
|
||||
@@ -333,7 +297,7 @@ fun ToolCard(
|
||||
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().clickableAt(onToggle), shape = shape) {
|
||||
Card(Modifier.fillMaxWidth().clickable(onClick = onToggle), shape = shape) {
|
||||
Column(Modifier.padding(GROUP_INSET_LARGE)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(tool.tool, style = MaterialTheme.typography.titleSmall)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import androidx.compose.foundation.ScrollState
|
||||
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.PaddingValues
|
||||
@@ -18,8 +20,12 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
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.onPlaced
|
||||
import androidx.compose.ui.layout.positionInParent
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@@ -54,17 +60,62 @@ import androidx.compose.ui.unit.dp
|
||||
class TranscriptScroll(internal val scroll: ScrollState) {
|
||||
|
||||
/**
|
||||
* Where each row's top edge sits inside the content, by the seq that names it.
|
||||
* How tall each row is, by the seq that names it, and the order they are drawn in.
|
||||
*
|
||||
* Written from the layout pass as rows are placed, so it describes the layout that is on
|
||||
* screen. Keyed on [TranscriptRow.startSeq] rather than on the row's display key for the reason
|
||||
* the anchor is: a tool run is renamed when the newest page starts somewhere new, and a
|
||||
* position recorded against the old name is never found again.
|
||||
* Heights rather than positions, and that is the whole difference between this costing nothing
|
||||
* and costing the frame. A position is only correct for one layout, so keeping one per row
|
||||
* meant a callback per row per frame once the list stopped disposing them -- the transcript
|
||||
* lagged the moment a second page was loaded, and worse with each page after. A height changes
|
||||
* when its row changes and not otherwise, and a position can be added up from heights at the
|
||||
* two moments anything actually needs one: saving where the reader is, and putting it back.
|
||||
*
|
||||
* Keyed on [TranscriptRow.startSeq] rather than on the row's display key for the reason the
|
||||
* anchor is: a tool run is renamed when the newest page starts somewhere new, and a position
|
||||
* recorded against the old name is never found again.
|
||||
*/
|
||||
private val tops = HashMap<Long, Int>()
|
||||
private val heights = HashMap<Long, Int>()
|
||||
private var order: List<Long> = emptyList()
|
||||
private var spacing = 0
|
||||
private var padTop = 0
|
||||
|
||||
internal fun laidOut(order: List<Long>, spacing: Int, padTop: Int) {
|
||||
this.order = order
|
||||
this.spacing = spacing
|
||||
this.padTop = padTop
|
||||
}
|
||||
|
||||
internal fun height(seq: Long, height: Int) {
|
||||
heights[seq] = height
|
||||
}
|
||||
|
||||
/** Where the last touch went down, in the content's own coordinates. */
|
||||
private var tapY = 0f
|
||||
|
||||
internal fun touched(y: Float) {
|
||||
tapY = y
|
||||
}
|
||||
|
||||
/**
|
||||
* A position waiting to be put back, applied by the layout that first places its row.
|
||||
* Whether the last touch landed in the top half of the row named by [seq], which is the end
|
||||
* that row should hold when it changes height.
|
||||
*
|
||||
* One detector for the whole list rather than one per row, and one lookup at the moment of the
|
||||
* tap rather than a position kept current for every row. Both of the obvious arrangements cost
|
||||
* a callback per row per frame once the list stopped disposing rows -- an
|
||||
* `onGloballyPositioned` to know where a row is, or a gesture detector on each row to catch its
|
||||
* own touches -- and together they were most of the frame: on a deep transcript they took a
|
||||
* scroll from 4% of frames over budget to 47%. Neither is needed. The content knows where it
|
||||
* was touched, the heights say where each row starts, and the sum is only wanted when somebody
|
||||
* actually taps.
|
||||
*/
|
||||
fun tappedHigh(seq: Long): Boolean {
|
||||
val top = topOf(seq) ?: return false
|
||||
val height = heights[seq] ?: return false
|
||||
return tapY < top + height / 2f
|
||||
}
|
||||
|
||||
/**
|
||||
* A position waiting to be put back, applied by the layout that first places the content.
|
||||
*
|
||||
* Held here rather than applied by whoever loaded the row because the pixels do not exist yet
|
||||
* at that point: a plain column has no height for a row until it has been measured. Applying it
|
||||
@@ -79,19 +130,31 @@ class TranscriptScroll(internal val scroll: ScrollState) {
|
||||
val settling: Boolean
|
||||
get() = pending != null
|
||||
|
||||
internal fun placed(seq: Long, top: Int) {
|
||||
tops[seq] = top
|
||||
pending?.let { anchor ->
|
||||
if (anchor.seq != seq) return@let
|
||||
scroll.dispatchRawDelta(
|
||||
(scroll.maxValue - top - anchor.offset - scroll.value).toFloat()
|
||||
)
|
||||
/**
|
||||
* The content has been placed: put back a waiting position, if its row is there to hold it.
|
||||
*
|
||||
* Once per layout rather than once per row. Both numbers it needs are the scroll container's
|
||||
* own, and those are written during measure -- so by placement they describe this layout.
|
||||
*/
|
||||
internal fun placed() {
|
||||
val anchor = pending ?: return
|
||||
val rowTop = topOf(anchor.seq) ?: return
|
||||
scroll.dispatchRawDelta((scroll.maxValue - rowTop - anchor.offset - scroll.value).toFloat())
|
||||
pending = null
|
||||
}
|
||||
|
||||
/** Where a row's top edge sits inside the content, or null if it has not been measured. */
|
||||
private fun topOf(seq: Long): Int? {
|
||||
var y = padTop
|
||||
for (s in order) {
|
||||
if (s == seq) return y
|
||||
y += (heights[s] ?: return null) + spacing
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Everything these described is gone -- a stream reset, or a different session. */
|
||||
fun clear() = tops.clear()
|
||||
fun clear() = heights.clear()
|
||||
|
||||
/** Whether the newest message is on screen. See the class comment: the newest end is zero. */
|
||||
val atNewest: Boolean
|
||||
@@ -116,8 +179,14 @@ class TranscriptScroll(internal val scroll: ScrollState) {
|
||||
fun anchor(): ScrollAnchor? {
|
||||
val top = scroll.maxValue - scroll.value
|
||||
// The row covering the top of the viewport: the last one that starts at or above it.
|
||||
val at = tops.entries.filter { it.value <= top }.maxByOrNull { it.value } ?: return null
|
||||
return ScrollAnchor(at.key, top - at.value)
|
||||
var y = padTop
|
||||
var found: Pair<Long, Int>? = null
|
||||
for (s in order) {
|
||||
if (y > top) break
|
||||
found = s to y
|
||||
y += (heights[s] ?: return null) + spacing
|
||||
}
|
||||
return found?.let { (seq, rowTop) -> ScrollAnchor(seq, top - rowTop) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,22 +242,47 @@ fun TranscriptColumn(
|
||||
below: @Composable () -> Unit,
|
||||
row: @Composable (TranscriptRow) -> Unit,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
// The order and the gaps, so a row's position can be added up from heights when one is wanted.
|
||||
// Recomputed only when the rows change, which is what keeps every frame free of it.
|
||||
remember(rows, spacing, contentPadding, density) {
|
||||
state.laidOut(
|
||||
rows.map { it.startSeq },
|
||||
with(density) { spacing.roundToPx() },
|
||||
with(density) { contentPadding.calculateTopPadding().roundToPx() },
|
||||
)
|
||||
layoutDirection
|
||||
}
|
||||
Column(
|
||||
modifier
|
||||
.verticalScroll(state.scroll, reverseScrolling = true)
|
||||
.padding(contentPadding)
|
||||
.heightIn(min = viewportHeight)
|
||||
.fillMaxWidth(),
|
||||
.fillMaxWidth()
|
||||
// Once for the whole list, not once per row: this is where a saved position is put
|
||||
// back, and by placement the scroll container's own measurements describe this layout.
|
||||
.onPlaced { state.placed() }
|
||||
// One gesture detector for the whole list; see [TranscriptScroll.tappedHigh]. On the
|
||||
// initial pass and consuming nothing, so every control inside still gets the gesture
|
||||
// exactly as it would have.
|
||||
.pointerInput(Unit) {
|
||||
awaitEachGesture {
|
||||
state.touched(
|
||||
awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial)
|
||||
.position
|
||||
.y
|
||||
)
|
||||
}
|
||||
},
|
||||
verticalArrangement = Arrangement.spacedBy(spacing, Alignment.Bottom),
|
||||
) {
|
||||
rows.forEach { item ->
|
||||
// Keyed so that a row keeps its composition -- and so the state inside it, an open
|
||||
// tool call or an expanded reply, stays with the row rather than with the position.
|
||||
// tool call, stays with the row rather than with the position.
|
||||
key(item.key) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().onPlaced {
|
||||
state.placed(item.startSeq, it.positionInParent().y.toInt())
|
||||
}
|
||||
Modifier.fillMaxWidth().onSizeChanged { state.height(item.startSeq, it.height) }
|
||||
) {
|
||||
row(item)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user