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.IconButton
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -365,47 +364,80 @@ fun SearchScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), 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, value = textFieldValue,
onValueChange = { onValueChange = {
textFieldValue = it textFieldValue = it
state.updateFromText(it.text) state.updateFromText(it.text)
}, },
// .height(44.dp) overrides M3's 56dp min-height (intended for modifier = Modifier.weight(1f).height(40.dp).focusRequester(focusRequester),
// mobile touch) — desktop inputs should feel closer to Slack / textStyle =
// Raycast / VS Code. 44dp (not 40) keeps the bodyMedium MaterialTheme.typography.bodyMedium
// placeholder from clipping vertically inside M3's built-in .copy(color = MaterialTheme.colorScheme.onSurface),
// content padding. cursorBrush =
modifier = Modifier.weight(1f).height(44.dp).focusRequester(focusRequester), androidx.compose.ui.graphics
textStyle = MaterialTheme.typography.bodyMedium, .SolidColor(MaterialTheme.colorScheme.primary),
placeholder = { singleLine = true,
Text( interactionSource = searchInteraction,
"Search people, tags, notes…", decorationBox = { innerTextField ->
style = MaterialTheme.typography.bodyMedium, androidx.compose.material3.OutlinedTextFieldDefaults.DecorationBox(
) value = textFieldValue.text,
}, innerTextField = innerTextField,
leadingIcon = { enabled = true,
Icon( singleLine = true,
MaterialSymbols.Search, visualTransformation = androidx.compose.ui.text.input.VisualTransformation.None,
contentDescription = "Search", interactionSource = searchInteraction,
tint = MaterialTheme.colorScheme.onSurfaceVariant, placeholder = {
modifier = Modifier.size(18.dp), Text(
) "Search people, tags, notes…",
}, style = MaterialTheme.typography.bodyMedium,
trailingIcon = { )
if (displayText.isNotEmpty()) { },
IconButton(onClick = { state.clearSearch() }, modifier = Modifier.size(28.dp)) { leadingIcon = {
Icon( Icon(
MaterialSymbols.Clear, MaterialSymbols.Search,
contentDescription = "Clear", contentDescription = "Search",
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp), 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) { if (account != null && !account.isReadOnly) {
IconButton(onClick = { showRelayPicker = true }) { IconButton(onClick = { showRelayPicker = true }) {
@@ -43,7 +43,6 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface 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, value = messageText,
onValueChange = onMessageChange, 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 =
Modifier Modifier
.weight(1f) .weight(1f)
.heightIn(min = 44.dp) .heightIn(min = 40.dp)
.onPreviewKeyEvent { event -> .onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
// Cmd+Enter (Mac) or Ctrl+Enter to send // Cmd+Enter (Mac) or Ctrl+Enter to send
@@ -740,16 +744,43 @@ private fun MessageInput(
false false
} }
}, },
textStyle = MaterialTheme.typography.bodyMedium, textStyle =
placeholder = { MaterialTheme.typography.bodyMedium
Text( .copy(color = MaterialTheme.colorScheme.onSurface),
"Message... (${if (isMacOS) "\u2318" else "Ctrl"}+Enter to send)", cursorBrush =
style = MaterialTheme.typography.bodyMedium, 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( IconButton(