diff --git a/docs/api-migrations.md b/docs/api-migrations.md index 5e5a9a73..eeed9168 100644 --- a/docs/api-migrations.md +++ b/docs/api-migrations.md @@ -13,6 +13,21 @@ inventory check is temporarily deferred until that inventory recognizes that renderer. The MyGo, Web, Apple, Android and shared ABI regressions remain covered separately. +## Unreleased: Android single-line text placeholders + +Android placeholders now follow their control's line policy: `text-field`, +`secure-field`, `input`, `search-field` and `combobox` keep placeholders on one +line and ellipsize overflow. `textarea` placeholders continue to wrap. Previously +a narrow or zero-width field could wrap its placeholder character by character, +inflating a single-line control and its containing row to hundreds of dp even +though the editable text itself was single-line. + +The wire format, default control height and `grow` behavior are unchanged. For +example, keep sending `{"op":"set-prop","id":2,"property":"placeholder","value":"Search this book…"}`; +no fixed height or placeholder rewrite is necessary. Applications must still +provide usable width or responsive toolbar alternatives: this correction does +not make a field usable when other row children consume all available width. + ## Unreleased: Apple normalizes all floating wire properties Apple now normalizes integral JSON numbers to its floating retained value for diff --git a/platform/android/lui/src/main/kotlin/dev/lui/LuiNodeView.kt b/platform/android/lui/src/main/kotlin/dev/lui/LuiNodeView.kt index e3dc12a8..3f671999 100644 --- a/platform/android/lui/src/main/kotlin/dev/lui/LuiNodeView.kt +++ b/platform/android/lui/src/main/kotlin/dev/lui/LuiNodeView.kt @@ -1279,7 +1279,13 @@ private fun LuiTextControl( } } val placeholderContent: (@Composable () -> Unit)? = - node.propString("placeholder")?.let { p -> { Text(p) } } + node.propString("placeholder")?.let { p -> { + Text( + p, + maxLines = if (multiline) Int.MAX_VALUE else 1, + overflow = if (multiline) TextOverflow.Clip else TextOverflow.Ellipsis, + ) + } } val transformation = if (secure) { androidx.compose.ui.text.input.PasswordVisualTransformation() } else { diff --git a/platform/android/lui/src/test/kotlin/dev/lui/LuiTextControlLayoutTest.kt b/platform/android/lui/src/test/kotlin/dev/lui/LuiTextControlLayoutTest.kt new file mode 100644 index 00000000..3ca1cf71 --- /dev/null +++ b/platform/android/lui/src/test/kotlin/dev/lui/LuiTextControlLayoutTest.kt @@ -0,0 +1,63 @@ +package dev.lui + +import androidx.compose.foundation.layout.Box +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsOwner +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import app.cash.paparazzi.DeviceConfig +import app.cash.paparazzi.Paparazzi +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class LuiTextControlLayoutTest { + @get:Rule val paparazzi = Paparazzi(deviceConfig = DeviceConfig.PIXEL_5.copy(softButtons = false)) + + private fun nodes(root: SemanticsNode): List = + listOf(root) + root.children.flatMap(::nodes) + + @Test fun narrowPlaceholderRespectsTextControlLinePolicy() { + for (kind in listOf("text-field", "secure-field", "input", "search-field", "combobox", "textarea")) { + val backend = LuiBackend() + val ops = mutableListOf("""{"op":"create-node","id":1,"kind":"column"}""") + for (id in 2..3) { + ops.add("""{"op":"create-node","id":$id,"kind":"$kind"}""") + ops.add("""{"op":"set-prop","id":$id,"property":"width","value":80}""") + ops.add("""{"op":"insert-child","parent":1,"child":$id,"index":${id - 2}}""") + } + ops.add("""{"op":"set-prop","id":3,"property":"placeholder","value":"A long placeholder that cannot fit in one narrow line"}""") + assertTrue(backend.applyBatch("""{"generation":1,"ops":[${ops.joinToString(",")}]}""")) + var heights = emptyList() + val view = ComposeView(paparazzi.context).apply { setContent { + val native = LocalView.current + LuiTheme { + Box(Modifier.onGloballyPositioned { + val owner = native.javaClass.methods.first { it.name == "getSemanticsOwner" } + .invoke(native) as SemanticsOwner + heights = nodes(owner.unmergedRootSemanticsNode) + .filter { it.config.getOrNull(SemanticsProperties.EditableText) != null } + .map { it.size.height } + }) { backend.Content(1) } + } + } } + try { + paparazzi.snapshot(view, name = "narrow-$kind-placeholder") + assertEquals("both $kind controls were measured", 2, heights.size) + assertTrue("empty $kind has an intrinsic height", heights[0] > 0) + if (kind == "textarea") { + assertTrue("textarea placeholder still wraps: $heights", heights[1] > heights[0]) + } else { + assertEquals("single-line $kind placeholder must not inflate height", heights[0], heights[1]) + } + } finally { + view.disposeComposition() + } + } + } +}