A field's hint had a grey line of its own between the label and the box, so a
form of a dozen settings was mostly explanation. It is the empty box's
placeholder now, where it costs no height and says the same thing to anybody
who has not typed yet.
The wait's bar was drawn in whatever width the status row had left beside its
words, which is where the context figure goes -- so a session loading a model
or reading a prompt stopped saying how full it was, at the moment somebody is
watching it fill. The bar is the whole width above the row instead, the
compaction's indeterminate one with it, and the percentage joins the status
word ("reading prompt 30%") rather than replacing the context. The row keeps
the bar's height when there is no bar: it sits above the transcript and the
box, and one that came and went would move both under the reader's thumb
every time a turn started.
Checked on the emulator against the sandbox's progress rig: /loading and
/reading both draw a full-width bar with "context unknown" still beside them,
and the settings form has no grey lines left between a label and its box.
ktfmt, compile, lint and the unit tests are clean.
128 lines
5.5 KiB
Kotlin
128 lines
5.5 KiB
Kotlin
package com.example.aiapp
|
|
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.border
|
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
import androidx.compose.foundation.text.BasicTextField
|
|
import androidx.compose.foundation.text.KeyboardActions
|
|
import androidx.compose.foundation.text.KeyboardOptions
|
|
import androidx.compose.material3.LocalTextStyle
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.graphics.SolidColor
|
|
import androidx.compose.ui.text.TextStyle
|
|
import androidx.compose.ui.unit.dp
|
|
|
|
/**
|
|
* A text field whose label is a line above it rather than a thing floating inside it.
|
|
*
|
|
* Every field in this app goes through here, and the reason is vertical space. Material's outlined
|
|
* field reserves room for a label that animates into its own border and pads the value by half a
|
|
* line top and bottom, so one setting costs the height of three lines of text to hold one. A form
|
|
* of ten settings is then a screen and a half of scrolling to read ten short answers.
|
|
*
|
|
* What is *not* shrunk is the value itself: it stays at body size, because what is expensive here
|
|
* is the framing rather than the text, and a field whose contents are smaller than the text beside
|
|
* it is a field the reader has to lean in to check. See UI_RULES on never shrinking text to fit.
|
|
*
|
|
* [hint] is what leaving it blank means, drawn inside the empty box. It had a grey line of its own
|
|
* above the box until 2026-09-21: a form of a dozen settings was then mostly explanation, and a
|
|
* setting should be a title and a box to type in. Inside, it costs no height and is gone the moment
|
|
* anybody types -- which is the trade, since that is also when somebody might look back at it.
|
|
*/
|
|
@Composable
|
|
fun LabelledField(
|
|
label: String,
|
|
value: String,
|
|
onValueChange: (String) -> Unit,
|
|
modifier: Modifier = Modifier,
|
|
hint: String? = null,
|
|
enabled: Boolean = true,
|
|
/**
|
|
* How many lines the box is, at rest. One for a value; several for prose, where the reader is
|
|
* writing rather than filling in -- see `ParamKind::Prose`.
|
|
*/
|
|
lines: Int = 1,
|
|
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
|
/** What the keyboard's own action key does, which is usually what the button beside it does. */
|
|
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
|
) {
|
|
Column(modifier.fillMaxWidth()) {
|
|
// Body size in the ordinary text colour, which is what a setting's label is where the
|
|
// control beside it is a switch or a picker. Smaller and greyer on the ones that are
|
|
// fields reads as two ranks of setting where there is one.
|
|
Text(label, modifier = Modifier.padding(bottom = 2.dp))
|
|
FieldBox(value, onValueChange, enabled, lines, keyboardOptions, keyboardActions, hint)
|
|
}
|
|
}
|
|
|
|
/** The box itself: the border, the padding, and the text. Shared so the two fields agree. */
|
|
@Composable
|
|
private fun FieldBox(
|
|
value: String,
|
|
onValueChange: (String) -> Unit,
|
|
enabled: Boolean,
|
|
lines: Int,
|
|
keyboardOptions: KeyboardOptions,
|
|
keyboardActions: KeyboardActions,
|
|
hint: String?,
|
|
) {
|
|
val interactions = remember { MutableInteractionSource() }
|
|
val focused by interactions.collectIsFocusedAsState()
|
|
// The focused border is the accent at the same width as the resting one. Growing it instead
|
|
// would move the text inside by a pixel on every focus, which is a whole form twitching as the
|
|
// reader moves down it.
|
|
val edge =
|
|
when {
|
|
!enabled -> MaterialTheme.colorScheme.outlineVariant
|
|
focused -> MaterialTheme.colorScheme.primary
|
|
else -> MaterialTheme.colorScheme.outline
|
|
}
|
|
val shape = RoundedCornerShape(8.dp)
|
|
val style =
|
|
LocalTextStyle.current.merge(
|
|
TextStyle(
|
|
color =
|
|
if (enabled) MaterialTheme.colorScheme.onSurface
|
|
else MaterialTheme.colorScheme.onSurfaceVariant
|
|
)
|
|
)
|
|
BasicTextField(
|
|
value = value,
|
|
onValueChange = onValueChange,
|
|
enabled = enabled,
|
|
singleLine = lines == 1,
|
|
minLines = lines,
|
|
textStyle = style,
|
|
keyboardOptions = keyboardOptions,
|
|
keyboardActions = keyboardActions,
|
|
interactionSource = interactions,
|
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
|
modifier =
|
|
Modifier.fillMaxWidth()
|
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest, shape)
|
|
.border(1.dp, edge, shape)
|
|
.padding(horizontal = 10.dp, vertical = 8.dp),
|
|
decorationBox = { field ->
|
|
Box {
|
|
// Under the text rather than beside it: the value is what the box is for, and a
|
|
// hint that pushed it sideways would move every character as somebody typed.
|
|
if (value.isEmpty() && hint != null) {
|
|
Text(hint, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
}
|
|
field()
|
|
}
|
|
},
|
|
)
|
|
}
|