diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt index 3c21f2d..6a0314c 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/NerdIcons.kt @@ -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) 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 5628b7f..c208810 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/SessionScreen.kt @@ -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)) - } } } } diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt index 0846b30..6253b21 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/Theme.kt @@ -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) diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt index 74daf3e..8d2fd4c 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/UsageDialog.kt @@ -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 = { - 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)) - GlyphButton(REFRESH_GLYPH, "Refresh usage", { refresh() }) + // 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 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() }) + } + 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") + } } - }, - // 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") } }, - ) + } + } } /** What came back, or why nothing did. Split out so the dialog above reads as its own shape. */ @@ -100,29 +119,33 @@ private fun UsageBody(state: LoadState>) { 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. - Text( - snapshot.setupName.ifEmpty { snapshot.setup }, - style = MaterialTheme.typography.titleMedium, - ) - Text( - snapshot.provider, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(8.dp)) - SnapshotState(snapshot) - snapshot.windows.forEach { window -> - WindowBar(window) - Spacer(Modifier.height(12.dp)) - } + // 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 }} ยท ${snapshot.provider}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + SnapshotState(snapshot) + 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)) } } } diff --git a/app/androidApp/src/main/res/font/nerd_icons.ttf b/app/androidApp/src/main/res/font/nerd_icons.ttf index c3708c7..7456825 100644 Binary files a/app/androidApp/src/main/res/font/nerd_icons.ttf and b/app/androidApp/src/main/res/font/nerd_icons.ttf differ diff --git a/app/build-icon-font.sh b/app/build-icon-font.sh index 964315f..2d9c18e 100755 --- a/app/build-icon-font.sh +++ b/app/build-icon-font.sh @@ -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 )