fix(desktop): BasicTextField+DecorationBox for compact Search/Chat inputs

M3 OutlinedTextField has ~32dp of vertical contentPadding baked in,
so forcing .height(40-44dp) clipped the bodyMedium (14sp, 20dp line-
height) placeholder vertically.

Refactored both Search and Chat inputs to BasicTextField wrapped in
OutlinedTextFieldDefaults.DecorationBox, which lets us override
contentPadding to (12.dp horizontal, 8.dp vertical). The field now
renders at a true 40dp tall with the placeholder centered and no
clipping. Border, focus states, placeholder, and leading/trailing
icons still come from M3 so the look stays consistent.

https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
This commit is contained in:
Claude
2026-04-24 16:02:00 +00:00
parent 8ae4ce6f91
commit bbe84c2bb0
2 changed files with 110 additions and 47 deletions
@@ -49,7 +49,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -365,47 +364,80 @@ fun SearchScreen(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
// Compact desktop search field. M3's OutlinedTextField has a hard
// ~32dp vertical contentPadding baked in, so forcing .height(40dp)
// clipped the placeholder. BasicTextField + DecorationBox lets us
// override contentPadding to 8dp vertical so the field can sit at
// a true 40dp tall without clipping the bodyMedium line-height.
val searchInteraction =
remember {
androidx.compose.foundation.interaction
.MutableInteractionSource()
}
androidx.compose.foundation.text.BasicTextField(
value = textFieldValue,
onValueChange = {
textFieldValue = it
state.updateFromText(it.text)
},
// .height(44.dp) overrides M3's 56dp min-height (intended for
// mobile touch) — desktop inputs should feel closer to Slack /
// Raycast / VS Code. 44dp (not 40) keeps the bodyMedium
// placeholder from clipping vertically inside M3's built-in
// content padding.
modifier = Modifier.weight(1f).height(44.dp).focusRequester(focusRequester),
textStyle = MaterialTheme.typography.bodyMedium,
placeholder = {
Text(
"Search people, tags, notes…",
style = MaterialTheme.typography.bodyMedium,
)
},
leadingIcon = {
Icon(
MaterialSymbols.Search,
contentDescription = "Search",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
},
trailingIcon = {
if (displayText.isNotEmpty()) {
IconButton(onClick = { state.clearSearch() }, modifier = Modifier.size(28.dp)) {
modifier = Modifier.weight(1f).height(40.dp).focusRequester(focusRequester),
textStyle =
MaterialTheme.typography.bodyMedium
.copy(color = MaterialTheme.colorScheme.onSurface),
cursorBrush =
androidx.compose.ui.graphics
.SolidColor(MaterialTheme.colorScheme.primary),
singleLine = true,
interactionSource = searchInteraction,
decorationBox = { innerTextField ->
androidx.compose.material3.OutlinedTextFieldDefaults.DecorationBox(
value = textFieldValue.text,
innerTextField = innerTextField,
enabled = true,
singleLine = true,
visualTransformation = androidx.compose.ui.text.input.VisualTransformation.None,
interactionSource = searchInteraction,
placeholder = {
Text(
"Search people, tags, notes…",
style = MaterialTheme.typography.bodyMedium,
)
},
leadingIcon = {
Icon(
MaterialSymbols.Clear,
contentDescription = "Clear",
MaterialSymbols.Search,
contentDescription = "Search",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
}
}
},
trailingIcon = {
if (displayText.isNotEmpty()) {
IconButton(onClick = { state.clearSearch() }, modifier = Modifier.size(28.dp)) {
Icon(
MaterialSymbols.Clear,
contentDescription = "Clear",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
}
}
},
contentPadding =
androidx.compose.foundation.layout.PaddingValues(
horizontal = 12.dp,
vertical = 8.dp,
),
container = {
androidx.compose.material3.OutlinedTextFieldDefaults.Container(
enabled = true,
isError = false,
interactionSource = searchInteraction,
shape = RoundedCornerShape(10.dp),
)
},
)
},
singleLine = true,
shape = RoundedCornerShape(10.dp),
)
if (account != null && !account.isReadOnly) {
IconButton(onClick = { showRelayPicker = true }) {
@@ -43,7 +43,6 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
@@ -718,17 +717,22 @@ private fun MessageInput(
)
}
OutlinedTextField(
// BasicTextField + DecorationBox with custom contentPadding lets the
// field sit at a compact ~40dp minimum on desktop (M3's default
// OutlinedTextField has ~32dp vertical contentPadding baked in, which
// would clip the bodyMedium placeholder at any height below ~52dp).
val messageInteraction =
remember {
androidx.compose.foundation.interaction
.MutableInteractionSource()
}
androidx.compose.foundation.text.BasicTextField(
value = messageText,
onValueChange = onMessageChange,
// heightIn(min = 44.dp) overrides M3's 56dp mobile-touch min so the
// chat input sits at a desktop-sensible ~44dp when empty; still grows
// to up to ~120dp with content via maxLines = 4. 44 (not 40) keeps the
// bodyMedium placeholder from clipping inside M3's internal padding.
modifier =
Modifier
.weight(1f)
.heightIn(min = 44.dp)
.heightIn(min = 40.dp)
.onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
// Cmd+Enter (Mac) or Ctrl+Enter to send
@@ -740,16 +744,43 @@ private fun MessageInput(
false
}
},
textStyle = MaterialTheme.typography.bodyMedium,
placeholder = {
Text(
"Message... (${if (isMacOS) "\u2318" else "Ctrl"}+Enter to send)",
style = MaterialTheme.typography.bodyMedium,
textStyle =
MaterialTheme.typography.bodyMedium
.copy(color = MaterialTheme.colorScheme.onSurface),
cursorBrush =
androidx.compose.ui.graphics
.SolidColor(MaterialTheme.colorScheme.primary),
maxLines = 4,
interactionSource = messageInteraction,
decorationBox = { innerTextField ->
androidx.compose.material3.OutlinedTextFieldDefaults.DecorationBox(
value = messageText,
innerTextField = innerTextField,
enabled = true,
singleLine = false,
visualTransformation = androidx.compose.ui.text.input.VisualTransformation.None,
interactionSource = messageInteraction,
placeholder = {
Text(
"Message\u2026 (${if (isMacOS) "\u2318" else "Ctrl"}+Enter to send)",
style = MaterialTheme.typography.bodyMedium,
)
},
contentPadding =
androidx.compose.foundation.layout.PaddingValues(
horizontal = 12.dp,
vertical = 8.dp,
),
container = {
androidx.compose.material3.OutlinedTextFieldDefaults.Container(
enabled = true,
isError = false,
interactionSource = messageInteraction,
shape = RoundedCornerShape(10.dp),
)
},
)
},
singleLine = false,
maxLines = 4,
shape = RoundedCornerShape(10.dp),
)
IconButton(