diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Chevron.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Chevron.kt new file mode 100644 index 0000000..0d7cdf7 --- /dev/null +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Chevron.kt @@ -0,0 +1,53 @@ +package com.example.aiapp + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.unit.dp + +/** + * A chevron, pointing up or down. + * + * Drawn rather than set in a font: a chevron from an icon font is one of the glyphs a system font + * may simply not have, and the reader who gets an empty box instead is never the one who wrote it. + * + * One composable for both directions rather than two that differ by a minus sign -- the pair would + * drift, and the drift would be a bug in exactly one direction. + * + * It draws no label of its own, so every caller owes it a `contentDescription`: this is the whole + * of what assistive technology has to go on, and it is also the answer to "what was that arrow + * for" six months from now. + */ +@Composable +fun Chevron( + pointingUp: Boolean, + modifier: Modifier = Modifier, + colour: Color = MaterialTheme.colorScheme.onSurfaceVariant, +) { + Canvas(modifier.width(20.dp).height(10.dp)) { + val inset = 2.dp.toPx() + val point = if (pointingUp) inset else size.height - inset + val ends = if (pointingUp) size.height - inset else inset + val stroke = 2.dp.toPx() + drawLine( + colour, + Offset(inset, ends), + Offset(size.width / 2, point), + strokeWidth = stroke, + cap = StrokeCap.Round, + ) + drawLine( + colour, + Offset(size.width / 2, point), + Offset(size.width - inset, ends), + strokeWidth = stroke, + cap = StrokeCap.Round, + ) + } +} 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 664a144..0bcf7de 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -28,6 +28,10 @@ import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Surface +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -660,16 +664,29 @@ fun SessionScreen( // Only while the newest message is off-screen. Reading back // through a conversation is a place to be, not a state to be - // rescued from, so this waits to be wanted -- and says where it - // goes rather than drawing an arrow, since an arrow in a list - // that is laid out upside down is the one thing nobody should - // have to reason about. + // rescued from, so this waits to be wanted. + // + // Down, and the same chevron a tool group collapses with: the + // list is built upside down internally, but nobody reading it + // knows that -- on screen the newest message is at the bottom, + // which is where this goes. The name is carried in the + // description, since an arrow alone says nothing to a screen + // reader and nothing to whoever finds this in six months. if (!followTail) { - FilledTonalButton( + Surface( onClick = { scope.launch { listState.animateScrollToItem(0) } }, - modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 12.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = + Modifier.align(Alignment.BottomCenter) + .padding(bottom = 12.dp) + .semantics { contentDescription = "Jump to latest" }, ) { - Text("Jump to latest", style = MaterialTheme.typography.bodySmall) + Chevron( + pointingUp = false, + colour = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) } } } 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 3e1661f..2eabe12 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/ToolRows.kt @@ -132,25 +132,7 @@ private fun CollapseBar(onToggle: () -> Unit) { .padding(vertical = 10.dp), horizontalArrangement = Arrangement.Center, ) { - // Drawn rather than set in a font: a chevron from an icon font is one of the glyphs a - // system font may simply not have, and the reader who gets the empty box is never me. - androidx.compose.foundation.Canvas(Modifier.width(20.dp).height(10.dp)) { - val inset = 2.dp.toPx() - drawLine( - colour, - Offset(inset, size.height - inset), - Offset(size.width / 2, inset), - strokeWidth = 2.dp.toPx(), - cap = StrokeCap.Round, - ) - drawLine( - colour, - Offset(size.width / 2, inset), - Offset(size.width - inset, size.height - inset), - strokeWidth = 2.dp.toPx(), - cap = StrokeCap.Round, - ) - } + Chevron(pointingUp = true, colour = colour) } }