Settings as a screen with two tabs, and fields that cost one line

The settings dialog had outgrown a dialog: it scrolled inside itself,
covered the session it is about, and had nowhere to put a second tab. It
is a screen now, drawn over the session like the file explorer so the
session under it stays composed, with the back gesture to leave it. The
second tab is ProviderScreen itself -- the same composable the machines
tab opens -- so a provider's settings have two ways in and one
implementation.

Every text field in the app goes through LabelledField: the label is a
line above the box rather than a thing floating inside it, the hint says
what leaving it blank means, and the padding is one line's worth.
Material's outlined field spends the height of three lines to hold one,
which on a form of a dozen settings is a screen and a half of scrolling.
The value's own text is unchanged -- the framing was what cost.

A session also gets a system prompt, which for llama.cpp is one entry in
the params table and no app change: it rides in front of the conversation
on every request rather than being recorded as the first thing in it, so
changing it takes effect on the next message. ParamKind::Prose is new
because a paragraph in a one-line box shows six words of itself.
This commit is contained in:
iris-ai committed 2026-09-21 03:35:54 -04:00
1 parent 386c1c4def
commit 7278a58387
17 files changed
+364 -176

No files matched your search

@@ -0,0 +1,126 @@
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, and it goes above the box with the label for the same
* reason the label does: inside, it is gone the moment anybody types, which is exactly when a
* reader looks back to check what they are overriding.
*/
@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()) {
Text(
label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 2.dp),
)
hint?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 2.dp),
)
}
FieldBox(value, onValueChange, enabled, lines, keyboardOptions, keyboardActions)
}
}
/** 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,
) {
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 { field() } },
)
}