Output a command produced can be selected and copied
A build's error is the tail of what the compiler actually said, and on a phone there was no way to get it out: the machine that produced it is not the machine in your hand, so a message you cannot copy is one you have to retype into whatever you are fixing it with. Theme.kt's OutputText is the one place that decides this, and every failure message now 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. The log body gets a SelectionContainer directly rather than going through OutputText, since it is an AnnotatedString the ANSI renderer coloured inside its own scrolling panel; Copy stays beside it for taking the whole thing. The app's own words are deliberately left alone. Selection handles on a status word like "failed" are noise, and a card that starts a selection on long-press fights the gestures it already has -- so the split is by where the words came from, not by how important they look. Checked on the emulator against a failing build: long-pressing the error on the card raises handles and the Copy toolbar, the state word above it does not, and the log dialog still scrolls in both directions with selection enabled.
This commit is contained in:
1 parent
90082bd286
commit
a7f7f4550e
4 files changed
+65
-18
No files matched your search
@@ -425,6 +425,19 @@ mutable at runtime from the phone.
|
|||||||
build happen under one lock for the same reason `claim` is
|
build happen under one lock for the same reason `claim` is
|
||||||
synchronous -- a phone polling in the gap would see a project that is
|
synchronous -- a phone polling in the gap would see a project that is
|
||||||
neither pulling nor building and call the run finished.
|
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
|
- **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
|
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
|
once it finishes, it should just go back to its normal enabled button
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -225,7 +226,7 @@ fun ComponentLogDialog(
|
|||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
failure?.let {
|
failure?.let {
|
||||||
Text(it, color = MaterialTheme.colorScheme.error)
|
OutputText(it)
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
// Not an error, and not drawn like one: there is simply no
|
// Not an error, and not drawn like one: there is simply no
|
||||||
@@ -261,6 +262,13 @@ fun ComponentLogDialog(
|
|||||||
overflow = TextOverflow.StartEllipsis,
|
overflow = TextOverflow.StartEllipsis,
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
|
// 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(
|
Text(
|
||||||
rendered.takeIf { loaded.text.isNotEmpty() }
|
rendered.takeIf { loaded.text.isNotEmpty() }
|
||||||
?: AnnotatedString("(nothing in this log yet)"),
|
?: AnnotatedString("(nothing in this log yet)"),
|
||||||
@@ -293,6 +301,7 @@ fun ComponentLogDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
// The whole foot is one row rather than the dialog's confirm and
|
// The whole foot is one row rather than the dialog's confirm and
|
||||||
// dismiss slots. Given two slots, Material stacks them the moment
|
// dismiss slots. Given two slots, Material stacks them the moment
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
package com.example.devupdater
|
package com.example.devupdater
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.material3.ButtonColors
|
import androidx.compose.material3.ButtonColors
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.LocalTextStyle
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Catppuccin Mocha, as published in `catppuccin/palette`.
|
* Catppuccin Mocha, as published in `catppuccin/palette`.
|
||||||
@@ -179,6 +183,32 @@ val AnsiColors: List<Color> =
|
|||||||
Mocha.Text, // bright white
|
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.
|
* The progress bar, everywhere this app draws one.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1704,8 +1704,9 @@ private fun AppCard(
|
|||||||
// first time is both at once, and the failure is the more
|
// first time is both at once, and the failure is the more
|
||||||
// useful of the two.
|
// useful of the two.
|
||||||
when {
|
when {
|
||||||
projectState is ProjectState.Error ->
|
// Git's own words, most of the time. Selectable, like every
|
||||||
Text(projectState.message, color = MaterialTheme.colorScheme.error)
|
// other message here that this app did not write.
|
||||||
|
projectState is ProjectState.Error -> OutputText(projectState.message)
|
||||||
|
|
||||||
!entry.built && !awaitingApproval ->
|
!entry.built && !awaitingApproval ->
|
||||||
Text(
|
Text(
|
||||||
@@ -1730,11 +1731,7 @@ private fun AppCard(
|
|||||||
// pressed a button is the one they are waiting to read.
|
// pressed a button is the one they are waiting to read.
|
||||||
if (projectState !is ProjectState.Error) {
|
if (projectState !is ProjectState.Error) {
|
||||||
entry.checkError?.let { reason ->
|
entry.checkError?.let { reason ->
|
||||||
Text(
|
OutputText(reason, style = MaterialTheme.typography.bodySmall)
|
||||||
reason,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 ->
|
component.error?.let { reason ->
|
||||||
Text(
|
OutputText(reason, style = MaterialTheme.typography.bodySmall)
|
||||||
reason,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The caller's content and the service's own never appear
|
// 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
|
// whether that was its build, its download, or a service
|
||||||
// action. In its own row rather than at the foot of the card,
|
// action. In its own row rather than at the foot of the card,
|
||||||
// which could only ever have been the project's.
|
// 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 {
|
(state as? ComponentState.Error)?.let {
|
||||||
Text(
|
OutputText(it.message, style = MaterialTheme.typography.bodySmall)
|
||||||
it.message,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only while the old app is actually still there. The build
|
// Only while the old app is actually still there. The build
|
||||||
|
|||||||
Reference in new issue
Block a user