Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions compose/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ kotlin {
iosMain.dependencies {
implementation(libs.ktor.client.darwin)
}
// composition-level tests run on the simulator; the Android host tests have no window
iosTest.dependencies {
@OptIn(org.jetbrains.compose.ExperimentalComposeLibrary::class)
implementation(compose.uiTest)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
Expand Down
14 changes: 9 additions & 5 deletions compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,9 @@ private fun boxAlignment(name: String?): Alignment = when (name) {
*/
@Composable
private fun TextFieldComponent(scope: NodeScope) {
val maxLength = scope.get<Double>("maxLength")?.toInt()?.takeIf { it > 0 }
fun clip(t: String) = clipTo(t, maxLength)
// read when used, not captured: a command runs before the recomposition that would refresh a captured value
fun clip(t: String) = clipTo(t, scope.get<Double>("maxLength")?.toInt()?.takeIf { it > 0 })
val maxLength = scope.get<Double>("maxLength")
var text by retain { mutableStateOf(clip(scope.get<String>("initialText") ?: "")) }
var focused by remember { mutableStateOf(false) }
val focusRequester = remember { FocusRequester() }
Expand Down Expand Up @@ -330,19 +331,22 @@ private fun TextFieldComponent(scope: NodeScope) {
*/
@Composable
private fun CodeFieldComponent(scope: NodeScope) {
val length = (scope.get<Double>("length")?.toInt() ?: 6).coerceIn(1, MAX_CODE_LENGTH)
fun length() = (scope.get<Double>("length")?.toInt() ?: 6).coerceIn(1, MAX_CODE_LENGTH)
val length = length()
var value by retain { mutableStateOf(TextFieldValue("")) }
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
var focused by remember { mutableStateOf(false) }
// [force]: commands report onChange even when nothing changed, like TextField.setText
fun update(raw: String, force: Boolean = false) {
val t = raw.filter { it in '0'..'9' }.take(length)
// the live prop, like TextField.clip: a command may run before this composition saw a new length
val max = length()
val t = raw.filter { it in '0'..'9' }.take(max)
val changed = t != value.text
value = TextFieldValue(t, selection = TextRange(t.length))
if (!changed && !force) return
if (scope.has("onChange")) scope.dispatch("onChange", payload("text", t))
if (changed && t.length == length && scope.has("onComplete")) scope.dispatch("onComplete", payload("text", t))
if (changed && t.length == max && scope.has("onComplete")) scope.dispatch("onComplete", payload("text", t))
}
// fewer cells drop the digits that no longer fit at once, rather than keeping them hidden until the next edit
LaunchedEffect(length) { if (value.text.length > length) update(value.text) }
Expand Down
6 changes: 5 additions & 1 deletion compose/src/commonMain/kotlin/app/tinyui/schema/NodeScope.kt
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@ interface NodeScope {
fun RenderChild(child: UINode)
}

/** Consumes this node's commands as they arrive, oldest first; one-shot, after composition (docs/adr-004 §3.2). */
/**
* Consumes this node's commands as they arrive, oldest first; one-shot, after composition (docs/adr-004 §3.2).
* A command arrives once its flush is applied, but before the recomposition that flush causes: the handler must read
* the props it depends on through [NodeScope.get] when it runs, not use values captured during composition.
*/
@Composable
fun NodeScope.Commands(handler: suspend (Command) -> Unit) {
val node = node
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package app.tinyui.components

import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runComposeUiTest
import app.tinyui.node.NodeTree
import app.tinyui.node.PatchProblem
import app.tinyui.node.UINode
import app.tinyui.schema.ComponentRegistry
import app.tinyui.schema.NodeScope
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

/** A command arrives after its flush is applied but before the recomposition it causes (docs/components.md §4). */
@OptIn(ExperimentalTestApi::class)
class CommandPropsTest {
private val problems = ArrayList<PatchProblem>()
private val registry = ComponentRegistry().registerBuiltins()
private val tree = NodeTree(registry) { problems += it }
private val events = ArrayList<String>()

/** The page's scope without a page: props and events from the node, dispatch recorded. */
private fun scopeOf(node: UINode) = object : NodeScope {
override val node = node
@Suppress("UNCHECKED_CAST")
override fun <T> get(key: String): T? = (node.props[key] ?: registry.schema(node.type)?.prop(key)?.default) as T?
override fun has(event: String) = node.events[event] == true
override fun dispatch(event: String, payload: String) { events += "$event $payload" }
@Composable override fun modifier(clickable: Boolean) = Modifier
@Composable override fun Children() {}
@Composable override fun RenderChild(child: UINode) {}
}

@Test
fun textFieldSetTextClipsToTheLimitOfItsOwnFlush() = runComposeUiTest {
tree.apply("""[["c",1,"TextField"],["p",1,"maxLength",4],["p",1,"onChange",true],["i",0,1,0]]""")
val scope = scopeOf(tree.node(1)!!)
setContent { MaterialTheme { registry.Render("TextField", scope) } }
waitForIdle()
// one flush raises the limit and sets text longer than the old one
tree.apply("""[["p",1,"maxLength",10],["x",1,"setText",{"text":"123456"}]]""")
waitForIdle()
assertTrue(problems.isEmpty(), problems.toString())
assertEquals(1, events.size, events.toString())
assertTrue("123456" in events.single(), "clipped with the limit of an earlier composition: ${events.single()}")
}

@Test
fun codeFieldSetTextTakesTheLengthOfItsOwnFlush() = runComposeUiTest {
tree.apply("""[["c",1,"CodeField"],["p",1,"length",4],["p",1,"onChange",true],["i",0,1,0]]""")
val scope = scopeOf(tree.node(1)!!)
setContent { MaterialTheme { registry.Render("CodeField", scope) } }
waitForIdle()
tree.apply("""[["p",1,"length",6],["x",1,"setText",{"text":"123456"}]]""")
waitForIdle()
assertTrue(problems.isEmpty(), problems.toString())
assertEquals(1, events.size, events.toString())
assertTrue("123456" in events.single(), "took the length of an earlier composition: ${events.single()}")
}
}
2 changes: 2 additions & 0 deletions docs/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ const [open, setOpen] = signal(false);

命令进节点的 `commands` 队列(不是 props),组件用 `scope.Commands { command -> … }` 在组合后按到达顺序消费,一次性。`LazyColumn` 的 `scrollTo` 与新增行同一 flush 到达时,行先进 children,命令随后执行,index 已经有效。

**处理器要在执行时读 props**:命令在它那批 flush 提交后送达,但早于这批引起的重组。处理器若用组合期捕获的值(`val maxLength = scope.get(...)` 再闭包进去),同一批里改 prop 又发命令时就用了旧值。`TextField.setText` 裁剪、`CodeField.setText` 取位数都在处理器里现读 `scope.get`,自定义组件照此办。

## 5. `Placeholder`

未注册的类型渲染 `Placeholder`,同时 E5 上报。debug 构建(Android `FLAG_DEBUGGABLE`、iOS debug binary)画红框加类型名与 id;release 渲染零尺寸的空 `Box`。
Expand Down
Loading