diff --git a/AGENTS.md b/AGENTS.md index 69a0624..8919e9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -425,6 +425,19 @@ mutable at runtime from the phone. build happen under one lock for the same reason `claim` is synchronous -- a phone polling in the gap would see a project that is neither pulling nor building and call the run finished. +- **Text a command produced is selectable; text this app wrote is not.** + `Theme.kt`'s `OutputText` is the whole of it, and every failure message + goes through it -- a component's build or download, a service action, a + checkout's remote check, and the log dialog's own failure line -- plus a + `SelectionContainer` around the log body, which cannot use `OutputText` + because it is an `AnnotatedString` the ANSI renderer coloured inside its + own scrolling panel. The reason is that this is the one text on screen a + person has to take somewhere else, and the machine that produced it is + not the machine in their hand. A status word or a button label stays + unselectable on purpose: selection handles on those are noise, and a + card that starts a selection on long-press fights the gestures it + already has. Iris asked for exactly that line on 2026-09-01: "not the + 'failed' but the command output for build errors and stuff". - **A finished component shows nothing, and its button goes back to normal.** Iris's call, 2026-09-01: "you shouldn't see the time it took once it finishes, it should just go back to its normal enabled button diff --git a/app/androidApp/src/main/kotlin/com/example/devupdater/ComponentLogDialog.kt b/app/androidApp/src/main/kotlin/com/example/devupdater/ComponentLogDialog.kt index c521461..70763d3 100644 --- a/app/androidApp/src/main/kotlin/com/example/devupdater/ComponentLogDialog.kt +++ b/app/androidApp/src/main/kotlin/com/example/devupdater/ComponentLogDialog.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme @@ -225,7 +226,7 @@ fun ComponentLogDialog( Spacer(Modifier.height(8.dp)) } failure?.let { - Text(it, color = MaterialTheme.colorScheme.error) + OutputText(it) Spacer(Modifier.height(8.dp)) } // Not an error, and not drawn like one: there is simply no @@ -261,36 +262,44 @@ fun ComponentLogDialog( overflow = TextOverflow.StartEllipsis, ) Spacer(Modifier.height(4.dp)) - Text( - rendered.takeIf { loaded.text.isNotEmpty() } - ?: AnnotatedString("(nothing in this log yet)"), - style = MaterialTheme.typography.bodySmall, - fontFamily = FontFamily.Monospace, - // Both directions: a log wraps badly and a stack - // trace is wide, so it scrolls rather than being - // reflowed into something harder to read. - // - // The panel is painted outside the two scrolls, so - // it is the window rather than the content: filled - // inside the scroll it would be the width of the - // longest line and slide away as the log was - // scrolled, leaving the dark behind the text - // rather than behind the area. `fillMaxWidth` for - // the same reason -- a short log would otherwise - // give a panel the width of its longest line, and - // the block would change shape as the reader - // paged through it. - modifier = - Modifier.fillMaxWidth() - .heightIn(max = LOG_HEIGHT) - .background( - MaterialTheme.colorScheme.surfaceContainerLowest, - LOG_PANEL_SHAPE, - ) - .padding(LOG_PANEL_PADDING) - .verticalScroll(rememberScrollState()) - .horizontalScroll(rememberScrollState()), - ) + // Selectable for the reason [OutputText] gives, and + // not through it: this one is an AnnotatedString the + // ANSI renderer coloured, inside its own scrolling + // panel. Copy below still takes the whole log, which is + // the other half of the same need -- one line to paste + // into a search, or all of it to send to somebody. + SelectionContainer { + Text( + rendered.takeIf { loaded.text.isNotEmpty() } + ?: AnnotatedString("(nothing in this log yet)"), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + // Both directions: a log wraps badly and a stack + // trace is wide, so it scrolls rather than being + // reflowed into something harder to read. + // + // The panel is painted outside the two scrolls, so + // it is the window rather than the content: filled + // inside the scroll it would be the width of the + // longest line and slide away as the log was + // scrolled, leaving the dark behind the text + // rather than behind the area. `fillMaxWidth` for + // the same reason -- a short log would otherwise + // give a panel the width of its longest line, and + // the block would change shape as the reader + // paged through it. + modifier = + Modifier.fillMaxWidth() + .heightIn(max = LOG_HEIGHT) + .background( + MaterialTheme.colorScheme.surfaceContainerLowest, + LOG_PANEL_SHAPE, + ) + .padding(LOG_PANEL_PADDING) + .verticalScroll(rememberScrollState()) + .horizontalScroll(rememberScrollState()), + ) + } } } }, diff --git a/app/androidApp/src/main/kotlin/com/example/devupdater/Theme.kt b/app/androidApp/src/main/kotlin/com/example/devupdater/Theme.kt index ab5dce4..183a917 100644 --- a/app/androidApp/src/main/kotlin/com/example/devupdater/Theme.kt +++ b/app/androidApp/src/main/kotlin/com/example/devupdater/Theme.kt @@ -1,14 +1,18 @@ package com.example.devupdater import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.ButtonColors import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle /** * Catppuccin Mocha, as published in `catppuccin/palette`. @@ -179,6 +183,32 @@ val AnsiColors: List = Mocha.Text, // bright white ) +/** + * Text that came from somewhere else — a compiler's error, git's stderr, a service script's + * complaint — drawn so it can be selected and copied. + * + * This is the one text on the screen a person actually needs to take elsewhere, to search for or to + * paste into whatever they are fixing it with, and the machine that produced it is not the machine + * in their hand. So it is the one text that is selectable. + * + * This app's own words deliberately are not. Selection handles on a status word or a button label + * are noise, and making the whole card long-pressable would fight the gestures it already has — so + * the split is by where the words came from, not by how important they look. + * + * One composable because a failure surfaces in four places — a component's build or download, a + * service action, and a checkout's remote check — and they are exactly the places nobody looks at + * again. [style] defaults to whatever the caller's context already had, so wrapping an existing + * `Text` in this changes nothing but the selectability. + */ +@Composable +fun OutputText( + text: String, + color: Color = MaterialTheme.colorScheme.error, + style: TextStyle = LocalTextStyle.current, +) { + SelectionContainer { Text(text, style = style, color = color) } +} + /** * The progress bar, everywhere this app draws one. * diff --git a/app/androidApp/src/main/kotlin/com/example/devupdater/UpdaterScreen.kt b/app/androidApp/src/main/kotlin/com/example/devupdater/UpdaterScreen.kt index 5752a13..4631946 100644 --- a/app/androidApp/src/main/kotlin/com/example/devupdater/UpdaterScreen.kt +++ b/app/androidApp/src/main/kotlin/com/example/devupdater/UpdaterScreen.kt @@ -1704,8 +1704,9 @@ private fun AppCard( // first time is both at once, and the failure is the more // useful of the two. when { - projectState is ProjectState.Error -> - Text(projectState.message, color = MaterialTheme.colorScheme.error) + // Git's own words, most of the time. Selectable, like every + // other message here that this app did not write. + projectState is ProjectState.Error -> OutputText(projectState.message) !entry.built && !awaitingApproval -> Text( @@ -1730,11 +1731,7 @@ private fun AppCard( // pressed a button is the one they are waiting to read. if (projectState !is ProjectState.Error) { entry.checkError?.let { reason -> - Text( - reason, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) + OutputText(reason, style = MaterialTheme.typography.bodySmall) } } } @@ -2271,12 +2268,10 @@ private fun ComponentCard( } } + // The service script's own words about why it could not answer, + // so selectable for the same reason. component.error?.let { reason -> - Text( - reason, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) + OutputText(reason, style = MaterialTheme.typography.bodySmall) } // The caller's content and the service's own never appear @@ -2365,12 +2360,12 @@ private fun ComponentCard( // whether that was its build, its download, or a service // action. In its own row rather than at the foot of the card, // which could only ever have been the project's. + // + // Selectable, because this is the build's own output: the tail + // of what the compiler said, which is the thing somebody + // actually needs to copy somewhere. (state as? ComponentState.Error)?.let { - Text( - it.message, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) + OutputText(it.message, style = MaterialTheme.typography.bodySmall) } // Only while the old app is actually still there. The build