Colour the composer by what its buttons do, and let the read-out breathe less

Queue is the paper plane with a clock on it (`md-send_clock`) rather than
the plain plane plus the word: the pair is now told apart by the mark, which
is what an icon is for, and the word survives as the button's accessible
name where a screen reader still needs it.

The three composer buttons take their colour from what pressing one does --
green sends now, blue sends later, red takes the running turn away -- and
Stop becomes a filled button like the other two. Outlined said it was a
qualifier on the primary action; it is a second thing you can do about the
turn, and what separates them is the colour and the mark. The fills are
named in Theme.kt with their content colour stated beside them, because a
semantic colour has to carry its own contrast: these do not change with the
surface, so nothing will rescue a foreground that stops being readable.
Worth knowing when reading that file: the action greens and reds sit next to
a `runningColor` green and a `failedColor` red, which are *states*. Nothing
in one set is pressable and nothing in the other is a state, so a reader
never has to tell them apart.

The usage read-out loses its per-machine cards. A card is a step up the
surface ladder and inside a dialog -- already a raised surface -- the step
barely rendered while costing 16dp on every side. The machine and the
service it answered for are one small quiet line instead of a heading over a
subtitle, since the numbers underneath are what somebody opened this to see.

The gaps between the bars now go *between* them rather than after each,
which is what put a band of empty dialog above Close. The rest of that band
was AlertDialog's own spacing, fixed at sizes meant for a sentence of prose
and a decision, so this is a plain Dialog with the same container colour and
corner and spacing chosen for a dense read-out.

Looked at on the emulator: green send, then blue queue beside red stop
during a `/slow 20` echo turn, and the dialog over the live session. The
account had risen to 78% by then, which showed the five-hour bar and the
header glyph going yellow on real numbers rather than forced ones.
This commit is contained in:
iris committed 2026-08-29 22:45:18 -04:00
1 parent ff39ef5cf9
commit 69ef6f068a
6 files changed
+120 -52

No files matched your search

