From e91b56a7e343d9ccdc3a6775c0efda684ac9555c Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sun, 1 Nov 2026 01:09:39 +0000 Subject: [PATCH 1/2] fix(android): keep single-line placeholders from inflating field height --- docs/api-migrations.md | 15 +++++ .../src/main/kotlin/dev/lui/LuiNodeView.kt | 4 +- .../dev/lui/LuiTextControlLayoutTest.kt | 63 +++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 platform/android/lui/src/test/kotlin/dev/lui/LuiTextControlLayoutTest.kt diff --git a/docs/api-migrations.md b/docs/api-migrations.md index 49d34a80..22710d7f 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: Android accepts wrapping preserved text Android's retained text-flow validator now accepts `white-space: "pre-wrap"`, 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..9133c2c9 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,9 @@ 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 = 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() + } + } + } +} From 872b4ac03c9bf5a50c7cc4ad84bac17eb79d99b0 Mon Sep 17 00:00:00 2001 From: Tienson Qin Date: Sun, 1 Nov 2026 01:11:49 +0000 Subject: [PATCH 2/2] fix(android): preserve multiline placeholder clipping policy --- platform/android/lui/src/main/kotlin/dev/lui/LuiNodeView.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 9133c2c9..3f671999 100644 --- a/platform/android/lui/src/main/kotlin/dev/lui/LuiNodeView.kt +++ b/platform/android/lui/src/main/kotlin/dev/lui/LuiNodeView.kt @@ -1280,7 +1280,11 @@ private fun LuiTextControl( } val placeholderContent: (@Composable () -> Unit)? = node.propString("placeholder")?.let { p -> { - Text(p, maxLines = if (multiline) Int.MAX_VALUE else 1, overflow = TextOverflow.Ellipsis) + 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()