@@ -34,9 +34,9 @@ import androidx.compose.ui.unit.sp
* The same arrangement as dev-updater, down to the cog and the refresh arrow being the same two
* Material Design codepoints. Those two must not drift: an icon that means "settings" in one app
* and something else in the other is the failure this is worth preventing. The script is copied
* rather than shared because most of what looks like duplication is the `GLYPHS` list, which has
* to differ -- the point of subsetting is to ship only the codepoints one app draws. All Material Design
* bar one, so they read as one family; the exception is noted where it is declared.
* rather than shared because most of what looks like duplication is the `GLYPHS` list, which has to
* differ -- the point of subsetting is to ship only the codepoints one app draws. All Material
* Design bar one, so they read as one family; the exception is noted where it is declared.
*/
val NerdIcons = FontFamily(Font(R.font.nerd_icons))
@@ -55,6 +55,15 @@ val SEND_GLYPH = glyph(0xF048A)
/** `md-stop` -- a filled square: interrupt the turn that is running. */
val STOP_GLYPH = glyph(0xF04DB)
/**
* `md-send_clock` -- the same paper plane with a clock on it: this message will wait its turn.
*
* The pair with [SEND_GLYPH] is the point. Sending during a turn queues the message rather than
* starting one, and the two buttons have to be told apart at a glance -- one glyph doing both jobs
* while looking identical would promise something immediate and do something that waits.
*/
val QUEUE_GLYPH = glyph(0xF1163)
/** `md-arrow_left` -- back one level, to whatever this was opened from. */
val BACK_GLYPH = glyph(0xF004D)
@@ -30,7 +30,6 @@ import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -1056,10 +1055,15 @@ fun SessionScreen(
)
}
if (running) {
OutlinedButton(onClick = { act { interruptSession(settings, summary.id) } }) {
// The same filled shape as the button beside it, not an outlined one: these
// are two things you can do about the turn that is running, and weighting one
// of them as secondary said they were a primary action and its qualifier.
// What separates them is the colour and the mark, which is what they mean.
Button(
onClick = { act { interruptSession(settings, summary.id) } },
colors = actionButtonColors(stopColor),
) {
// A filled square, which is what stop has looked like since tape decks.
// Outlined beside the filled Send, so the pair still reads as one primary
// action and one secondary -- the glyphs changed, the weighting did not.
Glyph(
STOP_GLYPH,
colour = LocalContentColor.current,
@@ -1068,22 +1072,19 @@ fun SessionScreen(
}
Spacer(Modifier.width(8.dp))
}
// The paper plane alone when it means send. While a turn is in flight it keeps
// the word "Queue" beside it, because that is what sending then does -- the
// message is injected at the next tool boundary rather than starting a turn of
// its own -- and an icon that does two things while looking identical would
// promise something immediate and do something that waits. The word is also the
// button's accessible name, which is all a screen reader gets either way.
Button(onClick = { send() }) {
// The paper plane, with a clock on it while a turn is in flight: sending then
// queues the message for the next tool boundary rather than starting a turn of
// its own, and the two have to be told apart at a glance. The label says the same
// thing to a screen reader, which has nothing else to read.
Button(
onClick = { send() },
colors = actionButtonColors(if (running) queueColor else sendColor),
) {
Glyph(
SEND_GLYPH,
if (running) QUEUE_GLYPH else SEND_GLYPH,
colour = LocalContentColor.current,
modifier = Modifier.semantics { contentDescription = sendLabel(running) },
)
if (running) {
Spacer(Modifier.width(8.dp))
Text(sendLabel(running))
}
}
}
}
@@ -1,5 +1,7 @@
package com.example.aiapp
import androidx.compose.material3.ButtonColors
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
@@ -206,3 +208,35 @@ val linkColor: Color
/** Past a limit. The scheme's error colour, for the reason [failedColor] gives. */
val overLimitColor: Color
@Composable get() = MaterialTheme.colorScheme.error
/**
* The composer's three buttons, coloured by what pressing one does rather than by where it sits.
*
* Green sends now, blue sends later, red takes the running turn away. The pair of greens and the
* pair of reds elsewhere in this file are deliberate near-collisions worth naming: [runningColor]
* is green because a session is working, and [failedColor] is red because one fell over -- those
* are *states*, and these are *actions*. A reader never has to tell them apart, because nothing
* here is a state and nothing there is pressable.
*/
val sendColor: Color
@Composable get() = Mocha.Green
/** Sending while a turn runs: the message waits rather than starting one. See [sendColor]. */
val queueColor: Color
@Composable get() = Mocha.Blue
/** Interrupting the running turn -- the one button here that takes something away. */
val stopColor: Color
@Composable get() = Mocha.Red
/**
* A filled button in one of the action colours above.
*
* The content colour is stated here beside the fill rather than inherited. A semantic colour has to
* carry its own contrast: these fills are fixed whatever the surface under them does, so the theme
* will not change to rescue a foreground that stops being readable on one of them. Crust is what
* every accent on this palette takes, which is the same reason `onPrimary` is Crust above.
*/
@Composable
fun actionButtonColors(fill: Color): ButtonColors =
ButtonDefaults.buttonColors(containerColor = fill, contentColor = Mocha.Crust)
@@ -8,11 +8,10 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -25,6 +24,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import java.time.OffsetDateTime
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -58,29 +58,48 @@ fun UsageDialog(settings: ServerSettings, onDismiss: () -> Unit) {
}
LaunchedEffect(Unit) { refresh() }
AlertDialog(
onDismissRequest = onDismiss,
title = {
// A plain Dialog rather than an AlertDialog, for the spacing alone. AlertDialog fixes the
// gaps between its title, its content and its buttons at sizes meant for a sentence of prose
// and a decision; this is a dense read-out, and those gaps left a band of empty dialog above
// Close that was taller than a bar. Everything else here is what AlertDialog would have
// drawn -- the same container colour, the same corner -- so nothing about it looks foreign.
Dialog(onDismissRequest = onDismiss) {
Surface(
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Column(Modifier.padding(horizontal = 24.dp, vertical = 16.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
// Deliberately not subtitled with the provider this was opened from. These
// numbers belong to an account on a particular machine, reported by whichever
// paid service answered there -- naming the session's provider here made an echo
// session's screen read "echo" above a card reading "claude", which is a claim
// about echo that nothing measured. Each card names the machine and the service
// it came from, which is the true scope.
Text("Usage", modifier = Modifier.weight(1f))
// paid service answered there -- naming the session's provider here made an
// echo session's screen read "echo" above a line reading "claude", which is a
// claim about echo that nothing measured. Each machine names itself and the
// service it came from, which is the true scope.
Text(
"Usage",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f),
)
GlyphButton(REFRESH_GLYPH, "Refresh usage", { refresh() })
}
},
// Scrolls here rather than being trimmed: a machine can report any number of windows and
// there can be any number of machines, and a dialog is the one place where running out of
// room is silent.
text = { Column(Modifier.verticalScroll(rememberScrollState())) { UsageBody(state) } },
confirmButton = { TextButton(onClick = onDismiss) { Text("Close") } },
)
Spacer(Modifier.height(8.dp))
// Scrolls rather than being trimmed: a machine can report any number of windows
// and there can be any number of machines, and a dialog is the one place where
// running out of room is silent. `fill = false` so a short read-out keeps a short
// dialog instead of stretching to the window.
Column(Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState())) {
UsageBody(state)
}
TextButton(onClick = onDismiss, modifier = Modifier.align(Alignment.End)) {
Text("Close")
}
}
}
}
}
/** What came back, or why nothing did. Split out so the dialog above reads as its own shape. */
@@ -100,31 +119,35 @@ private fun UsageBody(state: LoadState<List<UsageSnapshot>>) {
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
current.value.forEach { snapshot ->
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
// The machine first: these are one account's numbers, and
// which account is decided by which machine ran the session.
// No card around each machine. A card is a step up the surface ladder, and
// inside a dialog -- itself a raised surface -- the step barely renders while
// costing 16dp of padding on every side. What separates one machine from the
// next is the line naming it, which is enough for a list this short.
current.value.forEachIndexed { index, snapshot ->
if (index > 0) {
Spacer(Modifier.height(20.dp))
}
// Machine and service on one line: which account these numbers belong to
// is decided by both together, and stacked as a heading over a subtitle
// they read as a section of their own rather than as the label they are.
// Small and quiet, because the numbers below are what somebody opened
// this to see.
Text(
snapshot.setupName.ifEmpty { snapshot.setup },
style = MaterialTheme.typography.titleMedium,
)
Text(
snapshot.provider,
"${snapshot.setupName.ifEmpty { snapshot.setup }} · ${snapshot.provider}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
SnapshotState(snapshot)
snapshot.windows.forEach { window ->
snapshot.windows.forEachIndexed { windowIndex, window ->
// Between the bars, not after the last one: a trailing gap here is
// what put a band of empty dialog above the Close button.
if (windowIndex > 0) {
Spacer(Modifier.height(12.dp))
}
WindowBar(window)
Spacer(Modifier.height(12.dp))
}
}
}
Spacer(Modifier.height(12.dp))
}
}
}
}
}
Binary file not shown.
+1
View File
@@ -32,6 +32,7 @@ GLYPHS=(
U+F0450 # md-refresh
U+F048A # md-send
U+F04DB # md-stop
U+F1163 # md-send_clock
U+F004D # md-arrow_left
U+F201 # fa-line_chart -- Font Awesome's, asked for by name
)