From 89ab834093c63759730129c10bc2253bcb4596f2 Mon Sep 17 00:00:00 2001 From: HarlonWang <81813780@qq.com> Date: Wed, 16 Sep 2026 19:51:22 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20M2=20=E5=88=97=E8=A1=A8=E9=A1=B5?= =?UTF-8?q?=E2=80=94=E2=80=94schema=20=E7=94=9F=E6=88=90=E9=93=BE=E3=80=81?= =?UTF-8?q?=E5=85=AB=E4=B8=AA=E5=86=85=E7=BD=AE=E7=BB=84=E4=BB=B6=E3=80=81?= =?UTF-8?q?HostServices=20=E4=B8=8E=20@tiny-ui/native?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - schema/:TS DSL 是唯一真值,tinyui schema 生成 core 的 JSX 类型与 compose 的注册 schema,CI 校验一致 - compose:PropSpec 加 required / initial / size,公共布局 prop 按固定顺序合成 Modifier,命令参数校验与 Commands 消费助手, Column / Row / Box / Text / Button / TextField / LazyColumn / Spacer,Placeholder 分 debug / release; HostServices(Navigator / Store / HttpClient / I18n / Config / EventBus)接 J2 白名单、J3 http、J4 与 K5,K 入口 5 s 超时 - core:泛型 Ref,For / Show 保留泛型;native:navigation / store / events / http - sample:todos 页(For + LazyColumn + TextField + http 分页 + scrollTo),Android / iOS 模拟器验证 Co-Authored-By: Claude Fable 5.1 --- .github/workflows/build.yml | 1 + README.md | 5 +- compose/api/compose.klib.api | 303 ++++++++++++++++-- .../wang/harlon/tinyui/Platform.android.kt | 9 + .../tinyui/schema/Placeholder.android.kt | 20 ++ .../kotlin/wang/harlon/tinyui/HostServices.kt | 111 +++++++ .../kotlin/wang/harlon/tinyui/PageHost.kt | 147 +++++++-- .../kotlin/wang/harlon/tinyui/TinyUIPage.kt | 10 +- .../wang/harlon/tinyui/components/Builtins.kt | 178 +++++++++- .../components/generated/BuiltinSchemas.kt | 140 ++++++++ .../wang/harlon/tinyui/node/NodeTree.kt | 18 +- .../kotlin/wang/harlon/tinyui/node/UiNode.kt | 2 + .../harlon/tinyui/schema/ComponentRegistry.kt | 27 +- .../harlon/tinyui/schema/ComponentSchema.kt | 106 ++++-- .../wang/harlon/tinyui/schema/NodeScope.kt | 28 +- .../kotlin/wang/harlon/tinyui/PageHostTest.kt | 60 +++- .../wang/harlon/tinyui/node/NodeTreeTest.kt | 28 +- .../kotlin/wang/harlon/tinyui/Platform.ios.kt | 9 + .../harlon/tinyui/schema/Placeholder.ios.kt | 20 ++ docs/README.md | 4 +- docs/components.md | 75 +++++ docs/native-api.md | 80 +++++ docs/roadmap.md | 18 +- package.json | 8 +- packages/cli/package.json | 4 + packages/cli/src/bin.ts | 44 ++- packages/cli/src/schema/dsl.ts | 101 ++++++ packages/cli/src/schema/generate.ts | 104 ++++++ packages/cli/src/schema/index.ts | 2 + packages/cli/src/schema/load.ts | 28 ++ packages/core/src/components.ts | 7 +- packages/core/src/control.ts | 8 +- packages/core/src/generated/components.ts | 159 +++++++++ packages/core/src/index.ts | 3 +- packages/core/src/jsx-runtime.ts | 13 +- packages/core/src/node.ts | 20 +- packages/native/package.json | 10 +- packages/native/src/events.ts | 13 + packages/native/src/http.ts | 21 ++ packages/native/src/index.ts | 6 + packages/native/src/navigation.ts | 19 ++ packages/native/src/store.ts | 21 ++ packages/native/test/host-stub.ts | 13 + packages/native/test/native.test.ts | 51 +++ pnpm-lock.yaml | 16 +- pnpm-workspace.yaml | 1 + sample/js/src/pages/todos.tsx | 69 ++++ sample/shared/build.gradle.kts | 5 + .../kotlin/wang/harlon/tinyui/sample/App.kt | 31 +- schema/components/box.ts | 10 + schema/components/button.ts | 10 + schema/components/column.ts | 12 + schema/components/lazy-column.ts | 16 + schema/components/row.ts | 12 + schema/components/spacer.ts | 5 + schema/components/text-field.ts | 20 ++ schema/components/text.ts | 13 + schema/index.ts | 12 + schema/package.json | 11 + schema/tsconfig.json | 5 + 60 files changed, 2109 insertions(+), 193 deletions(-) create mode 100644 compose/src/androidMain/kotlin/wang/harlon/tinyui/Platform.android.kt create mode 100644 compose/src/androidMain/kotlin/wang/harlon/tinyui/schema/Placeholder.android.kt create mode 100644 compose/src/commonMain/kotlin/wang/harlon/tinyui/HostServices.kt create mode 100644 compose/src/commonMain/kotlin/wang/harlon/tinyui/components/generated/BuiltinSchemas.kt create mode 100644 compose/src/iosMain/kotlin/wang/harlon/tinyui/Platform.ios.kt create mode 100644 compose/src/iosMain/kotlin/wang/harlon/tinyui/schema/Placeholder.ios.kt create mode 100644 docs/components.md create mode 100644 docs/native-api.md create mode 100644 packages/cli/src/schema/dsl.ts create mode 100644 packages/cli/src/schema/generate.ts create mode 100644 packages/cli/src/schema/index.ts create mode 100644 packages/cli/src/schema/load.ts create mode 100644 packages/core/src/generated/components.ts create mode 100644 packages/native/src/events.ts create mode 100644 packages/native/src/http.ts create mode 100644 packages/native/src/navigation.ts create mode 100644 packages/native/src/store.ts create mode 100644 packages/native/test/host-stub.ts create mode 100644 packages/native/test/native.test.ts create mode 100644 sample/js/src/pages/todos.tsx create mode 100644 schema/components/box.ts create mode 100644 schema/components/button.ts create mode 100644 schema/components/column.ts create mode 100644 schema/components/lazy-column.ts create mode 100644 schema/components/row.ts create mode 100644 schema/components/spacer.ts create mode 100644 schema/components/text-field.ts create mode 100644 schema/components/text.ts create mode 100644 schema/index.ts create mode 100644 schema/package.json create mode 100644 schema/tsconfig.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7023c70..d4cfcd3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,5 +42,6 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm build + - run: pnpm schema:check - run: pnpm typecheck - run: pnpm test diff --git a/README.md b/README.md index 61bdce2..4332468 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ JS 写声明式组件、Compose Multiplatform 渲染的 UI 框架,引擎为 QuickJS(经 quickjs-kmp 接入)。Android / iOS 双端。 -> 早期阶段。关键选型已定,M1 Counter 已在双端跑通(TSX → 模块字节码 → 引擎 → patch → Compose);进度见 [docs/roadmap.md](./docs/roadmap.md)。 +> 早期阶段。关键选型已定,M1 Counter 与 M2 列表页已在双端跑通(TSX → 模块字节码 → 引擎 → patch → Compose);进度见 [docs/roadmap.md](./docs/roadmap.md)。 - 设计文档与决策记录:[docs/](./docs/README.md) - 响应式模型与引擎 benchmark:[bench/](./bench/README.md) @@ -14,7 +14,7 @@ JS 写声明式组件、Compose Multiplatform 渲染的 UI 框架,引擎为 Qu docs/ 设计文档、ADR、roadmap packages/ npm 包:@tiny-ui/core · @tiny-ui/native · @tiny-ui/cli compose/ KMP 库 wang.harlon:tinyui(Compose Multiplatform 侧) -schema/ 内置组件 schema,两侧契约的唯一真值 +schema/ 内置组件 schema(TS DSL),两侧契约的唯一真值,`pnpm schema` 生成 sample/ 示例 App(sample/js 是页面源码,Gradle 构建时经 CLI 编成字节码打进资源) bench/ 响应式模型与引擎 benchmark build-logic/ Gradle convention plugins @@ -28,6 +28,7 @@ build-logic/ Gradle convention plugins pnpm install # Node 22+,pnpm 10 ./gradlew build # compose 库 + 测试(Android host / iOS simulator)+ sample APK + iOS framework;需要 JDK 25、Android SDK、Xcode pnpm build && pnpm test # 三个 npm 包 +pnpm schema # 改了 schema/ 之后重新生成两侧代码 ``` sample 的 Gradle 构建会调 `tinyui build` 把 `sample/js` 编成字节码,需要 quickjs-kmp 的宿主工具 `qjsc-kmp`,Android host 测试还需要宿主 JNI 库。两种来源: diff --git a/compose/api/compose.klib.api b/compose/api/compose.klib.api index e365530..80c2347 100644 --- a/compose/api/compose.klib.api +++ b/compose/api/compose.klib.api @@ -6,20 +6,69 @@ // - Show declarations: true // Library unique name: +final enum class wang.harlon.tinyui.schema/FieldSpec : kotlin/Enum { // wang.harlon.tinyui.schema/FieldSpec|null[0] + enum entry Bool // wang.harlon.tinyui.schema/FieldSpec.Bool|null[0] + enum entry Num // wang.harlon.tinyui.schema/FieldSpec.Num|null[0] + enum entry Str // wang.harlon.tinyui.schema/FieldSpec.Str|null[0] + + final val entries // wang.harlon.tinyui.schema/FieldSpec.entries|#static{}entries[0] + final fun (): kotlin.enums/EnumEntries // wang.harlon.tinyui.schema/FieldSpec.entries.|#static(){}[0] + + final fun accepts(kotlinx.serialization.json/JsonPrimitive): kotlin/Boolean // wang.harlon.tinyui.schema/FieldSpec.accepts|accepts(kotlinx.serialization.json.JsonPrimitive){}[0] + final fun valueOf(kotlin/String): wang.harlon.tinyui.schema/FieldSpec // wang.harlon.tinyui.schema/FieldSpec.valueOf|valueOf#static(kotlin.String){}[0] + final fun values(): kotlin/Array // wang.harlon.tinyui.schema/FieldSpec.values|values#static(){}[0] +} + abstract fun interface wang.harlon.tinyui.schema/Component { // wang.harlon.tinyui.schema/Component|null[0] abstract fun Render(wang.harlon.tinyui.schema/NodeScope, androidx.compose.runtime/Composer?, kotlin/Int) // wang.harlon.tinyui.schema/Component.Render|Render(wang.harlon.tinyui.schema.NodeScope;androidx.compose.runtime.Composer?;kotlin.Int){}[0] } +abstract fun interface wang.harlon.tinyui/Config { // wang.harlon.tinyui/Config|null[0] + abstract fun get(kotlin/String): kotlin/String? // wang.harlon.tinyui/Config.get|get(kotlin.String){}[0] + + final object Companion { // wang.harlon.tinyui/Config.Companion|null[0] + final val None // wang.harlon.tinyui/Config.Companion.None|{}None[0] + final fun (): wang.harlon.tinyui/Config // wang.harlon.tinyui/Config.Companion.None.|(){}[0] + } +} + +abstract fun interface wang.harlon.tinyui/I18n { // wang.harlon.tinyui/I18n|null[0] + abstract fun translate(kotlin/String, kotlin/String): kotlin/String? // wang.harlon.tinyui/I18n.translate|translate(kotlin.String;kotlin.String){}[0] + + final object Companion { // wang.harlon.tinyui/I18n.Companion|null[0] + final val None // wang.harlon.tinyui/I18n.Companion.None|{}None[0] + final fun (): wang.harlon.tinyui/I18n // wang.harlon.tinyui/I18n.Companion.None.|(){}[0] + } +} + abstract interface wang.harlon.tinyui.schema/NodeScope { // wang.harlon.tinyui.schema/NodeScope|null[0] abstract val node // wang.harlon.tinyui.schema/NodeScope.node|{}node[0] abstract fun (): wang.harlon.tinyui.node/UiNode // wang.harlon.tinyui.schema/NodeScope.node.|(){}[0] abstract fun <#A1: kotlin/Any?> get(kotlin/String): #A1? // wang.harlon.tinyui.schema/NodeScope.get|get(kotlin.String){0§}[0] abstract fun Children(androidx.compose.runtime/Composer?, kotlin/Int) // wang.harlon.tinyui.schema/NodeScope.Children|Children(androidx.compose.runtime.Composer?;kotlin.Int){}[0] + abstract fun RenderChild(wang.harlon.tinyui.node/UiNode, androidx.compose.runtime/Composer?, kotlin/Int) // wang.harlon.tinyui.schema/NodeScope.RenderChild|RenderChild(wang.harlon.tinyui.node.UiNode;androidx.compose.runtime.Composer?;kotlin.Int){}[0] abstract fun dispatch(kotlin/String, kotlin/String = ...) // wang.harlon.tinyui.schema/NodeScope.dispatch|dispatch(kotlin.String;kotlin.String){}[0] abstract fun has(kotlin/String): kotlin/Boolean // wang.harlon.tinyui.schema/NodeScope.has|has(kotlin.String){}[0] abstract fun modifier(): androidx.compose.ui/Modifier // wang.harlon.tinyui.schema/NodeScope.modifier|modifier(){}[0] - abstract fun takeCommands(): kotlin.collections/List // wang.harlon.tinyui.schema/NodeScope.takeCommands|takeCommands(){}[0] +} + +abstract interface wang.harlon.tinyui/HttpClient { // wang.harlon.tinyui/HttpClient|null[0] + abstract suspend fun request(wang.harlon.tinyui/HttpRequest): wang.harlon.tinyui/HttpResponse // wang.harlon.tinyui/HttpClient.request|request(wang.harlon.tinyui.HttpRequest){}[0] + + final object None : wang.harlon.tinyui/HttpClient { // wang.harlon.tinyui/HttpClient.None|null[0] + final suspend fun request(wang.harlon.tinyui/HttpRequest): wang.harlon.tinyui/HttpResponse // wang.harlon.tinyui/HttpClient.None.request|request(wang.harlon.tinyui.HttpRequest){}[0] + } +} + +abstract interface wang.harlon.tinyui/Navigator { // wang.harlon.tinyui/Navigator|null[0] + abstract fun pop(kotlin/String?) // wang.harlon.tinyui/Navigator.pop|pop(kotlin.String?){}[0] + abstract fun push(kotlin/String, kotlin/String) // wang.harlon.tinyui/Navigator.push|push(kotlin.String;kotlin.String){}[0] + + final object None : wang.harlon.tinyui/Navigator { // wang.harlon.tinyui/Navigator.None|null[0] + final fun pop(kotlin/String?) // wang.harlon.tinyui/Navigator.None.pop|pop(kotlin.String?){}[0] + final fun push(kotlin/String, kotlin/String) // wang.harlon.tinyui/Navigator.None.push|push(kotlin.String;kotlin.String){}[0] + } } abstract interface wang.harlon.tinyui/PageSink { // wang.harlon.tinyui/PageSink|null[0] @@ -28,6 +77,39 @@ abstract interface wang.harlon.tinyui/PageSink { // wang.harlon.tinyui/PageSink| abstract fun patchProblem(wang.harlon.tinyui.node/PatchProblem) // wang.harlon.tinyui/PageSink.patchProblem|patchProblem(wang.harlon.tinyui.node.PatchProblem){}[0] } +abstract interface wang.harlon.tinyui/Store { // wang.harlon.tinyui/Store|null[0] + abstract fun get(kotlin/String): kotlin/String? // wang.harlon.tinyui/Store.get|get(kotlin.String){}[0] + abstract fun observe(kotlin/String, kotlin/Function1): kotlin/AutoCloseable // wang.harlon.tinyui/Store.observe|observe(kotlin.String;kotlin.Function1){}[0] + abstract fun set(kotlin/String, kotlin/String) // wang.harlon.tinyui/Store.set|set(kotlin.String;kotlin.String){}[0] +} + +sealed interface wang.harlon.tinyui.schema/SizeValue { // wang.harlon.tinyui.schema/SizeValue|null[0] + final class Fixed : wang.harlon.tinyui.schema/SizeValue { // wang.harlon.tinyui.schema/SizeValue.Fixed|null[0] + constructor (androidx.compose.ui.unit/Dp) // wang.harlon.tinyui.schema/SizeValue.Fixed.|(androidx.compose.ui.unit.Dp){}[0] + + final val dp // wang.harlon.tinyui.schema/SizeValue.Fixed.dp|{}dp[0] + final fun (): androidx.compose.ui.unit/Dp // wang.harlon.tinyui.schema/SizeValue.Fixed.dp.|(){}[0] + + final fun component1(): androidx.compose.ui.unit/Dp // wang.harlon.tinyui.schema/SizeValue.Fixed.component1|component1(){}[0] + final fun copy(androidx.compose.ui.unit/Dp = ...): wang.harlon.tinyui.schema/SizeValue.Fixed // wang.harlon.tinyui.schema/SizeValue.Fixed.copy|copy(androidx.compose.ui.unit.Dp){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // wang.harlon.tinyui.schema/SizeValue.Fixed.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // wang.harlon.tinyui.schema/SizeValue.Fixed.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // wang.harlon.tinyui.schema/SizeValue.Fixed.toString|toString(){}[0] + } + + final object Fill : wang.harlon.tinyui.schema/SizeValue { // wang.harlon.tinyui.schema/SizeValue.Fill|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // wang.harlon.tinyui.schema/SizeValue.Fill.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // wang.harlon.tinyui.schema/SizeValue.Fill.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // wang.harlon.tinyui.schema/SizeValue.Fill.toString|toString(){}[0] + } + + final object Wrap : wang.harlon.tinyui.schema/SizeValue { // wang.harlon.tinyui.schema/SizeValue.Wrap|null[0] + final fun equals(kotlin/Any?): kotlin/Boolean // wang.harlon.tinyui.schema/SizeValue.Wrap.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // wang.harlon.tinyui.schema/SizeValue.Wrap.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // wang.harlon.tinyui.schema/SizeValue.Wrap.toString|toString(){}[0] + } +} + final class wang.harlon.tinyui.node/Command { // wang.harlon.tinyui.node/Command|null[0] constructor (kotlin/String, kotlin.collections/Map) // wang.harlon.tinyui.node/Command.|(kotlin.String;kotlin.collections.Map){}[0] @@ -80,8 +162,8 @@ final class wang.harlon.tinyui.schema/ComponentRegistry { // wang.harlon.tinyui. constructor () // wang.harlon.tinyui.schema/ComponentRegistry.|(){}[0] final fun Render(kotlin/String, wang.harlon.tinyui.schema/NodeScope, androidx.compose.runtime/Composer?, kotlin/Int) // wang.harlon.tinyui.schema/ComponentRegistry.Render|Render(kotlin.String;wang.harlon.tinyui.schema.NodeScope;androidx.compose.runtime.Composer?;kotlin.Int){}[0] - final fun manifest(kotlin.collections/List = ...): kotlin/String // wang.harlon.tinyui.schema/ComponentRegistry.manifest|manifest(kotlin.collections.List){}[0] - final fun register(kotlin/String, kotlin/Function1, wang.harlon.tinyui.schema/Component) // wang.harlon.tinyui.schema/ComponentRegistry.register|register(kotlin.String;kotlin.Function1;wang.harlon.tinyui.schema.Component){}[0] + final fun manifest(kotlin.collections/List): kotlin/String // wang.harlon.tinyui.schema/ComponentRegistry.manifest|manifest(kotlin.collections.List){}[0] + final fun register(wang.harlon.tinyui.schema/ComponentSchema, wang.harlon.tinyui.schema/Component) // wang.harlon.tinyui.schema/ComponentRegistry.register|register(wang.harlon.tinyui.schema.ComponentSchema;wang.harlon.tinyui.schema.Component){}[0] final fun schema(kotlin/String): wang.harlon.tinyui.schema/ComponentSchema? // wang.harlon.tinyui.schema/ComponentRegistry.schema|schema(kotlin.String){}[0] final object Companion { // wang.harlon.tinyui.schema/ComponentRegistry.Companion|null[0] @@ -91,25 +173,87 @@ final class wang.harlon.tinyui.schema/ComponentRegistry { // wang.harlon.tinyui. } final class wang.harlon.tinyui.schema/ComponentSchema { // wang.harlon.tinyui.schema/ComponentSchema|null[0] + constructor (kotlin/String, kotlin.collections/Map, kotlin.collections/Map>, kotlin.collections/Map>, kotlin/Boolean, kotlin/Boolean) // wang.harlon.tinyui.schema/ComponentSchema.|(kotlin.String;kotlin.collections.Map;kotlin.collections.Map>;kotlin.collections.Map>;kotlin.Boolean;kotlin.Boolean){}[0] + + final val children // wang.harlon.tinyui.schema/ComponentSchema.children|{}children[0] + final fun (): kotlin/Boolean // wang.harlon.tinyui.schema/ComponentSchema.children.|(){}[0] final val commands // wang.harlon.tinyui.schema/ComponentSchema.commands|{}commands[0] - final fun (): kotlin.collections/Set // wang.harlon.tinyui.schema/ComponentSchema.commands.|(){}[0] + final fun (): kotlin.collections/Map> // wang.harlon.tinyui.schema/ComponentSchema.commands.|(){}[0] final val events // wang.harlon.tinyui.schema/ComponentSchema.events|{}events[0] - final fun (): kotlin.collections/Set // wang.harlon.tinyui.schema/ComponentSchema.events.|(){}[0] + final fun (): kotlin.collections/Map> // wang.harlon.tinyui.schema/ComponentSchema.events.|(){}[0] + final val layout // wang.harlon.tinyui.schema/ComponentSchema.layout|{}layout[0] + final fun (): kotlin/Boolean // wang.harlon.tinyui.schema/ComponentSchema.layout.|(){}[0] final val props // wang.harlon.tinyui.schema/ComponentSchema.props|{}props[0] final fun (): kotlin.collections/Map // wang.harlon.tinyui.schema/ComponentSchema.props.|(){}[0] + final val type // wang.harlon.tinyui.schema/ComponentSchema.type|{}type[0] + final fun (): kotlin/String // wang.harlon.tinyui.schema/ComponentSchema.type.|(){}[0] + + final fun prop(kotlin/String): wang.harlon.tinyui.schema/PropSpec? // wang.harlon.tinyui.schema/ComponentSchema.prop|prop(kotlin.String){}[0] +} + +final class wang.harlon.tinyui/EventBus { // wang.harlon.tinyui/EventBus|null[0] + constructor () // wang.harlon.tinyui/EventBus.|(){}[0] + + final fun emit(kotlin/String, kotlin/String) // wang.harlon.tinyui/EventBus.emit|emit(kotlin.String;kotlin.String){}[0] + final fun subscribe(kotlin/String, kotlin/Function1): kotlin/AutoCloseable // wang.harlon.tinyui/EventBus.subscribe|subscribe(kotlin.String;kotlin.Function1){}[0] +} + +final class wang.harlon.tinyui/HostException : kotlin/Exception { // wang.harlon.tinyui/HostException|null[0] + constructor (kotlin/String, kotlin/String) // wang.harlon.tinyui/HostException.|(kotlin.String;kotlin.String){}[0] + + final val code // wang.harlon.tinyui/HostException.code|{}code[0] + final fun (): kotlin/String // wang.harlon.tinyui/HostException.code.|(){}[0] +} + +final class wang.harlon.tinyui/HostServices { // wang.harlon.tinyui/HostServices|null[0] + constructor (wang.harlon.tinyui/Navigator = ..., wang.harlon.tinyui/Store = ..., wang.harlon.tinyui/HttpClient = ..., wang.harlon.tinyui/I18n = ..., wang.harlon.tinyui/Config = ..., wang.harlon.tinyui/EventBus = ..., kotlin.collections/Map = ...) // wang.harlon.tinyui/HostServices.|(wang.harlon.tinyui.Navigator;wang.harlon.tinyui.Store;wang.harlon.tinyui.HttpClient;wang.harlon.tinyui.I18n;wang.harlon.tinyui.Config;wang.harlon.tinyui.EventBus;kotlin.collections.Map){}[0] + + final val config // wang.harlon.tinyui/HostServices.config|{}config[0] + final fun (): wang.harlon.tinyui/Config // wang.harlon.tinyui/HostServices.config.|(){}[0] + final val deviceInfo // wang.harlon.tinyui/HostServices.deviceInfo|{}deviceInfo[0] + final fun (): kotlin.collections/Map // wang.harlon.tinyui/HostServices.deviceInfo.|(){}[0] + final val events // wang.harlon.tinyui/HostServices.events|{}events[0] + final fun (): wang.harlon.tinyui/EventBus // wang.harlon.tinyui/HostServices.events.|(){}[0] + final val http // wang.harlon.tinyui/HostServices.http|{}http[0] + final fun (): wang.harlon.tinyui/HttpClient // wang.harlon.tinyui/HostServices.http.|(){}[0] + final val i18n // wang.harlon.tinyui/HostServices.i18n|{}i18n[0] + final fun (): wang.harlon.tinyui/I18n // wang.harlon.tinyui/HostServices.i18n.|(){}[0] + final val navigator // wang.harlon.tinyui/HostServices.navigator|{}navigator[0] + final fun (): wang.harlon.tinyui/Navigator // wang.harlon.tinyui/HostServices.navigator.|(){}[0] + final val store // wang.harlon.tinyui/HostServices.store|{}store[0] + final fun (): wang.harlon.tinyui/Store // wang.harlon.tinyui/HostServices.store.|(){}[0] +} + +final class wang.harlon.tinyui/HttpRequest { // wang.harlon.tinyui/HttpRequest|null[0] + constructor (kotlin/String, kotlin/String, kotlin.collections/Map, kotlin/String?, kotlin/Long?) // wang.harlon.tinyui/HttpRequest.|(kotlin.String;kotlin.String;kotlin.collections.Map;kotlin.String?;kotlin.Long?){}[0] + + final val bodyJson // wang.harlon.tinyui/HttpRequest.bodyJson|{}bodyJson[0] + final fun (): kotlin/String? // wang.harlon.tinyui/HttpRequest.bodyJson.|(){}[0] + final val headers // wang.harlon.tinyui/HttpRequest.headers|{}headers[0] + final fun (): kotlin.collections/Map // wang.harlon.tinyui/HttpRequest.headers.|(){}[0] + final val method // wang.harlon.tinyui/HttpRequest.method|{}method[0] + final fun (): kotlin/String // wang.harlon.tinyui/HttpRequest.method.|(){}[0] + final val timeoutMs // wang.harlon.tinyui/HttpRequest.timeoutMs|{}timeoutMs[0] + final fun (): kotlin/Long? // wang.harlon.tinyui/HttpRequest.timeoutMs.|(){}[0] + final val url // wang.harlon.tinyui/HttpRequest.url|{}url[0] + final fun (): kotlin/String // wang.harlon.tinyui/HttpRequest.url.|(){}[0] +} + +final class wang.harlon.tinyui/HttpResponse { // wang.harlon.tinyui/HttpResponse|null[0] + constructor (kotlin/Int, kotlin/String) // wang.harlon.tinyui/HttpResponse.|(kotlin.Int;kotlin.String){}[0] + + final val bodyJson // wang.harlon.tinyui/HttpResponse.bodyJson|{}bodyJson[0] + final fun (): kotlin/String // wang.harlon.tinyui/HttpResponse.bodyJson.|(){}[0] + final val status // wang.harlon.tinyui/HttpResponse.status|{}status[0] + final fun (): kotlin/Int // wang.harlon.tinyui/HttpResponse.status.|(){}[0] } -final class wang.harlon.tinyui.schema/SchemaBuilder { // wang.harlon.tinyui.schema/SchemaBuilder|null[0] - final fun <#A1: kotlin/Enum<#A1>> enum(kotlin/String, #A1?, kotlin.collections/Map) // wang.harlon.tinyui.schema/SchemaBuilder.enum|enum(kotlin.String;0:0?;kotlin.collections.Map){0§>}[0] - final fun boolean(kotlin/String, kotlin/Boolean? = ...) // wang.harlon.tinyui.schema/SchemaBuilder.boolean|boolean(kotlin.String;kotlin.Boolean?){}[0] - final fun color(kotlin/String, androidx.compose.ui.graphics/Color? = ...) // wang.harlon.tinyui.schema/SchemaBuilder.color|color(kotlin.String;androidx.compose.ui.graphics.Color?){}[0] - final fun command(kotlin/String) // wang.harlon.tinyui.schema/SchemaBuilder.command|command(kotlin.String){}[0] - final fun dp(kotlin/String, androidx.compose.ui.unit/Dp? = ...) // wang.harlon.tinyui.schema/SchemaBuilder.dp|dp(kotlin.String;androidx.compose.ui.unit.Dp?){}[0] - final fun event(kotlin/String) // wang.harlon.tinyui.schema/SchemaBuilder.event|event(kotlin.String){}[0] - final fun number(kotlin/String, kotlin/Double? = ...) // wang.harlon.tinyui.schema/SchemaBuilder.number|number(kotlin.String;kotlin.Double?){}[0] - final fun sp(kotlin/String, androidx.compose.ui.unit/TextUnit? = ...) // wang.harlon.tinyui.schema/SchemaBuilder.sp|sp(kotlin.String;androidx.compose.ui.unit.TextUnit?){}[0] - final fun string(kotlin/String, kotlin/String? = ...) // wang.harlon.tinyui.schema/SchemaBuilder.string|string(kotlin.String;kotlin.String?){}[0] - final inline fun <#A1: reified kotlin/Enum<#A1>> enum(kotlin/String, #A1? = ...) // wang.harlon.tinyui.schema/SchemaBuilder.enum|enum(kotlin.String;0:0?){0§>}[0] +final class wang.harlon.tinyui/InMemoryStore : wang.harlon.tinyui/Store { // wang.harlon.tinyui/InMemoryStore|null[0] + constructor () // wang.harlon.tinyui/InMemoryStore.|(){}[0] + + final fun get(kotlin/String): kotlin/String? // wang.harlon.tinyui/InMemoryStore.get|get(kotlin.String){}[0] + final fun observe(kotlin/String, kotlin/Function1): kotlin/AutoCloseable // wang.harlon.tinyui/InMemoryStore.observe|observe(kotlin.String;kotlin.Function1){}[0] + final fun set(kotlin/String, kotlin/String) // wang.harlon.tinyui/InMemoryStore.set|set(kotlin.String;kotlin.String){}[0] } final class wang.harlon.tinyui/PageFailure { // wang.harlon.tinyui/PageFailure|null[0] @@ -122,7 +266,7 @@ final class wang.harlon.tinyui/PageFailure { // wang.harlon.tinyui/PageFailure|n } final class wang.harlon.tinyui/PageHost { // wang.harlon.tinyui/PageHost|null[0] - constructor (wang.harlon.tinyui/RuntimeBundle, kotlin/ByteArray, wang.harlon.tinyui.schema/ComponentRegistry, wang.harlon.tinyui/PageSink, kotlin/String = ...) // wang.harlon.tinyui/PageHost.|(wang.harlon.tinyui.RuntimeBundle;kotlin.ByteArray;wang.harlon.tinyui.schema.ComponentRegistry;wang.harlon.tinyui.PageSink;kotlin.String){}[0] + constructor (wang.harlon.tinyui/RuntimeBundle, kotlin/ByteArray, wang.harlon.tinyui.schema/ComponentRegistry, wang.harlon.tinyui/PageSink, wang.harlon.tinyui/HostServices = ..., kotlin/String = ..., kotlin/Long = ...) // wang.harlon.tinyui/PageHost.|(wang.harlon.tinyui.RuntimeBundle;kotlin.ByteArray;wang.harlon.tinyui.schema.ComponentRegistry;wang.harlon.tinyui.PageSink;wang.harlon.tinyui.HostServices;kotlin.String;kotlin.Long){}[0] final val registry // wang.harlon.tinyui/PageHost.registry|{}registry[0] final fun (): wang.harlon.tinyui.schema/ComponentRegistry // wang.harlon.tinyui/PageHost.registry.|(){}[0] @@ -135,7 +279,7 @@ final class wang.harlon.tinyui/PageHost { // wang.harlon.tinyui/PageHost|null[0] final fun close() // wang.harlon.tinyui/PageHost.close|close(){}[0] final fun dispatch(kotlin/Int, kotlin/String, kotlin/String) // wang.harlon.tinyui/PageHost.dispatch|dispatch(kotlin.Int;kotlin.String;kotlin.String){}[0] final fun emit(kotlin/String, kotlin/String) // wang.harlon.tinyui/PageHost.emit|emit(kotlin.String;kotlin.String){}[0] - final fun reject(kotlin/Int, kotlin/String) // wang.harlon.tinyui/PageHost.reject|reject(kotlin.Int;kotlin.String){}[0] + final fun reject(kotlin/Int, kotlin/String, kotlin/String) // wang.harlon.tinyui/PageHost.reject|reject(kotlin.Int;kotlin.String;kotlin.String){}[0] final fun resolve(kotlin/Int, kotlin/String) // wang.harlon.tinyui/PageHost.resolve|resolve(kotlin.Int;kotlin.String){}[0] final fun start() // wang.harlon.tinyui/PageHost.start|start(){}[0] final fun visible(kotlin/Boolean) // wang.harlon.tinyui/PageHost.visible|visible(kotlin.Boolean){}[0] @@ -158,56 +302,118 @@ final class wang.harlon.tinyui/RuntimeBundle { // wang.harlon.tinyui/RuntimeBund } sealed class wang.harlon.tinyui.schema/PropSpec { // wang.harlon.tinyui.schema/PropSpec|null[0] - final val default // wang.harlon.tinyui.schema/PropSpec.default|{}default[0] - final fun (): kotlin/Any? // wang.harlon.tinyui.schema/PropSpec.default.|(){}[0] + abstract val default // wang.harlon.tinyui.schema/PropSpec.default|{}default[0] + abstract fun (): kotlin/Any? // wang.harlon.tinyui.schema/PropSpec.default.|(){}[0] + final val initial // wang.harlon.tinyui.schema/PropSpec.initial|{}initial[0] + final fun (): kotlin/Boolean // wang.harlon.tinyui.schema/PropSpec.initial.|(){}[0] final val kind // wang.harlon.tinyui.schema/PropSpec.kind|{}kind[0] final fun (): kotlin/String // wang.harlon.tinyui.schema/PropSpec.kind.|(){}[0] + final val required // wang.harlon.tinyui.schema/PropSpec.required|{}required[0] + final fun (): kotlin/Boolean // wang.harlon.tinyui.schema/PropSpec.required.|(){}[0] abstract fun convert(kotlinx.serialization.json/JsonPrimitive): kotlin/Any? // wang.harlon.tinyui.schema/PropSpec.convert|convert(kotlinx.serialization.json.JsonPrimitive){}[0] - final class <#A1: kotlin/Enum<#A1>> Enum : wang.harlon.tinyui.schema/PropSpec { // wang.harlon.tinyui.schema/PropSpec.Enum|null[0] - constructor (#A1?, kotlin.collections/Map) // wang.harlon.tinyui.schema/PropSpec.Enum.|(1:0?;kotlin.collections.Map){}[0] - - final fun convert(kotlinx.serialization.json/JsonPrimitive): kotlin/Any? // wang.harlon.tinyui.schema/PropSpec.Enum.convert|convert(kotlinx.serialization.json.JsonPrimitive){}[0] - } - final class Bool : wang.harlon.tinyui.schema/PropSpec { // wang.harlon.tinyui.schema/PropSpec.Bool|null[0] - constructor (kotlin/Boolean?) // wang.harlon.tinyui.schema/PropSpec.Bool.|(kotlin.Boolean?){}[0] + constructor (kotlin/Boolean?, kotlin/Boolean = ..., kotlin/Boolean = ...) // wang.harlon.tinyui.schema/PropSpec.Bool.|(kotlin.Boolean?;kotlin.Boolean;kotlin.Boolean){}[0] + + final val default // wang.harlon.tinyui.schema/PropSpec.Bool.default|{}default[0] + final fun (): kotlin/Boolean? // wang.harlon.tinyui.schema/PropSpec.Bool.default.|(){}[0] final fun convert(kotlinx.serialization.json/JsonPrimitive): kotlin/Any? // wang.harlon.tinyui.schema/PropSpec.Bool.convert|convert(kotlinx.serialization.json.JsonPrimitive){}[0] } final class ColorSpec : wang.harlon.tinyui.schema/PropSpec { // wang.harlon.tinyui.schema/PropSpec.ColorSpec|null[0] - constructor (androidx.compose.ui.graphics/Color?) // wang.harlon.tinyui.schema/PropSpec.ColorSpec.|(androidx.compose.ui.graphics.Color?){}[0] + constructor (kotlin/String?, kotlin/Boolean = ..., kotlin/Boolean = ...) // wang.harlon.tinyui.schema/PropSpec.ColorSpec.|(kotlin.String?;kotlin.Boolean;kotlin.Boolean){}[0] + + final val default // wang.harlon.tinyui.schema/PropSpec.ColorSpec.default|{}default[0] + final fun (): androidx.compose.ui.graphics/Color? // wang.harlon.tinyui.schema/PropSpec.ColorSpec.default.|(){}[0] final fun convert(kotlinx.serialization.json/JsonPrimitive): kotlin/Any? // wang.harlon.tinyui.schema/PropSpec.ColorSpec.convert|convert(kotlinx.serialization.json.JsonPrimitive){}[0] } final class Dp : wang.harlon.tinyui.schema/PropSpec { // wang.harlon.tinyui.schema/PropSpec.Dp|null[0] - constructor (androidx.compose.ui.unit/Dp?) // wang.harlon.tinyui.schema/PropSpec.Dp.|(androidx.compose.ui.unit.Dp?){}[0] + constructor (kotlin/Double?, kotlin/Boolean = ..., kotlin/Boolean = ...) // wang.harlon.tinyui.schema/PropSpec.Dp.|(kotlin.Double?;kotlin.Boolean;kotlin.Boolean){}[0] + + final val default // wang.harlon.tinyui.schema/PropSpec.Dp.default|{}default[0] + final fun (): androidx.compose.ui.unit/Dp? // wang.harlon.tinyui.schema/PropSpec.Dp.default.|(){}[0] final fun convert(kotlinx.serialization.json/JsonPrimitive): kotlin/Any? // wang.harlon.tinyui.schema/PropSpec.Dp.convert|convert(kotlinx.serialization.json.JsonPrimitive){}[0] } + final class Enum : wang.harlon.tinyui.schema/PropSpec { // wang.harlon.tinyui.schema/PropSpec.Enum|null[0] + constructor (kotlin.collections/Set, kotlin/String?, kotlin/Boolean = ..., kotlin/Boolean = ...) // wang.harlon.tinyui.schema/PropSpec.Enum.|(kotlin.collections.Set;kotlin.String?;kotlin.Boolean;kotlin.Boolean){}[0] + + final val default // wang.harlon.tinyui.schema/PropSpec.Enum.default|{}default[0] + final fun (): kotlin/String? // wang.harlon.tinyui.schema/PropSpec.Enum.default.|(){}[0] + final val values // wang.harlon.tinyui.schema/PropSpec.Enum.values|{}values[0] + final fun (): kotlin.collections/Set // wang.harlon.tinyui.schema/PropSpec.Enum.values.|(){}[0] + + final fun convert(kotlinx.serialization.json/JsonPrimitive): kotlin/Any? // wang.harlon.tinyui.schema/PropSpec.Enum.convert|convert(kotlinx.serialization.json.JsonPrimitive){}[0] + } + final class Num : wang.harlon.tinyui.schema/PropSpec { // wang.harlon.tinyui.schema/PropSpec.Num|null[0] - constructor (kotlin/Double?) // wang.harlon.tinyui.schema/PropSpec.Num.|(kotlin.Double?){}[0] + constructor (kotlin/Double?, kotlin/Boolean = ..., kotlin/Boolean = ...) // wang.harlon.tinyui.schema/PropSpec.Num.|(kotlin.Double?;kotlin.Boolean;kotlin.Boolean){}[0] + + final val default // wang.harlon.tinyui.schema/PropSpec.Num.default|{}default[0] + final fun (): kotlin/Double? // wang.harlon.tinyui.schema/PropSpec.Num.default.|(){}[0] final fun convert(kotlinx.serialization.json/JsonPrimitive): kotlin/Any? // wang.harlon.tinyui.schema/PropSpec.Num.convert|convert(kotlinx.serialization.json.JsonPrimitive){}[0] } + final class Size : wang.harlon.tinyui.schema/PropSpec { // wang.harlon.tinyui.schema/PropSpec.Size|null[0] + constructor (kotlin/String?, kotlin/Boolean = ..., kotlin/Boolean = ...) // wang.harlon.tinyui.schema/PropSpec.Size.|(kotlin.String?;kotlin.Boolean;kotlin.Boolean){}[0] + + final val default // wang.harlon.tinyui.schema/PropSpec.Size.default|{}default[0] + final fun (): wang.harlon.tinyui.schema/SizeValue? // wang.harlon.tinyui.schema/PropSpec.Size.default.|(){}[0] + + final fun convert(kotlinx.serialization.json/JsonPrimitive): kotlin/Any? // wang.harlon.tinyui.schema/PropSpec.Size.convert|convert(kotlinx.serialization.json.JsonPrimitive){}[0] + } + final class Sp : wang.harlon.tinyui.schema/PropSpec { // wang.harlon.tinyui.schema/PropSpec.Sp|null[0] - constructor (androidx.compose.ui.unit/TextUnit?) // wang.harlon.tinyui.schema/PropSpec.Sp.|(androidx.compose.ui.unit.TextUnit?){}[0] + constructor (kotlin/Double?, kotlin/Boolean = ..., kotlin/Boolean = ...) // wang.harlon.tinyui.schema/PropSpec.Sp.|(kotlin.Double?;kotlin.Boolean;kotlin.Boolean){}[0] + + final val default // wang.harlon.tinyui.schema/PropSpec.Sp.default|{}default[0] + final fun (): androidx.compose.ui.unit/TextUnit? // wang.harlon.tinyui.schema/PropSpec.Sp.default.|(){}[0] final fun convert(kotlinx.serialization.json/JsonPrimitive): kotlin/Any? // wang.harlon.tinyui.schema/PropSpec.Sp.convert|convert(kotlinx.serialization.json.JsonPrimitive){}[0] } final class Str : wang.harlon.tinyui.schema/PropSpec { // wang.harlon.tinyui.schema/PropSpec.Str|null[0] - constructor (kotlin/String?) // wang.harlon.tinyui.schema/PropSpec.Str.|(kotlin.String?){}[0] + constructor (kotlin/String?, kotlin/Boolean = ..., kotlin/Boolean = ...) // wang.harlon.tinyui.schema/PropSpec.Str.|(kotlin.String?;kotlin.Boolean;kotlin.Boolean){}[0] + + final val default // wang.harlon.tinyui.schema/PropSpec.Str.default|{}default[0] + final fun (): kotlin/String? // wang.harlon.tinyui.schema/PropSpec.Str.default.|(){}[0] final fun convert(kotlinx.serialization.json/JsonPrimitive): kotlin/Any? // wang.harlon.tinyui.schema/PropSpec.Str.convert|convert(kotlinx.serialization.json.JsonPrimitive){}[0] } } +final object wang.harlon.tinyui.components.generated/BuiltinSchemas { // wang.harlon.tinyui.components.generated/BuiltinSchemas|null[0] + final val Box // wang.harlon.tinyui.components.generated/BuiltinSchemas.Box|{}Box[0] + final fun (): wang.harlon.tinyui.schema/ComponentSchema // wang.harlon.tinyui.components.generated/BuiltinSchemas.Box.|(){}[0] + final val Button // wang.harlon.tinyui.components.generated/BuiltinSchemas.Button|{}Button[0] + final fun (): wang.harlon.tinyui.schema/ComponentSchema // wang.harlon.tinyui.components.generated/BuiltinSchemas.Button.|(){}[0] + final val Column // wang.harlon.tinyui.components.generated/BuiltinSchemas.Column|{}Column[0] + final fun (): wang.harlon.tinyui.schema/ComponentSchema // wang.harlon.tinyui.components.generated/BuiltinSchemas.Column.|(){}[0] + final val LazyColumn // wang.harlon.tinyui.components.generated/BuiltinSchemas.LazyColumn|{}LazyColumn[0] + final fun (): wang.harlon.tinyui.schema/ComponentSchema // wang.harlon.tinyui.components.generated/BuiltinSchemas.LazyColumn.|(){}[0] + final val Row // wang.harlon.tinyui.components.generated/BuiltinSchemas.Row|{}Row[0] + final fun (): wang.harlon.tinyui.schema/ComponentSchema // wang.harlon.tinyui.components.generated/BuiltinSchemas.Row.|(){}[0] + final val Spacer // wang.harlon.tinyui.components.generated/BuiltinSchemas.Spacer|{}Spacer[0] + final fun (): wang.harlon.tinyui.schema/ComponentSchema // wang.harlon.tinyui.components.generated/BuiltinSchemas.Spacer.|(){}[0] + final val Text // wang.harlon.tinyui.components.generated/BuiltinSchemas.Text|{}Text[0] + final fun (): wang.harlon.tinyui.schema/ComponentSchema // wang.harlon.tinyui.components.generated/BuiltinSchemas.Text.|(){}[0] + final val TextField // wang.harlon.tinyui.components.generated/BuiltinSchemas.TextField|{}TextField[0] + final fun (): wang.harlon.tinyui.schema/ComponentSchema // wang.harlon.tinyui.components.generated/BuiltinSchemas.TextField.|(){}[0] + final val all // wang.harlon.tinyui.components.generated/BuiltinSchemas.all|{}all[0] + final fun (): kotlin.collections/List // wang.harlon.tinyui.components.generated/BuiltinSchemas.all.|(){}[0] +} + +final object wang.harlon.tinyui.schema/LayoutProps { // wang.harlon.tinyui.schema/LayoutProps|null[0] + final val specs // wang.harlon.tinyui.schema/LayoutProps.specs|{}specs[0] + final fun (): kotlin.collections/Map // wang.harlon.tinyui.schema/LayoutProps.specs.|(){}[0] +} + final object wang.harlon.tinyui/TinyUI { // wang.harlon.tinyui/TinyUI|null[0] final val engineVersion // wang.harlon.tinyui/TinyUI.engineVersion|{}engineVersion[0] final fun (): kotlin/String // wang.harlon.tinyui/TinyUI.engineVersion.|(){}[0] @@ -216,43 +422,70 @@ final object wang.harlon.tinyui/TinyUI { // wang.harlon.tinyui/TinyUI|null[0] final const val wang.harlon.tinyui.node/ROOT_ID // wang.harlon.tinyui.node/ROOT_ID|{}ROOT_ID[0] final fun (): kotlin/Int // wang.harlon.tinyui.node/ROOT_ID.|(){}[0] +final val wang.harlon.tinyui.components.generated/wang_harlon_tinyui_components_generated_BuiltinSchemas$stableprop // wang.harlon.tinyui.components.generated/wang_harlon_tinyui_components_generated_BuiltinSchemas$stableprop|#static{}wang_harlon_tinyui_components_generated_BuiltinSchemas$stableprop[0] final val wang.harlon.tinyui.node/wang_harlon_tinyui_node_Command$stableprop // wang.harlon.tinyui.node/wang_harlon_tinyui_node_Command$stableprop|#static{}wang_harlon_tinyui_node_Command$stableprop[0] final val wang.harlon.tinyui.node/wang_harlon_tinyui_node_NodeTree$stableprop // wang.harlon.tinyui.node/wang_harlon_tinyui_node_NodeTree$stableprop|#static{}wang_harlon_tinyui_node_NodeTree$stableprop[0] final val wang.harlon.tinyui.node/wang_harlon_tinyui_node_PatchProblem$stableprop // wang.harlon.tinyui.node/wang_harlon_tinyui_node_PatchProblem$stableprop|#static{}wang_harlon_tinyui_node_PatchProblem$stableprop[0] final val wang.harlon.tinyui.node/wang_harlon_tinyui_node_UiNode$stableprop // wang.harlon.tinyui.node/wang_harlon_tinyui_node_UiNode$stableprop|#static{}wang_harlon_tinyui_node_UiNode$stableprop[0] final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_ComponentRegistry$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_ComponentRegistry$stableprop|#static{}wang_harlon_tinyui_schema_ComponentRegistry$stableprop[0] final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_ComponentSchema$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_ComponentSchema$stableprop|#static{}wang_harlon_tinyui_schema_ComponentSchema$stableprop[0] +final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_LayoutProps$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_LayoutProps$stableprop|#static{}wang_harlon_tinyui_schema_LayoutProps$stableprop[0] final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec$stableprop|#static{}wang_harlon_tinyui_schema_PropSpec$stableprop[0] final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Bool$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Bool$stableprop|#static{}wang_harlon_tinyui_schema_PropSpec_Bool$stableprop[0] final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_ColorSpec$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_ColorSpec$stableprop|#static{}wang_harlon_tinyui_schema_PropSpec_ColorSpec$stableprop[0] final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Dp$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Dp$stableprop|#static{}wang_harlon_tinyui_schema_PropSpec_Dp$stableprop[0] final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Enum$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Enum$stableprop|#static{}wang_harlon_tinyui_schema_PropSpec_Enum$stableprop[0] final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Num$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Num$stableprop|#static{}wang_harlon_tinyui_schema_PropSpec_Num$stableprop[0] +final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Size$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Size$stableprop|#static{}wang_harlon_tinyui_schema_PropSpec_Size$stableprop[0] final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Sp$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Sp$stableprop|#static{}wang_harlon_tinyui_schema_PropSpec_Sp$stableprop[0] final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Str$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Str$stableprop|#static{}wang_harlon_tinyui_schema_PropSpec_Str$stableprop[0] -final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SchemaBuilder$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SchemaBuilder$stableprop|#static{}wang_harlon_tinyui_schema_SchemaBuilder$stableprop[0] +final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Fill$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Fill$stableprop|#static{}wang_harlon_tinyui_schema_SizeValue_Fill$stableprop[0] +final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Fixed$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Fixed$stableprop|#static{}wang_harlon_tinyui_schema_SizeValue_Fixed$stableprop[0] +final val wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Wrap$stableprop // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Wrap$stableprop|#static{}wang_harlon_tinyui_schema_SizeValue_Wrap$stableprop[0] +final val wang.harlon.tinyui/wang_harlon_tinyui_EventBus$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_EventBus$stableprop|#static{}wang_harlon_tinyui_EventBus$stableprop[0] +final val wang.harlon.tinyui/wang_harlon_tinyui_HostException$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_HostException$stableprop|#static{}wang_harlon_tinyui_HostException$stableprop[0] +final val wang.harlon.tinyui/wang_harlon_tinyui_HostServices$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_HostServices$stableprop|#static{}wang_harlon_tinyui_HostServices$stableprop[0] +final val wang.harlon.tinyui/wang_harlon_tinyui_HttpClient_None$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_HttpClient_None$stableprop|#static{}wang_harlon_tinyui_HttpClient_None$stableprop[0] +final val wang.harlon.tinyui/wang_harlon_tinyui_HttpRequest$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_HttpRequest$stableprop|#static{}wang_harlon_tinyui_HttpRequest$stableprop[0] +final val wang.harlon.tinyui/wang_harlon_tinyui_HttpResponse$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_HttpResponse$stableprop|#static{}wang_harlon_tinyui_HttpResponse$stableprop[0] +final val wang.harlon.tinyui/wang_harlon_tinyui_InMemoryStore$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_InMemoryStore$stableprop|#static{}wang_harlon_tinyui_InMemoryStore$stableprop[0] +final val wang.harlon.tinyui/wang_harlon_tinyui_Navigator_None$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_Navigator_None$stableprop|#static{}wang_harlon_tinyui_Navigator_None$stableprop[0] final val wang.harlon.tinyui/wang_harlon_tinyui_PageFailure$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_PageFailure$stableprop|#static{}wang_harlon_tinyui_PageFailure$stableprop[0] final val wang.harlon.tinyui/wang_harlon_tinyui_PageHost$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_PageHost$stableprop|#static{}wang_harlon_tinyui_PageHost$stableprop[0] final val wang.harlon.tinyui/wang_harlon_tinyui_RuntimeBundle$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_RuntimeBundle$stableprop|#static{}wang_harlon_tinyui_RuntimeBundle$stableprop[0] final val wang.harlon.tinyui/wang_harlon_tinyui_TinyUI$stableprop // wang.harlon.tinyui/wang_harlon_tinyui_TinyUI$stableprop|#static{}wang_harlon_tinyui_TinyUI$stableprop[0] final fun (wang.harlon.tinyui.schema/ComponentRegistry).wang.harlon.tinyui.components/registerBuiltins(): wang.harlon.tinyui.schema/ComponentRegistry // wang.harlon.tinyui.components/registerBuiltins|registerBuiltins@wang.harlon.tinyui.schema.ComponentRegistry(){}[0] +final fun (wang.harlon.tinyui.schema/NodeScope).wang.harlon.tinyui.schema/Commands(kotlin.coroutines/SuspendFunction1, androidx.compose.runtime/Composer?, kotlin/Int) // wang.harlon.tinyui.schema/Commands|Commands@wang.harlon.tinyui.schema.NodeScope(kotlin.coroutines.SuspendFunction1;androidx.compose.runtime.Composer?;kotlin.Int){}[0] +final fun wang.harlon.tinyui.components.generated/wang_harlon_tinyui_components_generated_BuiltinSchemas$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.components.generated/wang_harlon_tinyui_components_generated_BuiltinSchemas$stableprop_getter|wang_harlon_tinyui_components_generated_BuiltinSchemas$stableprop_getter(){}[0] final fun wang.harlon.tinyui.node/wang_harlon_tinyui_node_Command$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.node/wang_harlon_tinyui_node_Command$stableprop_getter|wang_harlon_tinyui_node_Command$stableprop_getter(){}[0] final fun wang.harlon.tinyui.node/wang_harlon_tinyui_node_NodeTree$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.node/wang_harlon_tinyui_node_NodeTree$stableprop_getter|wang_harlon_tinyui_node_NodeTree$stableprop_getter(){}[0] final fun wang.harlon.tinyui.node/wang_harlon_tinyui_node_PatchProblem$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.node/wang_harlon_tinyui_node_PatchProblem$stableprop_getter|wang_harlon_tinyui_node_PatchProblem$stableprop_getter(){}[0] final fun wang.harlon.tinyui.node/wang_harlon_tinyui_node_UiNode$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.node/wang_harlon_tinyui_node_UiNode$stableprop_getter|wang_harlon_tinyui_node_UiNode$stableprop_getter(){}[0] final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_ComponentRegistry$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_ComponentRegistry$stableprop_getter|wang_harlon_tinyui_schema_ComponentRegistry$stableprop_getter(){}[0] final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_ComponentSchema$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_ComponentSchema$stableprop_getter|wang_harlon_tinyui_schema_ComponentSchema$stableprop_getter(){}[0] +final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_LayoutProps$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_LayoutProps$stableprop_getter|wang_harlon_tinyui_schema_LayoutProps$stableprop_getter(){}[0] final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec$stableprop_getter|wang_harlon_tinyui_schema_PropSpec$stableprop_getter(){}[0] final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Bool$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Bool$stableprop_getter|wang_harlon_tinyui_schema_PropSpec_Bool$stableprop_getter(){}[0] final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_ColorSpec$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_ColorSpec$stableprop_getter|wang_harlon_tinyui_schema_PropSpec_ColorSpec$stableprop_getter(){}[0] final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Dp$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Dp$stableprop_getter|wang_harlon_tinyui_schema_PropSpec_Dp$stableprop_getter(){}[0] final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Enum$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Enum$stableprop_getter|wang_harlon_tinyui_schema_PropSpec_Enum$stableprop_getter(){}[0] final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Num$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Num$stableprop_getter|wang_harlon_tinyui_schema_PropSpec_Num$stableprop_getter(){}[0] +final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Size$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Size$stableprop_getter|wang_harlon_tinyui_schema_PropSpec_Size$stableprop_getter(){}[0] final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Sp$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Sp$stableprop_getter|wang_harlon_tinyui_schema_PropSpec_Sp$stableprop_getter(){}[0] final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Str$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_PropSpec_Str$stableprop_getter|wang_harlon_tinyui_schema_PropSpec_Str$stableprop_getter(){}[0] -final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SchemaBuilder$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SchemaBuilder$stableprop_getter|wang_harlon_tinyui_schema_SchemaBuilder$stableprop_getter(){}[0] -final fun wang.harlon.tinyui/TinyUIPage(wang.harlon.tinyui/RuntimeBundle, kotlin/ByteArray, wang.harlon.tinyui.schema/ComponentRegistry, wang.harlon.tinyui/PageSink, kotlin/String?, androidx.compose.ui/Modifier?, kotlin/Function3?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // wang.harlon.tinyui/TinyUIPage|TinyUIPage(wang.harlon.tinyui.RuntimeBundle;kotlin.ByteArray;wang.harlon.tinyui.schema.ComponentRegistry;wang.harlon.tinyui.PageSink;kotlin.String?;androidx.compose.ui.Modifier?;kotlin.Function3?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] +final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Fill$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Fill$stableprop_getter|wang_harlon_tinyui_schema_SizeValue_Fill$stableprop_getter(){}[0] +final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Fixed$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Fixed$stableprop_getter|wang_harlon_tinyui_schema_SizeValue_Fixed$stableprop_getter(){}[0] +final fun wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Wrap$stableprop_getter(): kotlin/Int // wang.harlon.tinyui.schema/wang_harlon_tinyui_schema_SizeValue_Wrap$stableprop_getter|wang_harlon_tinyui_schema_SizeValue_Wrap$stableprop_getter(){}[0] +final fun wang.harlon.tinyui/TinyUIPage(wang.harlon.tinyui/RuntimeBundle, kotlin/ByteArray, wang.harlon.tinyui.schema/ComponentRegistry, wang.harlon.tinyui/PageSink, wang.harlon.tinyui/HostServices?, kotlin/String?, androidx.compose.ui/Modifier?, kotlin/Function3?, kotlin/Function1?, androidx.compose.runtime/Composer?, kotlin/Int, kotlin/Int) // wang.harlon.tinyui/TinyUIPage|TinyUIPage(wang.harlon.tinyui.RuntimeBundle;kotlin.ByteArray;wang.harlon.tinyui.schema.ComponentRegistry;wang.harlon.tinyui.PageSink;wang.harlon.tinyui.HostServices?;kotlin.String?;androidx.compose.ui.Modifier?;kotlin.Function3?;kotlin.Function1?;androidx.compose.runtime.Composer?;kotlin.Int;kotlin.Int){}[0] +final fun wang.harlon.tinyui/wang_harlon_tinyui_EventBus$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_EventBus$stableprop_getter|wang_harlon_tinyui_EventBus$stableprop_getter(){}[0] +final fun wang.harlon.tinyui/wang_harlon_tinyui_HostException$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_HostException$stableprop_getter|wang_harlon_tinyui_HostException$stableprop_getter(){}[0] +final fun wang.harlon.tinyui/wang_harlon_tinyui_HostServices$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_HostServices$stableprop_getter|wang_harlon_tinyui_HostServices$stableprop_getter(){}[0] +final fun wang.harlon.tinyui/wang_harlon_tinyui_HttpClient_None$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_HttpClient_None$stableprop_getter|wang_harlon_tinyui_HttpClient_None$stableprop_getter(){}[0] +final fun wang.harlon.tinyui/wang_harlon_tinyui_HttpRequest$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_HttpRequest$stableprop_getter|wang_harlon_tinyui_HttpRequest$stableprop_getter(){}[0] +final fun wang.harlon.tinyui/wang_harlon_tinyui_HttpResponse$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_HttpResponse$stableprop_getter|wang_harlon_tinyui_HttpResponse$stableprop_getter(){}[0] +final fun wang.harlon.tinyui/wang_harlon_tinyui_InMemoryStore$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_InMemoryStore$stableprop_getter|wang_harlon_tinyui_InMemoryStore$stableprop_getter(){}[0] +final fun wang.harlon.tinyui/wang_harlon_tinyui_Navigator_None$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_Navigator_None$stableprop_getter|wang_harlon_tinyui_Navigator_None$stableprop_getter(){}[0] final fun wang.harlon.tinyui/wang_harlon_tinyui_PageFailure$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_PageFailure$stableprop_getter|wang_harlon_tinyui_PageFailure$stableprop_getter(){}[0] final fun wang.harlon.tinyui/wang_harlon_tinyui_PageHost$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_PageHost$stableprop_getter|wang_harlon_tinyui_PageHost$stableprop_getter(){}[0] final fun wang.harlon.tinyui/wang_harlon_tinyui_RuntimeBundle$stableprop_getter(): kotlin/Int // wang.harlon.tinyui/wang_harlon_tinyui_RuntimeBundle$stableprop_getter|wang_harlon_tinyui_RuntimeBundle$stableprop_getter(){}[0] diff --git a/compose/src/androidMain/kotlin/wang/harlon/tinyui/Platform.android.kt b/compose/src/androidMain/kotlin/wang/harlon/tinyui/Platform.android.kt new file mode 100644 index 0000000..7e11383 --- /dev/null +++ b/compose/src/androidMain/kotlin/wang/harlon/tinyui/Platform.android.kt @@ -0,0 +1,9 @@ +package wang.harlon.tinyui + +import android.os.Build + +internal actual fun platformInfo(): Map = mapOf( + "os" to "android", + "osVersion" to Build.VERSION.RELEASE.orEmpty(), + "model" to Build.MODEL.orEmpty(), +) diff --git a/compose/src/androidMain/kotlin/wang/harlon/tinyui/schema/Placeholder.android.kt b/compose/src/androidMain/kotlin/wang/harlon/tinyui/schema/Placeholder.android.kt new file mode 100644 index 0000000..21acf3e --- /dev/null +++ b/compose/src/androidMain/kotlin/wang/harlon/tinyui/schema/Placeholder.android.kt @@ -0,0 +1,20 @@ +package wang.harlon.tinyui.schema + +import android.content.pm.ApplicationInfo +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp + +@Composable +internal actual fun Placeholder(scope: NodeScope) { + val debuggable = (LocalContext.current.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 + if (debuggable) { + Text("<${scope.node.type} #${scope.node.id}>", color = Color.Red, modifier = scope.modifier().border(1.dp, Color.Red).padding(4.dp)) + } else { + EmptyPlaceholder(scope) + } +} diff --git a/compose/src/commonMain/kotlin/wang/harlon/tinyui/HostServices.kt b/compose/src/commonMain/kotlin/wang/harlon/tinyui/HostServices.kt new file mode 100644 index 0000000..9408953 --- /dev/null +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/HostServices.kt @@ -0,0 +1,111 @@ +package wang.harlon.tinyui + +import kotlin.concurrent.atomics.AtomicReference +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.concurrent.atomics.update + +/** What the host App provides to pages (docs/native-api.md); every part has a default that does nothing. */ +class HostServices( + val navigator: Navigator = Navigator.None, + val store: Store = InMemoryStore(), + val http: HttpClient = HttpClient.None, + val i18n: I18n = I18n.None, + val config: Config = Config.None, + val events: EventBus = EventBus(), + /** Extra values merged into `device.info` (docs/native-api.md §1). */ + val deviceInfo: Map = emptyMap(), +) + +/** J4 `navigation.push` / `navigation.pop`; results travel back as K5 `navigation.result` (docs/app-model.md). */ +interface Navigator { + fun push(page: String, paramsJson: String) + fun pop(resultJson: String?) + + object None : Navigator { + override fun push(page: String, paramsJson: String) {} + override fun pop(resultJson: String?) {} + } +} + +/** App-wide key → JSON text; the truth for cross-page state (docs/app-model.md §3). */ +interface Store { + fun get(key: String): String? + fun set(key: String, json: String) + /** [listener] receives the new JSON; the returned handle removes it. */ + fun observe(key: String, listener: (String) -> Unit): AutoCloseable +} + +/** Copy-on-write: pages subscribe on their engine threads while the host writes from anywhere. */ +@OptIn(ExperimentalAtomicApi::class) +class InMemoryStore : Store { + private val values = AtomicReference>(emptyMap()) + private val listeners = Listeners() + + override fun get(key: String): String? = values.load()[key] + + override fun set(key: String, json: String) { + values.update { it + (key to json) } + listeners.notify(key, json) + } + + override fun observe(key: String, listener: (String) -> Unit): AutoCloseable = listeners.add(key, listener) +} + +class HttpRequest(val method: String, val url: String, val headers: Map, val bodyJson: String?, val timeoutMs: Long?) + +/** J3 `http.request`; [bodyJson] is JSON text or null. Throw [HostException] for E3 codes. */ +interface HttpClient { + suspend fun request(request: HttpRequest): HttpResponse + + object None : HttpClient { + override suspend fun request(request: HttpRequest): HttpResponse = throw HostException("E_UNSUPPORTED", "http is not available") + } +} + +class HttpResponse(val status: Int, val bodyJson: String) + +/** An E3 failure with one of the codes in docs/native-api.md §6. */ +class HostException(val code: String, message: String) : Exception(message) + +/** J2 `i18n.t`. */ +fun interface I18n { + fun translate(key: String, argsJson: String): String? + + companion object { + val None = I18n { _, _ -> null } + } +} + +/** J2 `config.get`, JSON text. */ +fun interface Config { + fun get(key: String): String? + + companion object { + val None = Config { null } + } +} + +/** One bus for host events and business events; pages subscribe per topic (docs/app-model.md §3). */ +class EventBus { + private val listeners = Listeners() + + fun emit(topic: String, payloadJson: String) = listeners.notify(topic, payloadJson) + + fun subscribe(topic: String, listener: (String) -> Unit): AutoCloseable = listeners.add(topic, listener) +} + +@OptIn(ExperimentalAtomicApi::class) +internal class Listeners { + private val byKey = AtomicReference Unit>>>(emptyMap()) + + fun add(key: String, listener: (String) -> Unit): AutoCloseable { + byKey.update { it + (key to (it[key].orEmpty() + listener)) } + return AutoCloseable { byKey.update { it + (key to (it[key].orEmpty() - listener)) } } + } + + fun notify(key: String, json: String) { + byKey.load()[key]?.forEach { it(json) } + } +} + +internal expect fun platformInfo(): Map diff --git a/compose/src/commonMain/kotlin/wang/harlon/tinyui/PageHost.kt b/compose/src/commonMain/kotlin/wang/harlon/tinyui/PageHost.kt index 039af71..02697a4 100644 --- a/compose/src/commonMain/kotlin/wang/harlon/tinyui/PageHost.kt +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/PageHost.kt @@ -1,24 +1,44 @@ package wang.harlon.tinyui +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.doubleOrNull import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put import wang.harlon.quickjs.JsEngine import wang.harlon.quickjs.JsEngineConfig import wang.harlon.quickjs.JsException @@ -26,12 +46,12 @@ import wang.harlon.quickjs.JsRef import wang.harlon.quickjs.JsRuntime import wang.harlon.quickjs.JsValue import wang.harlon.quickjs.ObjectTransport -import wang.harlon.tinyui.node.Command import wang.harlon.tinyui.node.NodeTree import wang.harlon.tinyui.node.PatchProblem import wang.harlon.tinyui.node.UiNode import wang.harlon.tinyui.schema.ComponentRegistry import wang.harlon.tinyui.schema.NodeScope +import wang.harlon.tinyui.schema.SizeValue /** Bytecode of the runtime modules every page imports, as `tinyui build` writes them under `runtime/`. */ class RuntimeBundle(val core: ByteArray, val native: ByteArray) @@ -55,7 +75,10 @@ class PageHost( private val page: ByteArray, val registry: ComponentRegistry, private val sink: PageSink, + private val services: HostServices = HostServices(), private val propsJson: String = "{}", + /** K entries longer than this are interrupted and fail the page (docs/native-api.md §6). */ + private val entryTimeoutMs: Long = 5_000, ) { val tree = NodeTree(registry, sink::patchProblem) var failure: PageFailure? by mutableStateOf(null) @@ -63,6 +86,7 @@ class PageHost( private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val timers = HashMap() + private val subscriptions = ArrayList() private val runtime = JsRuntime(JsEngineConfig(moduleScheme = MODULE_SCHEME, logger = sink::log)) private var entries: Entries? = null @@ -89,7 +113,7 @@ class PageHost( check(protocol == PROTOCOL) { "protocol $protocol from the runtime module, host implements $PROTOCOL" } namespace.use { ns -> (ns.get("default", ObjectTransport.REF) as JsRef).use { default -> - e.call("mount", default, JsValue.Str(propsJson), JsValue.Str(registry.manifest())) + e.call("mount", default, JsValue.Str(propsJson), JsValue.Str(registry.manifest(CAPABILITIES))) } } e.call("flush") @@ -103,19 +127,25 @@ class PageHost( fun dispatch(nodeId: Int, event: String, payload: String) = entry("dispatch", JsValue.Num(nodeId), JsValue.Str(event), JsValue.Str(payload)) fun visible(visible: Boolean) = entry("visible", JsValue.Bool(visible)) fun resolve(cbId: Int, resultJson: String) = entry("resolve", JsValue.Num(cbId), JsValue.Str(resultJson)) - fun reject(cbId: Int, errorJson: String) = entry("reject", JsValue.Num(cbId), JsValue.Str(errorJson)) + fun reject(cbId: Int, code: String, message: String) = + entry("reject", JsValue.Num(cbId), JsValue.Str(buildJsonObject { put("code", code); put("message", message) }.toString())) + /** K5. The host calls this for `navigation.result`; bus topics arrive through the page's own subscriptions. */ fun emit(topic: String, payloadJson: String) = entry("emit", JsValue.Str(topic), JsValue.Str(payloadJson)) private fun entry(name: String, vararg args: JsValue, before: () -> Unit = {}) { if (failure != null) return scope.launch { try { - runtime.withEngine { - before() - val e = entries ?: return@withEngine - e.call(name, *args) - e.call("flush") + withTimeout(entryTimeoutMs) { + runtime.withEngine { + before() + val e = entries ?: return@withEngine + e.call(name, *args) + e.call("flush") + } } + } catch (t: TimeoutCancellationException) { + fail("E6", IllegalStateException("K entry $name exceeded ${entryTimeoutMs} ms and was interrupted")) } catch (t: Throwable) { fail("E2", t) } @@ -125,6 +155,8 @@ class PageHost( fun close() { scope.cancel() // pending entries and timers are its children tree.clear() + subscriptions.forEach { runCatching { it.close() } } + subscriptions.clear() // not a child of `scope`: cancelling the page must not cancel its own teardown CoroutineScope(Dispatchers.Default).launch { try { @@ -167,54 +199,112 @@ class PageHost( } JsValue.Undefined } - engine.registerFunction("__host_query") { _ -> JsValue.Str("null") } + engine.registerFunction("__host_query") { args -> JsValue.Str(query((args[0] as JsValue.Str).value, (args[1] as JsValue.Str).value)) } engine.registerFunction("__host_call") { args -> - val name = (args[0] as JsValue.Str).value - val cbId = (args[1] as JsValue.Num).value.toInt() - val argsJson = (args[2] as JsValue.Str).value - when (name) { - "timer.schedule" -> schedule(cbId, argsJson) - else -> scope.launch { reject(cbId, """{"code":"E_UNSUPPORTED","message":"$name is not available"}""") } - } + call((args[0] as JsValue.Str).value, (args[1] as JsValue.Num).value.toInt(), (args[2] as JsValue.Str).value) JsValue.Undefined } engine.registerFunction("__host_send") { args -> - val name = (args[0] as JsValue.Str).value - val argsJson = (args[1] as JsValue.Str).value - if (name == "timer.cancel") { - val cbId = runCatching { Json.parseToJsonElement(argsJson).jsonObject["cbId"]?.jsonPrimitive?.intOrNull }.getOrNull() - if (cbId != null) timers.remove(cbId)?.cancel() - } + send((args[0] as JsValue.Str).value, (args[1] as JsValue.Str).value) JsValue.Undefined } } + /** J2 whitelist (docs/native-api.md §1); anything else answers null. */ + private fun query(name: String, argsJson: String): String { + val a = args(argsJson) + return when (name) { + "device.info" -> JsonObject((platformInfo() + services.deviceInfo).mapValues { JsonPrimitive(it.value) }).toString() + "i18n.t" -> services.i18n.translate(a.str("key") ?: return "null", a["args"]?.toString() ?: "{}")?.let { JsonPrimitive(it).toString() } ?: "null" + "config.get" -> a.str("key")?.let(services.config::get) ?: "null" + "store.get" -> a.str("key")?.let(services.store::get) ?: "null" + else -> "null" + } + } + + /** J3 (docs/native-api.md §5); results come back through [resolve] / [reject] as one transaction each. */ + private fun call(name: String, cbId: Int, argsJson: String) { + val a = args(argsJson) + when (name) { + "timer.schedule" -> schedule(cbId, a) + "http.request" -> scope.launch { + try { + val request = HttpRequest( + method = a.str("method") ?: "GET", + url = a.str("url") ?: throw HostException("E_INVALID", "http.request needs url"), + headers = (a["headers"] as? JsonObject)?.mapValues { it.value.jsonPrimitive.content } ?: emptyMap(), + bodyJson = a["body"]?.toString(), + timeoutMs = a["timeout"]?.jsonPrimitive?.longOrNull, + ) + val response = services.http.request(request) + resolve(cbId, buildJsonObject { put("status", response.status); put("body", Json.parseToJsonElement(response.bodyJson)) }.toString()) + } catch (e: HostException) { + reject(cbId, e.code, e.message ?: "") + } catch (e: Exception) { + reject(cbId, "E_NET", e.message ?: e.toString()) + } + } + else -> scope.launch { reject(cbId, "E_UNSUPPORTED", "$name is not available") } + } + } + + /** J4 (docs/native-api.md §2–§4). */ + private fun send(name: String, argsJson: String) { + val a = args(argsJson) + when (name) { + "timer.cancel" -> a["cbId"]?.jsonPrimitive?.intOrNull?.let { timers.remove(it)?.cancel() } + "navigation.push" -> services.navigator.push(a.str("page") ?: return, a["params"]?.toString() ?: "{}") + "navigation.pop" -> services.navigator.pop(a["result"]?.toString()) + "store.set" -> { val key = a.str("key") ?: return; services.store.set(key, a.str("value") ?: "null") } + "store.subscribe" -> { val key = a.str("key") ?: return; subscriptions += services.store.observe(key) { json -> emit("store:$key", """{"value":$json}""") } } + "events.emit" -> services.events.emit(a.str("topic") ?: return, a["payload"]?.toString() ?: "{}") + "events.subscribe" -> { val topic = a.str("topic") ?: return; subscriptions += services.events.subscribe(topic) { json -> emit(topic, json) } } + else -> sink.log("unknown J4 $name") + } + } + /** * The host side of `setTimeout` (J3 `timer.schedule` → K3 `resolve`); dies with the page. - * [timers] is only touched while holding the runtime lock: here and in `__host_send` (host functions + * [timers] is only touched while holding the runtime lock: here and in `timer.cancel` (host functions * run inside the calling entry) and in the `before` step of the firing entry. */ - private fun schedule(cbId: Int, argsJson: String) { - val ms = runCatching { Json.parseToJsonElement(argsJson).jsonObject["ms"]?.jsonPrimitive?.doubleOrNull }.getOrNull() ?: 0.0 + private fun schedule(cbId: Int, a: JsonObject) { + val ms = a["ms"]?.jsonPrimitive?.doubleOrNull ?: 0.0 timers[cbId] = scope.launch { delay(ms.toLong().coerceAtLeast(0)) entry("resolve", JsValue.Num(cbId), JsValue.Str(""), before = { timers.remove(cbId) }) } } + private fun args(json: String): JsonObject = runCatching { Json.parseToJsonElement(json).jsonObject }.getOrDefault(JsonObject(emptyMap())) + private fun JsonObject.str(key: String): String? = this[key]?.jsonPrimitive?.contentOrNull + /** The scope a component renders through. */ internal inner class Scope(override val node: UiNode) : NodeScope { @Suppress("UNCHECKED_CAST") - override fun get(key: String): T? = (node.props[key] ?: registry.schema(node.type)?.props?.get(key)?.default) as T? + override fun get(key: String): T? = (node.props[key] ?: registry.schema(node.type)?.prop(key)?.default) as T? override fun has(event: String): Boolean = node.events[event] == true override fun dispatch(event: String, payload: String) = this@PageHost.dispatch(node.id, event, payload) - override fun takeCommands(): List = node.commands.toList().also { node.commands.clear() } - override fun modifier(): Modifier = Modifier + + /** docs/components.md §2: size → clip → background → gesture → padding. */ + override fun modifier(): Modifier { + var m: Modifier = Modifier + when (val w = get("width")) { is SizeValue.Fixed -> m = m.width(w.dp); SizeValue.Fill -> m = m.fillMaxWidth(); SizeValue.Wrap -> m = m.wrapContentWidth(); null -> {} } + when (val h = get("height")) { is SizeValue.Fixed -> m = m.height(h.dp); SizeValue.Fill -> m = m.fillMaxHeight(); SizeValue.Wrap -> m = m.wrapContentHeight(); null -> {} } + get("cornerRadius")?.let { m = m.clip(RoundedCornerShape(it)) } + get("background")?.let { m = m.background(it) } + if (has("onClick")) m = m.clickable { dispatch("onClick") } + get("padding")?.let { m = m.padding(it) } + return m + } @Composable override fun Children() { for (child in node.children) key(child.id) { Render(child) } } + + @Composable + override fun RenderChild(child: UiNode) = Render(child) } @Composable @@ -228,5 +318,6 @@ class PageHost( /** docs/patch-protocol.md §6 */ const val PROTOCOL = 1 private val ENTRY_NAMES = listOf("mount", "unmount", "visible", "dispatch", "resolve", "reject", "emit", "flush") + private val CAPABILITIES = listOf("device.info", "i18n.t", "config.get", "store.get", "store.set", "navigation.push", "navigation.pop", "events.emit", "http.request", "timer.schedule") } } diff --git a/compose/src/commonMain/kotlin/wang/harlon/tinyui/TinyUIPage.kt b/compose/src/commonMain/kotlin/wang/harlon/tinyui/TinyUIPage.kt index 19c182b..72f5a76 100644 --- a/compose/src/commonMain/kotlin/wang/harlon/tinyui/TinyUIPage.kt +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/TinyUIPage.kt @@ -9,19 +9,25 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import wang.harlon.tinyui.schema.ComponentRegistry -/** Renders one TinyUI page; the host owns navigation and passes the page's bytecode (docs/app-model.md). */ +/** + * Renders one TinyUI page; the host owns navigation and passes the page's bytecode (docs/app-model.md). + * To deliver a navigation result to this page, keep the [PageHost] (see [onHost]) and call [PageHost.emit]. + */ @Composable fun TinyUIPage( runtime: RuntimeBundle, page: ByteArray, registry: ComponentRegistry, sink: PageSink, + services: HostServices = HostServices(), propsJson: String = "{}", modifier: Modifier = Modifier, error: @Composable (PageFailure) -> Unit = { Text("TinyUI page failed (${it.kind}): ${it.message}") }, + onHost: (PageHost) -> Unit = {}, ) { - val host = remember(runtime, page, registry, sink, propsJson) { PageHost(runtime, page, registry, sink, propsJson) } + val host = remember(runtime, page, registry, sink, services, propsJson) { PageHost(runtime, page, registry, sink, services, propsJson) } DisposableEffect(host) { + onHost(host) host.start() onDispose { host.close() } } diff --git a/compose/src/commonMain/kotlin/wang/harlon/tinyui/components/Builtins.kt b/compose/src/commonMain/kotlin/wang/harlon/tinyui/components/Builtins.kt index c5029f7..22112e7 100644 --- a/compose/src/commonMain/kotlin/wang/harlon/tinyui/components/Builtins.kt +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/components/Builtins.kt @@ -1,37 +1,185 @@ package wang.harlon.tinyui.components +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.sp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import wang.harlon.tinyui.components.generated.BuiltinSchemas +import wang.harlon.tinyui.schema.Commands import wang.harlon.tinyui.schema.ComponentRegistry +import wang.harlon.tinyui.schema.NodeScope -/** First batch of built-ins (roadmap C); the JSX types in packages/core mirror these schemas by hand for M1. */ +/** First batch of built-ins (docs/components.md §3); schemas come from schema/ via `tinyui schema`. */ fun ComponentRegistry.registerBuiltins(): ComponentRegistry = apply { - register("Column", {}) { scope -> Column(scope.modifier()) { scope.Children() } } - register("Row", {}) { scope -> Row(scope.modifier()) { scope.Children() } } - register("Text", { - string("text", default = "") - color("color", default = Color.Unspecified) - sp("fontSize", default = TextUnit.Unspecified) - }) { scope -> + register(BuiltinSchemas.Column) { scope -> + Column( + modifier = scope.modifier(), + verticalArrangement = verticalArrangement(scope["justify"], scope["gap"]), + horizontalAlignment = when (scope.get("align")) { "center" -> Alignment.CenterHorizontally; "end" -> Alignment.End; else -> Alignment.Start }, + ) { scope.Children() } + } + register(BuiltinSchemas.Row) { scope -> + Row( + modifier = scope.modifier(), + horizontalArrangement = horizontalArrangement(scope["justify"], scope["gap"]), + verticalAlignment = when (scope.get("align")) { "center" -> Alignment.CenterVertically; "end" -> Alignment.Bottom; else -> Alignment.Top }, + ) { scope.Children() } + } + register(BuiltinSchemas.Box) { scope -> + Box(modifier = scope.modifier(), contentAlignment = boxAlignment(scope["align"])) { scope.Children() } + } + register(BuiltinSchemas.Text) { scope -> + val maxLines = scope.get("maxLines")?.toInt()?.takeIf { it > 0 } ?: Int.MAX_VALUE Text( text = scope["text"] ?: "", color = scope["color"] ?: Color.Unspecified, fontSize = scope["fontSize"] ?: TextUnit.Unspecified, + fontWeight = when (scope.get("fontWeight")) { "medium" -> FontWeight.Medium; "bold" -> FontWeight.Bold; else -> FontWeight.Normal }, + maxLines = maxLines, + overflow = if (maxLines == Int.MAX_VALUE) TextOverflow.Clip else TextOverflow.Ellipsis, + textAlign = when (scope.get("align")) { "center" -> TextAlign.Center; "end" -> TextAlign.End; else -> TextAlign.Start }, modifier = scope.modifier(), ) } - register("Button", { - string("text", default = "") - event("onClick") - }) { scope -> - Button(onClick = { if (scope.has("onClick")) scope.dispatch("onClick") }, modifier = scope.modifier()) { - Text(scope["text"] ?: "") + register(BuiltinSchemas.Button) { scope -> + val onClick = { if (scope.has("onClick")) scope.dispatch("onClick") } + val enabled = scope.get("enabled") ?: true + val label: @Composable () -> Unit = { Text(scope["text"] ?: "") } + when (scope.get("variant")) { + "outlined" -> OutlinedButton(onClick = onClick, enabled = enabled, modifier = scope.modifier(), content = { label() }) + "text" -> TextButton(onClick = onClick, enabled = enabled, modifier = scope.modifier(), content = { label() }) + else -> Button(onClick = onClick, enabled = enabled, modifier = scope.modifier(), content = { label() }) + } + } + register(BuiltinSchemas.TextField) { scope -> TextFieldComponent(scope) } + register(BuiltinSchemas.LazyColumn) { scope -> LazyColumnComponent(scope) } + register(BuiltinSchemas.Spacer) { scope -> Spacer(scope.modifier()) } +} + +private fun verticalArrangement(justify: String?, gap: Dp?): Arrangement.Vertical { + val spaced = gap != null && gap > 0.dp + return when (justify) { + "center" -> if (spaced) Arrangement.spacedBy(gap!!, Alignment.CenterVertically) else Arrangement.Center + "end" -> if (spaced) Arrangement.spacedBy(gap!!, Alignment.Bottom) else Arrangement.Bottom + "spaceBetween" -> Arrangement.SpaceBetween + else -> if (spaced) Arrangement.spacedBy(gap!!) else Arrangement.Top + } +} + +private fun horizontalArrangement(justify: String?, gap: Dp?): Arrangement.Horizontal { + val spaced = gap != null && gap > 0.dp + return when (justify) { + "center" -> if (spaced) Arrangement.spacedBy(gap!!, Alignment.CenterHorizontally) else Arrangement.Center + "end" -> if (spaced) Arrangement.spacedBy(gap!!, Alignment.End) else Arrangement.End + "spaceBetween" -> Arrangement.SpaceBetween + else -> if (spaced) Arrangement.spacedBy(gap!!) else Arrangement.Start + } +} + +private fun boxAlignment(name: String?): Alignment = when (name) { + "topCenter" -> Alignment.TopCenter; "topEnd" -> Alignment.TopEnd + "centerStart" -> Alignment.CenterStart; "center" -> Alignment.Center; "centerEnd" -> Alignment.CenterEnd + "bottomStart" -> Alignment.BottomStart; "bottomCenter" -> Alignment.BottomCenter; "bottomEnd" -> Alignment.BottomEnd + else -> Alignment.TopStart +} + +/** Text and cursor stay here; JS gets `onChange` / `onCommit` and sends `setText` / `focus` / `blur` (docs/adr-004 §3.3). */ +@Composable +private fun TextFieldComponent(scope: NodeScope) { + var text by remember { mutableStateOf(scope.get("initialText") ?: "") } + val focusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current + val commit = { if (scope.has("onCommit")) scope.dispatch("onCommit", payload("text", text)) } + scope.Commands { command -> + when (command.name) { + "setText" -> { text = command.args["text"] as? String ?: ""; if (scope.has("onChange")) scope.dispatch("onChange", payload("text", text)) } + "focus" -> focusRequester.requestFocus() + "blur" -> focusManager.clearFocus() } } + val keyboard = when (scope.get("keyboard")) { + "number" -> KeyboardType.Number; "email" -> KeyboardType.Email; "phone" -> KeyboardType.Phone; "password" -> KeyboardType.Password + else -> KeyboardType.Text + } + OutlinedTextField( + value = text, + onValueChange = { text = it; if (scope.has("onChange")) scope.dispatch("onChange", payload("text", it)) }, + placeholder = scope.get("placeholder")?.takeIf { it.isNotEmpty() }?.let { { Text(it) } }, + singleLine = scope.get("singleLine") ?: true, + keyboardOptions = KeyboardOptions(keyboardType = keyboard, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { commit() }), + visualTransformation = if (keyboard == KeyboardType.Password) PasswordVisualTransformation() else VisualTransformation.None, + modifier = scope.modifier().focusRequester(focusRequester).onFocusChanged { if (!it.isFocused) commit() }, + ) +} + +/** Rows are the node's children; virtualisation only in composition (docs/adr-003 §3.6). */ +@Composable +private fun LazyColumnComponent(scope: NodeScope) { + val state = rememberLazyListState() + val node = scope.node + scope.Commands { command -> + if (command.name == "scrollTo") state.animateScrollToItem(((command.args["index"] as? Double) ?: 0.0).toInt().coerceIn(0, maxOf(0, node.children.size - 1))) + } + if (scope.has("onReachEnd")) { + // once per arrival at the last row; arms again only after the list grows or the user scrolls away (docs/native-api.md §6) + LaunchedEffect(state, node) { + var armedFor = -1 + snapshotFlow { Triple(state.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1, node.children.size, state.isScrollInProgress) } + .filter { (last, size, _) -> size > 0 && last >= size - 1 } + .distinctUntilChanged() + .collect { (_, size, _) -> if (armedFor != size) { armedFor = size; scope.dispatch("onReachEnd") } } + } + } + if (scope.has("onScrollEnd")) { + LaunchedEffect(state) { + snapshotFlow { state.isScrollInProgress }.distinctUntilChanged().filter { !it } + .collect { scope.dispatch("onScrollEnd", buildJsonObject { put("index", state.firstVisibleItemIndex) }.toString()) } + } + } + val gap = scope.get("gap") ?: 0.dp + LazyColumn(state = state, modifier = scope.modifier(), verticalArrangement = if (gap > 0.dp) Arrangement.spacedBy(gap) else Arrangement.Top) { + items(node.children, key = { it.id }) { child -> key(child.id) { scope.RenderChild(child) } } + } } +private fun payload(key: String, value: String) = buildJsonObject { put(key, value) }.toString() diff --git a/compose/src/commonMain/kotlin/wang/harlon/tinyui/components/generated/BuiltinSchemas.kt b/compose/src/commonMain/kotlin/wang/harlon/tinyui/components/generated/BuiltinSchemas.kt new file mode 100644 index 0000000..5b629f5 --- /dev/null +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/components/generated/BuiltinSchemas.kt @@ -0,0 +1,140 @@ +// Generated by `tinyui schema` from schema/; do not edit. +package wang.harlon.tinyui.components.generated + +import wang.harlon.tinyui.schema.ComponentSchema +import wang.harlon.tinyui.schema.FieldSpec +import wang.harlon.tinyui.schema.PropSpec + +object BuiltinSchemas { + val Column = ComponentSchema( + type = "Column", + props = mapOf( + "gap" to PropSpec.Dp(default = 0.0, required = false, initial = false), + "align" to PropSpec.Enum(values = setOf("start", "center", "end"), default = "start", required = false, initial = false), + "justify" to PropSpec.Enum(values = setOf("start", "center", "end", "spaceBetween"), default = "start", required = false, initial = false), + ), + events = mapOf( + "onClick" to emptyMap(), + ), + commands = mapOf( + ), + children = true, + layout = true, + ) + + val Row = ComponentSchema( + type = "Row", + props = mapOf( + "gap" to PropSpec.Dp(default = 0.0, required = false, initial = false), + "align" to PropSpec.Enum(values = setOf("start", "center", "end"), default = "start", required = false, initial = false), + "justify" to PropSpec.Enum(values = setOf("start", "center", "end", "spaceBetween"), default = "start", required = false, initial = false), + ), + events = mapOf( + "onClick" to emptyMap(), + ), + commands = mapOf( + ), + children = true, + layout = true, + ) + + val Box = ComponentSchema( + type = "Box", + props = mapOf( + "align" to PropSpec.Enum(values = setOf("topStart", "topCenter", "topEnd", "centerStart", "center", "centerEnd", "bottomStart", "bottomCenter", "bottomEnd"), default = "topStart", required = false, initial = false), + ), + events = mapOf( + "onClick" to emptyMap(), + ), + commands = mapOf( + ), + children = true, + layout = true, + ) + + val Text = ComponentSchema( + type = "Text", + props = mapOf( + "text" to PropSpec.Str(default = null, required = true, initial = false), + "color" to PropSpec.ColorSpec(default = null, required = false, initial = false), + "fontSize" to PropSpec.Sp(default = null, required = false, initial = false), + "fontWeight" to PropSpec.Enum(values = setOf("normal", "medium", "bold"), default = "normal", required = false, initial = false), + "maxLines" to PropSpec.Num(default = 0.0, required = false, initial = false), + "align" to PropSpec.Enum(values = setOf("start", "center", "end"), default = "start", required = false, initial = false), + ), + events = mapOf( + "onClick" to emptyMap(), + ), + commands = mapOf( + ), + children = false, + layout = true, + ) + + val Button = ComponentSchema( + type = "Button", + props = mapOf( + "text" to PropSpec.Str(default = null, required = true, initial = false), + "enabled" to PropSpec.Bool(default = true, required = false, initial = false), + "variant" to PropSpec.Enum(values = setOf("filled", "outlined", "text"), default = "filled", required = false, initial = false), + ), + events = mapOf( + "onClick" to emptyMap(), + ), + commands = mapOf( + ), + children = false, + layout = true, + ) + + val TextField = ComponentSchema( + type = "TextField", + props = mapOf( + "initialText" to PropSpec.Str(default = "", required = false, initial = true), + "placeholder" to PropSpec.Str(default = "", required = false, initial = false), + "singleLine" to PropSpec.Bool(default = true, required = false, initial = false), + "keyboard" to PropSpec.Enum(values = setOf("text", "number", "email", "phone", "password"), default = "text", required = false, initial = false), + ), + events = mapOf( + "onChange" to mapOf("text" to FieldSpec.Str), + "onCommit" to mapOf("text" to FieldSpec.Str), + ), + commands = mapOf( + "setText" to mapOf("text" to FieldSpec.Str), + "focus" to emptyMap(), + "blur" to emptyMap(), + ), + children = false, + layout = true, + ) + + val LazyColumn = ComponentSchema( + type = "LazyColumn", + props = mapOf( + "gap" to PropSpec.Dp(default = 0.0, required = false, initial = false), + ), + events = mapOf( + "onReachEnd" to emptyMap(), + "onScrollEnd" to mapOf("index" to FieldSpec.Num), + ), + commands = mapOf( + "scrollTo" to mapOf("index" to FieldSpec.Num), + ), + children = true, + layout = true, + ) + + val Spacer = ComponentSchema( + type = "Spacer", + props = mapOf( + ), + events = mapOf( + ), + commands = mapOf( + ), + children = false, + layout = true, + ) + + val all: List = listOf(Column, Row, Box, Text, Button, TextField, LazyColumn, Spacer) +} diff --git a/compose/src/commonMain/kotlin/wang/harlon/tinyui/node/NodeTree.kt b/compose/src/commonMain/kotlin/wang/harlon/tinyui/node/NodeTree.kt index 0252100..09e3822 100644 --- a/compose/src/commonMain/kotlin/wang/harlon/tinyui/node/NodeTree.kt +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/node/NodeTree.kt @@ -28,6 +28,7 @@ class PatchProblem(val op: String, val reason: String) { class NodeTree(private val registry: ComponentRegistry, private val report: (PatchProblem) -> Unit) { val root = UiNode(ROOT_ID, "root") private val nodes = HashMap().apply { put(ROOT_ID, root) } + private val born = ArrayList() val size: Int get() = nodes.size @@ -43,6 +44,8 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat } Snapshot.withMutableSnapshot { for (op in ops) applyOp(op) + for (node in born) node.created = true + born.clear() } } @@ -72,7 +75,7 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat if (nodes.containsKey(id)) return report(PatchProblem(op.toString(), "id already exists")) val known = registry.schema(type) != null if (!known) report(PatchProblem(op.toString(), "unknown component type, rendering Placeholder")) - nodes[id] = UiNode(id, if (known) type else ComponentRegistry.PLACEHOLDER) + nodes[id] = UiNode(id, if (known) type else ComponentRegistry.PLACEHOLDER).also { born += it } } private fun setProp(f: JsonArray, op: JsonElement) { @@ -86,7 +89,8 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat if (flag) node.events[key] = true else node.events.remove(key) return } - val spec = schema.props[key] ?: return report(PatchProblem(op.toString(), "prop not in schema of ${node.type}")) + val spec = schema.prop(key) ?: return report(PatchProblem(op.toString(), "prop not in schema of ${node.type}")) + if (spec.initial && node.created) return report(PatchProblem(op.toString(), "$key is an initial prop of ${node.type}: writes after creation are ignored")) if (value is JsonNull) { node.props[key] = spec.default return @@ -140,8 +144,14 @@ class NodeTree(private val registry: ComponentRegistry, private val report: (Pat val node = child(f, op, at = 1) ?: return val name = f.string(2) ?: return report(PatchProblem(op.toString(), "x without name")) val schema = registry.schema(node.type) ?: return - if (name !in schema.commands) return report(PatchProblem(op.toString(), "command not in schema of ${node.type}")) - val args = (f.getOrNull(3) as? JsonObject)?.mapValues { (_, v) -> (v as? JsonPrimitive)?.scalar() } ?: emptyMap() + val fields = schema.commands[name] ?: return report(PatchProblem(op.toString(), "command not in schema of ${node.type}")) + val given = f.getOrNull(3) as? JsonObject ?: JsonObject(emptyMap()) + val args = HashMap() + for ((field, spec) in fields) { + val v = given[field] as? JsonPrimitive ?: return report(PatchProblem(op.toString(), "command $name needs $field")) + if (!spec.accepts(v)) return report(PatchProblem(op.toString(), "command $name: $field must be $spec")) + args[field] = v.scalar() + } node.commands.add(Command(name, args)) } diff --git a/compose/src/commonMain/kotlin/wang/harlon/tinyui/node/UiNode.kt b/compose/src/commonMain/kotlin/wang/harlon/tinyui/node/UiNode.kt index 0b35a3d..57dc8d0 100644 --- a/compose/src/commonMain/kotlin/wang/harlon/tinyui/node/UiNode.kt +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/node/UiNode.kt @@ -15,6 +15,8 @@ class UiNode internal constructor(val id: Int, val type: String) { /** One-shot commands waiting for the component to consume them (`x` op). */ val commands: SnapshotStateList = mutableStateListOf() internal var parent: UiNode? = null + /** Set once the creating flush is applied: `initial` props stop accepting writes. */ + internal var created = false } class Command(val name: String, val args: Map) diff --git a/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/ComponentRegistry.kt b/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/ComponentRegistry.kt index 2f56a4e..35ec0ae 100644 --- a/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/ComponentRegistry.kt +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/ComponentRegistry.kt @@ -10,7 +10,7 @@ import kotlinx.serialization.json.put /** * App-level, immutable once handed to a page (docs/adr-003 §3.3). Built-in types use bare names, - * host extensions a prefix such as `pp.KycCard`. + * host extensions a prefix such as `pp.KycCard`; both are declared in a schema DSL and generated. */ class ComponentRegistry { private class Entry(val schema: ComponentSchema, val component: Component) @@ -18,12 +18,12 @@ class ComponentRegistry { private val entries = LinkedHashMap() init { - register(PLACEHOLDER, {}) { Box(it.modifier()) } + register(ComponentSchema(PLACEHOLDER, emptyMap(), emptyMap(), emptyMap(), children = false, layout = true)) { Placeholder(it) } } - fun register(type: String, schema: SchemaBuilder.() -> Unit, component: Component) { - require(type !in entries) { "component $type already registered" } - entries[type] = Entry(SchemaBuilder().apply(schema).build(), component) + fun register(schema: ComponentSchema, component: Component) { + require(schema.type !in entries) { "component ${schema.type} already registered" } + entries[schema.type] = Entry(schema, component) } fun schema(type: String): ComponentSchema? = entries[type]?.schema @@ -34,12 +34,12 @@ class ComponentRegistry { } /** The manifest handed to JS at mount (`hostJson`, docs/runtime-api.md §9). */ - fun manifest(capabilities: List = emptyList()): String = buildJsonObject { + fun manifest(capabilities: List): String = buildJsonObject { put("components", JsonObject(entries.filterKeys { it != PLACEHOLDER }.mapValues { (_, e) -> buildJsonObject { - put("props", JsonArray(e.schema.props.keys.map(::JsonPrimitive))) - put("events", JsonArray(e.schema.events.map(::JsonPrimitive))) - put("commands", JsonArray(e.schema.commands.map(::JsonPrimitive))) + put("props", JsonArray((e.schema.props.keys + if (e.schema.layout) LayoutProps.specs.keys else emptySet()).map(::JsonPrimitive))) + put("events", JsonArray(e.schema.events.keys.map(::JsonPrimitive))) + put("commands", JsonArray(e.schema.commands.keys.map(::JsonPrimitive))) } })) put("capabilities", JsonArray(capabilities.map(::JsonPrimitive))) @@ -49,3 +49,12 @@ class ComponentRegistry { const val PLACEHOLDER = "Placeholder" } } + +/** Unknown component type: takes no space in release, shows the type in debug (docs/components.md §5). */ +@Composable +internal expect fun Placeholder(scope: NodeScope) + +@Composable +internal fun EmptyPlaceholder(scope: NodeScope) { + Box(scope.modifier()) +} diff --git a/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/ComponentSchema.kt b/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/ComponentSchema.kt index 6366108..6a45f1b 100644 --- a/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/ComponentSchema.kt +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/ComponentSchema.kt @@ -10,37 +10,80 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.doubleOrNull -/** How one prop crosses the bridge: its JSON scalar, the typed value it becomes, and the default `null` restores. */ -sealed class PropSpec(val kind: String, val default: Any?) { +/** `width` / `height`: a length in dp, or fill / wrap the parent. */ +sealed interface SizeValue { + data class Fixed(val dp: Dp) : SizeValue + data object Fill : SizeValue + data object Wrap : SizeValue +} + +/** + * How one prop crosses the bridge: its JSON scalar, the typed value it becomes, and the default `null` restores. + * Instances are generated from schema/ (docs/components.md). + */ +sealed class PropSpec(val kind: String, val required: Boolean, val initial: Boolean) { + /** Typed default; `null` when the schema has none. */ + abstract val default: Any? abstract fun convert(value: JsonPrimitive): Any? - class Str(default: String?) : PropSpec("string", default) { + class Str(default: String?, required: Boolean = false, initial: Boolean = false) : PropSpec("string", required, initial) { + override val default: String? = default override fun convert(value: JsonPrimitive): Any? = value.takeIf { it.isString }?.content } - class Num(default: Double?) : PropSpec("number", default) { + class Num(default: Double?, required: Boolean = false, initial: Boolean = false) : PropSpec("number", required, initial) { + override val default: Double? = default override fun convert(value: JsonPrimitive): Any? = value.takeUnless { it.isString }?.doubleOrNull } - class Bool(default: Boolean?) : PropSpec("boolean", default) { + class Bool(default: Boolean?, required: Boolean = false, initial: Boolean = false) : PropSpec("boolean", required, initial) { + override val default: Boolean? = default override fun convert(value: JsonPrimitive): Any? = value.takeUnless { it.isString }?.booleanOrNull } - class Dp(default: androidx.compose.ui.unit.Dp?) : PropSpec("dp", default) { + class Dp(default: Double?, required: Boolean = false, initial: Boolean = false) : PropSpec("dp", required, initial) { + override val default: androidx.compose.ui.unit.Dp? = default?.dp override fun convert(value: JsonPrimitive): Any? = value.takeUnless { it.isString }?.doubleOrNull?.dp } - class Sp(default: TextUnit?) : PropSpec("sp", default) { + class Sp(default: Double?, required: Boolean = false, initial: Boolean = false) : PropSpec("sp", required, initial) { + override val default: TextUnit? = default?.sp override fun convert(value: JsonPrimitive): Any? = value.takeUnless { it.isString }?.doubleOrNull?.sp } /** `#RRGGBB` or `#AARRGGBB`. */ - class ColorSpec(default: Color?) : PropSpec("color", default) { + class ColorSpec(default: String?, required: Boolean = false, initial: Boolean = false) : PropSpec("color", required, initial) { + override val default: Color? = default?.let(::parseColor) override fun convert(value: JsonPrimitive): Any? = value.takeIf { it.isString }?.content?.let(::parseColor) } - class Enum>(default: T?, private val values: Map) : PropSpec("enum", default) { - override fun convert(value: JsonPrimitive): Any? = value.takeIf { it.isString }?.content?.let { values[it] } + /** Stored as the enum name; the composable maps it. */ + class Enum(val values: Set, default: String?, required: Boolean = false, initial: Boolean = false) : PropSpec("enum", required, initial) { + override val default: String? = default + override fun convert(value: JsonPrimitive): Any? = value.takeIf { it.isString }?.content?.takeIf { it in values } + } + + class Size(default: String?, required: Boolean = false, initial: Boolean = false) : PropSpec("size", required, initial) { + override val default: SizeValue? = default?.let(::parseSize) + override fun convert(value: JsonPrimitive): Any? = + if (value.isString) parseSize(value.content) else value.doubleOrNull?.let { SizeValue.Fixed(it.dp) } + + private fun parseSize(text: String): SizeValue? = when (text) { + "fill" -> SizeValue.Fill + "wrap" -> SizeValue.Wrap + else -> text.toDoubleOrNull()?.let { SizeValue.Fixed(it.dp) } + } + } +} + +/** A field of an event payload or command args (flat, scalar). */ +enum class FieldSpec { + Str, Num, Bool; + + fun accepts(value: JsonPrimitive): Boolean = when (this) { + Str -> value.isString + Num -> !value.isString && value.doubleOrNull != null + Bool -> !value.isString && value.booleanOrNull != null } } @@ -55,29 +98,28 @@ internal fun parseColor(text: String): Color? { return Color(argb.toULong().toLong().toInt()) } -class ComponentSchema internal constructor( +class ComponentSchema( + val type: String, val props: Map, - val events: Set, - val commands: Set, -) - -class SchemaBuilder internal constructor() { - private val props = LinkedHashMap() - private val events = LinkedHashSet() - private val commands = LinkedHashSet() - - fun string(name: String, default: String? = null) { props[name] = PropSpec.Str(default) } - fun number(name: String, default: Double? = null) { props[name] = PropSpec.Num(default) } - fun boolean(name: String, default: Boolean? = null) { props[name] = PropSpec.Bool(default) } - fun dp(name: String, default: Dp? = null) { props[name] = PropSpec.Dp(default) } - fun sp(name: String, default: TextUnit? = null) { props[name] = PropSpec.Sp(default) } - fun color(name: String, default: Color? = null) { props[name] = PropSpec.ColorSpec(default) } - inline fun > enum(name: String, default: T? = null) = enum(name, default, enumValues().associateBy { it.name }) - fun > enum(name: String, default: T?, values: Map) { props[name] = PropSpec.Enum(default, values) } - fun event(name: String) { events += name } - fun command(name: String) { commands += name } - - internal fun build() = ComponentSchema(props, events, commands) + val events: Map>, + val commands: Map>, + val children: Boolean, + /** Accepts [LayoutProps]. */ + val layout: Boolean, +) { + /** Own props plus the common layout props when [layout] is on. */ + fun prop(key: String): PropSpec? = props[key] ?: if (layout) LayoutProps.specs[key] else null +} + +/** The common layout props; the order here is the Modifier order (docs/components.md §2). */ +object LayoutProps { + val specs: Map = linkedMapOf( + "width" to PropSpec.Size(default = null), + "height" to PropSpec.Size(default = null), + "cornerRadius" to PropSpec.Dp(default = null), + "background" to PropSpec.ColorSpec(default = null), + "padding" to PropSpec.Dp(default = null), + ) } /** A registered component renders one node through its [NodeScope]. */ diff --git a/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/NodeScope.kt b/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/NodeScope.kt index dc21502..056975e 100644 --- a/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/NodeScope.kt +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/NodeScope.kt @@ -1,6 +1,8 @@ package wang.harlon.tinyui.schema import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import wang.harlon.tinyui.node.Command import wang.harlon.tinyui.node.UiNode @@ -9,21 +11,35 @@ import wang.harlon.tinyui.node.UiNode interface NodeScope { val node: UiNode - /** A prop declared in the schema, already converted; `null` when neither JS nor the schema set it. */ + /** A prop declared in the schema, already converted; the schema default when JS never set it. */ operator fun get(key: String): T? /** Whether JS registered a handler for [event]; components attach gestures only when true. */ fun has(event: String): Boolean - /** K2: fire and forget, on any thread. */ + /** K2: fire and forget, on any thread. [payload] is the flat JSON object the event schema declares. */ fun dispatch(event: String, payload: String = "{}") - /** Takes the queued commands for this node, oldest first. */ - fun takeCommands(): List - - /** Layout modifier from the common props (empty in M1). */ + /** Layout modifier from the common props, with the click gesture when `onClick` is registered. */ fun modifier(): Modifier @Composable fun Children() + + /** Renders one child; for components that lay children out themselves (LazyColumn). */ + @Composable + fun RenderChild(child: UiNode) +} + +/** Consumes this node's commands as they arrive, oldest first; one-shot, after composition (docs/adr-004 §3.2). */ +@Composable +fun NodeScope.Commands(handler: suspend (Command) -> Unit) { + val node = node + LaunchedEffect(node) { + snapshotFlow { node.commands.toList() }.collect { pending -> + if (pending.isEmpty()) return@collect + node.commands.removeAll(pending) + for (c in pending) handler(c) + } + } } diff --git a/compose/src/commonTest/kotlin/wang/harlon/tinyui/PageHostTest.kt b/compose/src/commonTest/kotlin/wang/harlon/tinyui/PageHostTest.kt index e61003c..748a45b 100644 --- a/compose/src/commonTest/kotlin/wang/harlon/tinyui/PageHostTest.kt +++ b/compose/src/commonTest/kotlin/wang/harlon/tinyui/PageHostTest.kt @@ -1,6 +1,7 @@ package wang.harlon.tinyui import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withContext @@ -33,13 +34,19 @@ class PageHostTest { ["c",2,"Button"],["p",2,"text","+1"],["p",2,"onClick",true],["c",3,"Column"],["i",3,1,0],["i",3,2,1],["i",0,3,0]); handler = () => { count++; patches.push(["p",1,"text","count " + count]); }; }, - unmount() {}, visible() {}, emit() {}, - resolve(cbId) { patches.push(["p",1,"text","timer " + cbId]); }, reject() {}, - dispatch(id, event) { + unmount() {}, visible() {}, + resolve(cbId, json) { patches.push(["p",1,"text", cbId === 9 ? "http " + json : "timer " + cbId]); }, + reject(cbId, json) { patches.push(["p",1,"text","rejected " + json]); }, + dispatch(id, event, payloadJson) { if (id === 2 && event === "onClick") handler(); if (event === "onTimer") __host_call("timer.schedule", 7, JSON.stringify({ ms: 10 })); if (event === "onBoom") { try { throw new Error("boom"); } catch (e) { __host_report("E1", JSON.stringify({ entry: "dispatch", message: e.message, stack: e.stack })); } } + if (event === "onQuery") patches.push(["p",1,"text", __host_query("store.get", JSON.stringify({ key: "cart" })) + "|" + __host_query("i18n.t", JSON.stringify({ key: "hi" })) + "|" + JSON.parse(__host_query("device.info", "{}")).os]); + if (event === "onHttp") __host_call("http.request", 9, JSON.stringify({ method: "GET", url: "/todos" })); + if (event === "onSubscribe") { __host_send("store.subscribe", JSON.stringify({ key: "cart" })); __host_send("events.subscribe", JSON.stringify({ topic: "net" })); patches.push(["p",1,"text","subscribed"]); } + if (event === "onSet") __host_send("store.set", JSON.stringify({ key: "cart", value: payloadJson })); }, + emit(topic, json) { patches.push(["p",1,"text", topic + " " + json]); }, flush() { if (count > 2) throw new Error("render exploded"); if (patches.length) { __host_apply(JSON.stringify(patches)); patches = []; } }, }; export const VERSION = "stub"; @@ -47,16 +54,31 @@ class PageHostTest { private val native = module("@tiny-ui/native", "export const NATIVE = 1;") private val page = module("pages/counter", "export default function Counter() { return 'page'; }") - private fun host(props: String = "{}") = PageHost(RuntimeBundle(core, native), page, ComponentRegistry().registerBuiltins(), sink, props) + private val store = InMemoryStore().apply { set("cart", """{"n":2}""") } + private val services = HostServices( + store = store, + i18n = I18n { key, _ -> "hello-$key" }, + deviceInfo = mapOf("app" to "test"), + http = object : HttpClient { + override suspend fun request(request: HttpRequest): HttpResponse = + if (request.url == "/todos") HttpResponse(200, """[{"id":1}]""") else throw HostException("E_HTTP", "404") + }, + ) + + private fun host(props: String = "{}") = PageHost(RuntimeBundle(core, native), page, ComponentRegistry().registerBuiltins(), sink, services, props) // runTest's virtual time never advances the real engine thread: wait on a real dispatcher private suspend fun PageHost.await(check: () -> Boolean) = withContext(Dispatchers.Default) { - withTimeout(5_000) { - while (!check()) { - failure?.let { if (!check()) error("page failed: ${it.message}\n${log.joinToString("\n")}") } - delay(10) + try { + withTimeout(5_000) { + while (!check()) { + failure?.let { if (!check()) error("page failed: ${it.message}\n${log.joinToString("\n")}") } + delay(10) + } } + } catch (e: TimeoutCancellationException) { + error("timed out; text=${tree.node(1)?.props?.get("text")} log=${log.joinToString(" | ")}") } } @@ -65,7 +87,7 @@ class PageHostTest { val host = host("""{"name":"tinyui"}""") host.start() host.await { host.tree.root.children.isNotEmpty() } - assertEquals("hello tinyui / 4", host.tree.node(1)!!.props["text"]) + assertEquals("hello tinyui / 8", host.tree.node(1)!!.props["text"]) assertNull(host.failure) host.close() } @@ -95,6 +117,26 @@ class PageHostTest { host.close() } + @Test + fun hostServicesAnswerJ2J3J4AndK5() = runTest { + val host = host() + host.start() + host.await { host.tree.root.children.isNotEmpty() } + val text = { host.tree.node(1)!!.props["text"] as? String } + host.dispatch(2, "onQuery", "{}") + host.await { text() == """{"n":2}|"hello-hi"|${platformInfo()["os"]}""" } + host.dispatch(2, "onHttp", "{}") + host.await { text() == """http {"status":200,"body":[{"id":1}]}""" } + host.dispatch(2, "onSubscribe", "{}") + host.await { text() == "subscribed" } + services.events.emit("net", """{"online":false}""") + host.await { text() == """net {"online":false}""" } + host.dispatch(2, "onSet", """{"n":3}""") + host.await { text() == """store:cart {"value":{"n":3}}""" } + assertEquals("""{"n":3}""", store.get("cart")) + host.close() + } + @Test fun renderErrorsFailThePage() = runTest { val host = host() diff --git a/compose/src/commonTest/kotlin/wang/harlon/tinyui/node/NodeTreeTest.kt b/compose/src/commonTest/kotlin/wang/harlon/tinyui/node/NodeTreeTest.kt index 0d3db01..18ae582 100644 --- a/compose/src/commonTest/kotlin/wang/harlon/tinyui/node/NodeTreeTest.kt +++ b/compose/src/commonTest/kotlin/wang/harlon/tinyui/node/NodeTreeTest.kt @@ -1,6 +1,7 @@ package wang.harlon.tinyui.node import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import wang.harlon.tinyui.components.registerBuiltins import wang.harlon.tinyui.schema.ComponentRegistry @@ -32,7 +33,7 @@ class NodeTreeTest { assertEquals(Color(0xFFFF0000), node.props["color"]) assertEquals(18.sp, node.props["fontSize"]) tree.apply("""[["p",1,"color",null]]""") - assertEquals(Color.Unspecified, node.props["color"]) + assertNull(node.props["color"], "null restores the schema default, which Text.color does not have") assertTrue(problems.isEmpty(), problems.toString()) } @@ -63,6 +64,31 @@ class NodeTreeTest { assertEquals(listOf(2), ids(tree.node(1)!!), "a rejected move leaves the list untouched") } + @Test + fun initialPropsOnlyCountAtCreationAndCommandArgsAreValidated() { + tree.apply("""[["c",1,"TextField"],["p",1,"initialText","a"],["i",0,1,0]]""") + assertEquals("a", tree.node(1)!!.props["initialText"]) + tree.apply("""[["p",1,"initialText","b"],["p",1,"placeholder","hint"]]""") + assertEquals("a", tree.node(1)!!.props["initialText"], "later writes to an initial prop are ignored") + assertEquals("hint", tree.node(1)!!.props["placeholder"]) + assertEquals(1, problems.size); assertTrue("initial prop" in problems.single().reason) + problems.clear() + tree.apply("""[["x",1,"setText",{"text":"z"}],["x",1,"setText",{}],["x",1,"setText",{"text":3}],["x",1,"focus",{}]]""") + assertEquals(listOf("setText", "focus"), tree.node(1)!!.commands.map { it.name }) + assertEquals("z", tree.node(1)!!.commands[0].args["text"]) + assertEquals(2, problems.size) + } + + @Test + fun layoutPropsApplyToEveryLayoutComponent() { + tree.apply("""[["c",1,"Spacer"],["p",1,"width","fill"],["p",1,"height",12],["p",1,"padding",4],["i",0,1,0]]""") + val node = tree.node(1)!! + assertEquals(wang.harlon.tinyui.schema.SizeValue.Fill, node.props["width"]) + assertEquals(wang.harlon.tinyui.schema.SizeValue.Fixed(12.dp), node.props["height"]) + assertEquals(4.dp, node.props["padding"]) + assertTrue(problems.isEmpty(), problems.toString()) + } + @Test fun badOpsAreSkippedAndReported() { tree.apply("""[["c",1,"Text"],["p",1,"nope","x"],["p",1,"fontSize","big"],["p",1,"onTap",true],["p",99,"text","x"],["c",2,"pp.Unknown"],["i",0,2,7],["x",1,"focus",{}],["i",0,1,0]]""") diff --git a/compose/src/iosMain/kotlin/wang/harlon/tinyui/Platform.ios.kt b/compose/src/iosMain/kotlin/wang/harlon/tinyui/Platform.ios.kt new file mode 100644 index 0000000..289757d --- /dev/null +++ b/compose/src/iosMain/kotlin/wang/harlon/tinyui/Platform.ios.kt @@ -0,0 +1,9 @@ +package wang.harlon.tinyui + +import platform.UIKit.UIDevice + +internal actual fun platformInfo(): Map = mapOf( + "os" to "ios", + "osVersion" to UIDevice.currentDevice.systemVersion, + "model" to UIDevice.currentDevice.model, +) diff --git a/compose/src/iosMain/kotlin/wang/harlon/tinyui/schema/Placeholder.ios.kt b/compose/src/iosMain/kotlin/wang/harlon/tinyui/schema/Placeholder.ios.kt new file mode 100644 index 0000000..3051e31 --- /dev/null +++ b/compose/src/iosMain/kotlin/wang/harlon/tinyui/schema/Placeholder.ios.kt @@ -0,0 +1,20 @@ +package wang.harlon.tinyui.schema + +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import kotlin.native.Platform +import kotlin.experimental.ExperimentalNativeApi + +@OptIn(ExperimentalNativeApi::class) +@Composable +internal actual fun Placeholder(scope: NodeScope) { + if (Platform.isDebugBinary) { + Text("<${scope.node.type} #${scope.node.id}>", color = Color.Red, modifier = scope.modifier().border(1.dp, Color.Red).padding(4.dp)) + } else { + EmptyPlaceholder(scope) + } +} diff --git a/docs/README.md b/docs/README.md index 933479d..8c7b010 100644 --- a/docs/README.md +++ b/docs/README.md @@ -71,7 +71,7 @@ tinyui/ │ ├── native/ @tiny-ui/native 业务调用的宿主能力 API(http / storage / toast / navigation / i18n …,即 ADR-002 的 J2 / J3) │ └── cli/ @tiny-ui/cli 构建工具:TSX → h()(ES2025)→ 每页一个 ESM 模块字节码,`tinyui build` ├── compose/ wang.harlon:tinyui KMP 库(Compose Multiplatform 侧):节点表、注册表、桥、内置组件;依赖 quickjs-kmp -├── schema/ 内置组件 schema 的唯一真值 → 生成 packages/core 的 .d.ts 与 compose/ 的注册代码 +├── schema/ 内置组件 schema 的唯一真值(TS DSL)→ `pnpm schema` 生成 packages/core 的类型与 compose/ 的注册 schema ├── sample/ 示例 App,M1 Counter / M2 列表页在这里跑 │ ├── js/ 页面源码(pnpm workspace 成员),shared 的 Gradle 任务调 CLI 编成字节码打进 Compose 资源 │ ├── shared/ KMP 模块:App() 与 iOS 入口,出静态 framework(AGP 9 不允许 application 与 KMP 插件同模块) @@ -105,6 +105,8 @@ tinyui/ | [runtime-api.md](./runtime-api.md) | `@tiny-ui/core` v1 的 API 定义、页面模块契约、桥入口 | 已定(2026-09-16) | | [jsx-transform.md](./jsx-transform.md) | CLI 的 JSX 变换:thunk 包裹规则、编译期报错、source map | 已定(2026-09-16) | | [patch-protocol.md](./patch-protocol.md) | patch 协议 v1:六种 op、id、值、顺序保证、版本、E5 | 已定(2026-09-16) | +| [components.md](./components.md) | 内置组件:schema 真值与生成链、公共布局 prop 与 Modifier 顺序、首批八个组件、命令消费、Placeholder | 已定(2026-09-16) | +| [native-api.md](./native-api.md) | `@tiny-ui/native` 与 `HostServices`:J2 白名单、navigation / store / events / http、E3 错误码与阈值 | 已定(2026-09-16) | ## 下一阶段 diff --git a/docs/components.md b/docs/components.md new file mode 100644 index 0000000..5e97258 --- /dev/null +++ b/docs/components.md @@ -0,0 +1,75 @@ +# 内置组件:schema、布局 prop、首批清单 + +- 状态:已定(2026-09-16);M2 的实现依据 +- 来源:ADR-003 §3.3 / §3.4(注册表、schema、公共布局 prop)、ADR-004(事件三分、命令、文本框);roadmap C 组 +- 相关:[runtime-api.md](./runtime-api.md) §3(`h()` 怎么处理 prop)、[patch-protocol.md](./patch-protocol.md) §4(值类型) + +## 1. schema 是唯一真值 + +组件的 prop / 事件 / 命令声明只写一处:顶层 `schema/` 目录,TS 写的声明式 DSL,一个组件一个文件,`schema/index.ts` 汇总。`pnpm schema`(即 `tinyui schema`)执行它,生成两份产物并提交进仓,CI 用 `pnpm schema:check` 保证没有手改: + +| 产物 | 内容 | 谁用 | +|---|---|---| +| `packages/core/src/generated/components.ts` | `XxxProps`(JSX 属性类型)、`XxxYyyPayload`(事件 payload)、`XxxCommands`(命令参数表,配 `ref()`)、`IntrinsicElements`、组件名常量 | 业务的类型检查 | +| `compose/.../components/generated/BuiltinSchemas.kt` | 每个组件一个 `ComponentSchema`,prop 的类型 / 默认值 / `required` / `initial`,事件与命令的字段表 | Kotlin 注册表:写入时转换、E5 校验、清单下发 | + +Kotlin 侧的 composable 仍手写,`register(BuiltinSchemas.Text) { scope -> … }`。宿主扩展组件(`pp.KycCard`)用同一套 DSL(`import … from "@tiny-ui/cli/schema"`)生成到宿主自己的包里,本仓不含。 + +DSL: + +```ts +defineComponent("TextField", { + props: { + initialText: string({ initial: true, default: "" }), + keyboard: enumOf(["text", "number", "email", "phone", "password"], { default: "text" }), + }, + events: { onChange: { text: field.string() } }, + commands: { setText: { text: field.string() }, focus: {} }, + children: false, // 是否接受 children + layout: true, // 是否接受公共布局 prop,默认 true +}); +``` + +prop 类型:`string` / `number` / `boolean` / `dp` / `sp` / `color`(`#RRGGBB` / `#AARRGGBB`)/ `enumOf([...])` / `size`(数字 dp,或 `"fill"` / `"wrap"`)。选项:`required`、`default`、`initial`(只认创建时的值,之后的写入按 E5 跳过并上报)、`doc`。事件 payload 与命令参数的字段只有 `field.string / number / boolean`,扁平。 + +## 2. 公共布局 prop 与 Modifier 顺序 + +`layout: true` 的组件都接受这五个,运行时按下面的顺序合成 `Modifier`,从外到内: + +| 顺序 | prop | 类型 | Modifier | +|---|---|---|---| +| 1 | `width` / `height` | size | `width(dp)` / `fillMaxWidth()` / `wrapContentWidth()` | +| 2 | `cornerRadius` | dp | `clip(RoundedCornerShape)` | +| 3 | `background` | color | `background(color)` | +| 4 | (有 `onClick` handler 时) | | `clickable { dispatch("onClick") }` | +| 5 | `padding` | dp,四边同值 | `padding(dp)` | + +顺序固定:以后加 prop 只能插进这个序列,不能重排(重排会改变已有页面的视觉)。`padding` 在最里面,所以它是内容内边距;背景和点击区域包含它。 + +不在首批的:`margin`(Compose 没有对应,用父容器的 `gap` / `padding`)、`weight`、`alignSelf`(要父作用域;`Row` / `Column` 里给 child 写 `width="fill"` 会吃掉后面的兄弟,需要按比例分配时等 `weight`)。 + +## 3. 首批八个组件 + +`onClick` 所有容器和 `Text` 都有。事件的 payload 字段、命令的参数字段见 `schema/components/*.ts`,这里只列名字。 + +| 组件 | prop | 事件 | 命令 | children | +|---|---|---|---|---| +| `Column` / `Row` | `gap`、`align`(交叉轴 start / center / end)、`justify`(主轴 start / center / end / spaceBetween) | `onClick` | | 是 | +| `Box` | `align`(九宫格) | `onClick` | | 是 | +| `Text` | `text`(必填)、`color`、`fontSize`、`fontWeight`(normal / medium / bold)、`maxLines`(0 = 不限,超出省略号)、`align` | `onClick` | | | +| `Button` | `text`(必填)、`enabled`、`variant`(filled / outlined / text) | `onClick` | | | +| `TextField` | `initialText`(initial)、`placeholder`、`singleLine`、`keyboard` | `onChange{text}`、`onCommit{text}`(IME Done 或失焦) | `setText{text}`、`focus`、`blur` | | +| `LazyColumn` | `gap` | `onReachEnd`、`onScrollEnd{index}` | `scrollTo{index}` | 是,通常是一个 `` | +| `Spacer` | (只有布局 prop) | | | | + +`TextField` 的文本与光标永远在 Kotlin 侧(ADR-004 §3.3):`setText` 会同时触发 `onChange`。`LazyColumn` 的行就是它的 children,虚拟化只在组合层(ADR-003 §3.6)。 + +**`Image` 推迟**:牵出图片加载管线的选型(coil3 还是宿主提供 loader),M2 用不到,单独一次定。 + +## 4. 命令怎么消费 + +命令进节点的 `commands` 队列(不是 props),组件用 `scope.Commands { command -> … }` 在组合后按到达顺序消费,一次性。`LazyColumn` 的 `scrollTo` 与新增行同一 flush 到达时,行先进 children,命令随后执行,index 已经有效。 + +## 5. `Placeholder` + +未注册的类型渲染 `Placeholder`,同时 E5 上报。debug 构建(Android `FLAG_DEBUGGABLE`、iOS debug binary)画红框加类型名与 id;release 渲染零尺寸的空 `Box`。 diff --git a/docs/native-api.md b/docs/native-api.md new file mode 100644 index 0000000..5736b4f --- /dev/null +++ b/docs/native-api.md @@ -0,0 +1,80 @@ +# `@tiny-ui/native` 与宿主服务 + +- 状态:已定(2026-09-16);M2 的实现依据 +- 来源:ADR-002 §3.1(J2 白名单、J3 / J4)、[app-model.md](./app-model.md)(路由、store、事件)、ADR-004 §3.1(连续事件阈值) +- 两侧:JS 的 `@tiny-ui/native` 是 `__host_*` 的类型化封装;Kotlin 的 `HostServices` 是宿主 App 实现的接口组,交给 `TinyUIPage` + +## 1. J2 白名单 + +四个,框架固定,业务不能加;同步返回 JSON 文本,没有就是 `null`: + +| 名字 | 宿主实现 | 返回 | +|---|---|---| +| `device.info` | 框架自带(`os`、`osVersion`、`model`)+ `HostServices.deviceInfo` 追加 | 对象 | +| `i18n.t` | `HostServices.i18n`(`I18n.translate(key, argsJson)`) | 字符串 | +| `config.get` | `HostServices.config`(`Config.get(key)`,JSON 文本) | 任意 | +| `store.get` | `HostServices.store` | 任意 | + +实现必须在调用线程立即返回、不做 IO(ADR-002 §3.1 场景 3)。 + +## 2. `navigation` + +```ts +navigation.push("pages/detail", { id: 42 }); // J4 → Navigator.push(page, paramsJson) +navigation.pop({ saved: true }); // J4 → Navigator.pop(resultJson) +navigation.onResult((result, from) => …); // 渲染期调用;K5 topic navigation.result +``` + +宿主实现 `Navigator` 接口;把结果送回上一页时,宿主持有该页的 `PageHost`(`TinyUIPage` 的 `onHost` 回调拿到),调 `host.emit("navigation.result", json)`,`json` 形如 `{ "result": …, "from": "pages/detail" }`。v1 不提供 `push` 返回 Promise 的糖。 + +## 3. `store` + +```ts +store.get(key) // J2,快照 +store.set(key, value) // J4,value 会被 JSON.stringify +const cart = store.watch(key) // 渲染期调用,返回 accessor;K5 topic store: 到达时更新 +``` + +真值在 Kotlin `Store`(`get` / `set` / `observe`),默认 `InMemoryStore`;持久化由宿主实现决定。`watch` 会经 J4 `store.subscribe` 登记,只有登记过的页面收到该 key 的变更。 + +## 4. `events` + +```ts +events.emit("order.paid", { id }) // J4 → EventBus.emit +events.on("network", (p) => …) // 渲染期调用;J4 events.subscribe + K5 +``` + +`HostServices.events` 是 App 级 `EventBus`,宿主原生代码用同一个实例 `emit` 网络状态、主题等。订阅随页面 owner 结束,页面关闭时 Kotlin 侧的监听一并注销。 + +## 5. `http` + +```ts +const { status, body } = await http.get("/todos", { timeout: 5000 }); +await http.post("/todos", { title }, { headers: { "X-Trace": id } }); +``` + +J3 `http.request`,参数 `{ method, url, headers?, body?, timeout? }`;宿主实现 `HttpClient.request(HttpRequest): HttpResponse`(suspend,`bodyJson` 为 JSON 文本)。响应 `{ status, body }`,`body` 是解析后的 JSON。宿主抛 `HostException(code, message)` 走 E3,其他异常按 `E_NET`。 + +`setTimeout` / `clearTimeout` 是 core 提供的全局,经 J3 `timer.schedule` / J4 `timer.cancel`,宿主侧由 `PageHost` 实现,随页面关闭取消。 + +## 6. E3 错误码与阈值 + +| code | 含义 | +|---|---| +| `E_UNSUPPORTED` | 能力不存在(宿主没实现,或 J3 名字未知) | +| `E_INVALID` | 参数不合法 | +| `E_NET` | 网络层失败,或宿主实现抛了非 `HostException` 的异常 | +| `E_HTTP` | 非 2xx,宿主在 message 里给 status | +| `E_TIMEOUT` | 超时 | +| `E_DENIED` | 权限拒绝 | +| `E_CANCELLED` | 页面关闭或宿主取消 | +| `E_BAD_JSON` | 载荷不是合法 JSON(JS 侧生成) | + +阈值(可调,写在代码常量里): + +| 项 | 值 | 位置 | +|---|---|---| +| K 入口超时 | 5 s,超时 interrupt 判 E6 页面失败 | `PageHost.entryTimeoutMs` | +| `onReachEnd` | 最后一行进入可见区时触发一次;列表长度变化后再武装 | `LazyColumnComponent` | +| `onScrollEnd` | 滚动停止时触发,带首个可见行 index | 同上 | +| `onProgress` 类节流 | 1 s | 首批组件没有,写在这里备查 | diff --git a/docs/roadmap.md b/docs/roadmap.md index 865f0c6..98d3019 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -29,13 +29,13 @@ | JS 运行时 API:`signal` / `memo` / `effect` / `onCleanup` / `For` / `Show` / `ref` + `cmd` / **`createResource`**;"组件函数必须同步"规则;J3 / K3 用原生 Promise | ADR-001 / 004 / 005 | 已定(2026-09-16,[js-runtime.html](./js-runtime.html) + [runtime-api.md](./runtime-api.md)) | | CLI 的 JSX 变换:自动 thunk(Solid 式 getter 包裹)、source map 输出 | ADR-005 | 已定(2026-09-16,[jsx-transform.md](./jsx-transform.md)) | | patch 协议:六种 op、JSON 形态、是否带协议版本号 | ADR-001 / 002 / 004 | 已定(2026-09-16,[patch-protocol.md](./patch-protocol.md)) | -| schema DSL 形态 + 由 schema 生成 TS 类型定义的工具链 | ADR-003 | 待开 | -| 公共布局 prop 清单与 `Modifier` 合成顺序 | ADR-003 | 待开 | -| 内置组件集首批(`Column` / `Row` / `Box` / `Text` / `Image` / `Button` / `TextField` / `LazyColumn` / `Spacer`)及各自 prop / 事件 / 命令清单 | ADR-003 / 004 | 待开 | -| J2 白名单清单与注册方式 | ADR-002 | 待开 | -| `@tiny-ui/native` 的 `navigation` / `store` / `events` API 面与 `Navigator` 接口([app-model.md](./app-model.md));`push` 的 Promise 糖做不做 | app-model.md | 待开 | -| E3 错误 code 表、K 入口超时阈值、连续事件节流阈值 | ADR-002 / 004 | 待开 | -| `Placeholder` 在 release 的表现 | ADR-003 | 待开 | +| schema DSL 形态 + 由 schema 生成 TS 类型定义的工具链 | ADR-003 | 已定([components.md](./components.md) §1,`tinyui schema`) | +| 公共布局 prop 清单与 `Modifier` 合成顺序 | ADR-003 | 已定(components.md §2) | +| 内置组件集首批(`Column` / `Row` / `Box` / `Text` / `Image` / `Button` / `TextField` / `LazyColumn` / `Spacer`)及各自 prop / 事件 / 命令清单 | ADR-003 / 004 | 已定(components.md §3);`Image` 推迟到图片加载管线选型 | +| J2 白名单清单与注册方式 | ADR-002 | 已定([native-api.md](./native-api.md) §1) | +| `@tiny-ui/native` 的 `navigation` / `store` / `events` API 面与 `Navigator` 接口([app-model.md](./app-model.md));`push` 的 Promise 糖做不做 | app-model.md | 已定(native-api.md §2–§5;Promise 糖不做) | +| E3 错误 code 表、K 入口超时阈值、连续事件节流阈值 | ADR-002 / 004 | 已定(native-api.md §6) | +| `Placeholder` 在 release 的表现 | ADR-003 | 已定(components.md §5:零尺寸空 Box) | ## D. 明确推迟(记录不做,触发条件写清) @@ -53,6 +53,8 @@ | 返回栈深页引擎回收策略 | ADR-002 | 真机内存数据出来后定,可能提前 | | 应用级服务 Runtime(不挂 UI、随 App 生命周期的引擎) | app-model.md | 业务出现不属于任何页面的常驻 JS 逻辑 | | 手势组合内置组件 | ADR-004 | 业务需求出现 | +| `Image` 与图片加载管线(coil3 vs 宿主 loader) | components.md §3 | 出现需要图片的页面 | +| `weight` / `alignSelf` 等需要父作用域的布局 prop | components.md §2 | `Row` / `Column` 里按比例分配的需求 | ## 建议顺序与里程碑 @@ -60,4 +62,4 @@ 2. **B 前三项并行**——半天到一天的实验,结果决定 C 里几个数值和 ADR-003 的退路 3. **C 前三项定稿**(运行时 API、patch 协议、schema DSL)——两侧代码的契约,定了才能分头写 4. ~~**M1:Counter 端到端**——一个引擎、一页、一个 `Text` + 一个 `Button`,J1 / K1 / K2 全链路跑通,验证 ADR-001~004 主干~~ 已完成(PR #3,2026-09-16):`@tiny-ui/core` 运行时(30 测试)、CLI JSX 变换(19 测试)、compose 节点表 / schema DSL / 注册表 / `PageHost` / `TinyUIPage`(9 测试),Counter 在 Android 与 iOS 模拟器上跑通。顺带修了 quickjs-kmp 的字节码注册入口不刷新栈顶的 bug(quickjs-kmp PR #12),**真机需要 quickjs-kmp ≥ 0.1.1**,发版后 bump catalog -5. **C 剩余项 + 内置组件集,M2:列表页**——`For` / `LazyColumn` / `TextField` / J3 网络,覆盖所有权、命令、流式输入 +5. ~~**C 剩余项 + 内置组件集,M2:列表页**——`For` / `LazyColumn` / `TextField` / J3 网络,覆盖所有权、命令、流式输入~~ 已完成(PR #4,2026-09-16):schema 生成链、八个内置组件、`HostServices`、`@tiny-ui/native`;todos 页在 Android 与 iOS 模拟器上跑通输入提交、行增删改、命令滚动、分页 diff --git a/package.json b/package.json index 1545447..34ecda2 100644 --- a/package.json +++ b/package.json @@ -2,11 +2,15 @@ "name": "tinyui-monorepo", "private": true, "packageManager": "pnpm@10.20.0", - "engines": { "node": ">=22" }, + "engines": { + "node": ">=22" + }, "scripts": { "build": "pnpm -r build", "test": "pnpm -r test", - "typecheck": "pnpm -r typecheck" + "typecheck": "pnpm -r typecheck", + "schema": "node packages/cli/dist/bin.js schema --entry schema/index.ts --ts packages/core/src/generated/components.ts --kt compose/src/commonMain/kotlin/wang/harlon/tinyui/components/generated/BuiltinSchemas.kt --package wang.harlon.tinyui.components.generated", + "schema:check": "node packages/cli/dist/bin.js schema --entry schema/index.ts --ts packages/core/src/generated/components.ts --kt compose/src/commonMain/kotlin/wang/harlon/tinyui/components/generated/BuiltinSchemas.kt --package wang.harlon.tinyui.components.generated --check" }, "devDependencies": { "typescript": "7.0.2" diff --git a/packages/cli/package.json b/packages/cli/package.json index f827bbe..be16d07 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -8,6 +8,10 @@ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" + }, + "./schema": { + "types": "./dist/schema/index.d.ts", + "default": "./dist/schema/index.js" } }, "files": [ diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index e37a56a..be3be9e 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -1,13 +1,25 @@ #!/usr/bin/env node import { parseArgs } from "node:util"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; import { build } from "./build.ts"; +import { generateKt, generateTs } from "./schema/generate.ts"; +import { loadSchema } from "./schema/load.ts"; const USAGE = `usage: tinyui build [--root ] [--out ] [--qjsc ] [--js-only] + tinyui schema --entry [--ts ] [--kt --package [--object ]] [--check] +build --root project root, pages under /src/pages (default: cwd) --out output directory (default: /dist) --qjsc path to qjsc-kmp (default: $TINYUI_QJSC, then PATH) --js-only emit ESM sources and source maps only, skip bytecode + +schema + --entry TS module whose default export is the component list + --ts write JSX prop types here + --kt write Kotlin schemas here (with --package, optional --object, default BuiltinSchemas) + --check exit 1 if a target is not already up to date, write nothing `; async function main(argv: string[]): Promise { @@ -19,13 +31,20 @@ async function main(argv: string[]): Promise { out: { type: "string" }, qjsc: { type: "string" }, "js-only": { type: "boolean", default: false }, + entry: { type: "string" }, + ts: { type: "string" }, + kt: { type: "string" }, + package: { type: "string" }, + object: { type: "string", default: "BuiltinSchemas" }, + check: { type: "boolean", default: false }, help: { type: "boolean", short: "h", default: false }, }, }); - if (values.help || positionals[0] !== "build") { + if (values.help || (positionals[0] !== "build" && positionals[0] !== "schema")) { process.stdout.write(USAGE); return values.help ? 0 : 2; } + if (positionals[0] === "schema") return schema(values); const result = await build({ root: values.root ?? process.cwd(), ...(values.out !== undefined && { out: values.out }), @@ -38,6 +57,29 @@ async function main(argv: string[]): Promise { return 0; } +async function schema(v: { entry?: string; ts?: string; kt?: string; package?: string; object: string; check: boolean }): Promise { + if (!v.entry) throw new Error("schema: --entry is required"); + if (v.kt && !v.package) throw new Error("schema: --kt needs --package"); + const components = await loadSchema(v.entry); + const targets: { file: string; content: string }[] = []; + if (v.ts) targets.push({ file: v.ts, content: generateTs(components) }); + if (v.kt) targets.push({ file: v.kt, content: generateKt(components, v.package!, v.object) }); + let stale = 0; + for (const t of targets) { + const current = await readFile(t.file, "utf8").catch(() => null); + if (current === t.content) continue; + stale++; + if (v.check) { + process.stderr.write(`tinyui: ${t.file} is out of date; run tinyui schema\n`); + } else { + await mkdir(dirname(t.file), { recursive: true }); + await writeFile(t.file, t.content); + process.stdout.write(`${t.file}\n`); + } + } + return v.check && stale > 0 ? 1 : 0; +} + main(process.argv.slice(2)).then( (code) => process.exit(code), (e: unknown) => { diff --git a/packages/cli/src/schema/dsl.ts b/packages/cli/src/schema/dsl.ts new file mode 100644 index 0000000..88eb189 --- /dev/null +++ b/packages/cli/src/schema/dsl.ts @@ -0,0 +1,101 @@ +// Component schema DSL: the single source of truth for built-in and host components (docs/components.md). +// `tinyui schema` turns definitions into JSX prop types (TS) and registration schemas (Kotlin). + +export type PropKind = "string" | "number" | "boolean" | "dp" | "sp" | "color" | "enum" | "size"; +export type FieldKind = "string" | "number" | "boolean"; + +export interface PropDef { + kind: PropKind; + required?: boolean; + /** Only the value present at creation counts; later writes are ignored (docs/adr-004 §3.3). */ + initial?: boolean; + default?: string | number | boolean; + values?: readonly string[]; + doc?: string; +} + +export interface FieldDef { + kind: FieldKind; + doc?: string; +} + +export interface ComponentDef { + name: string; + doc?: string; + props: Record; + /** event name → payload fields (flat, docs/adr-004 §3.4) */ + events: Record>; + /** command name → args fields */ + commands: Record>; + children: boolean; + /** Accepts the common layout props (docs/components.md §2). */ + layout: boolean; +} + +interface PropOptions { required?: boolean; initial?: boolean; doc?: string } + +function prop(kind: PropKind, o: PropOptions & { default?: string | number | boolean } = {}): PropDef { + const d: PropDef = { kind }; + if (o.required) d.required = true; + if (o.initial) d.initial = true; + if (o.default !== undefined) d.default = o.default; + if (o.doc) d.doc = o.doc; + return d; +} + +export const string = (o: PropOptions & { default?: string } = {}) => prop("string", o); +export const number = (o: PropOptions & { default?: number } = {}) => prop("number", o); +export const boolean = (o: PropOptions & { default?: boolean } = {}) => prop("boolean", o); +export const dp = (o: PropOptions & { default?: number } = {}) => prop("dp", o); +export const sp = (o: PropOptions & { default?: number } = {}) => prop("sp", o); +export const color = (o: PropOptions & { default?: string } = {}) => prop("color", o); +/** A number in dp, or "fill" / "wrap". */ +export const size = (o: PropOptions & { default?: number | "fill" | "wrap" } = {}) => prop("size", o); +export function enumOf(values: T, o: PropOptions & { default?: T[number] } = {}): PropDef { + return { ...prop("enum", o), values }; +} + +export const field = { + string: (doc?: string): FieldDef => (doc ? { kind: "string", doc } : { kind: "string" }), + number: (doc?: string): FieldDef => (doc ? { kind: "number", doc } : { kind: "number" }), + boolean: (doc?: string): FieldDef => (doc ? { kind: "boolean", doc } : { kind: "boolean" }), +}; + +export interface ComponentInput { + doc?: string; + props?: Record; + events?: Record>; + commands?: Record>; + children?: boolean; + layout?: boolean; +} + +export function defineComponent(name: string, input: ComponentInput = {}): ComponentDef { + if (!/^[A-Z][A-Za-z0-9]*$|^[a-z][a-z0-9]*\.[A-Z][A-Za-z0-9]*$/.test(name)) { + throw new Error(`component name ${name} must be Bare (built-in) or prefix.Name (host extension)`); + } + for (const ev of Object.keys(input.events ?? {})) { + if (!/^on[A-Z]/.test(ev)) throw new Error(`${name}: event ${ev} must be named on[A-Z]…`); + } + for (const key of Object.keys(input.props ?? {})) { + if (/^on[A-Z]/.test(key) || key === "ref" || key === "children" || key === "key") throw new Error(`${name}: ${key} cannot be a prop name`); + } + return { + name, + ...(input.doc !== undefined && { doc: input.doc }), + props: input.props ?? {}, + events: input.events ?? {}, + commands: input.commands ?? {}, + children: input.children ?? false, + layout: input.layout ?? true, + }; +} + +/** The common layout props every `layout: true` component accepts; order = Modifier order (docs/components.md §2). */ +export const LAYOUT_PROPS: Record = { + width: size({ doc: "dp, or fill / wrap" }), + height: size({ doc: "dp, or fill / wrap" }), + cornerRadius: dp({ doc: "clips background and content" }), + background: color(), + padding: dp({ doc: "all four sides" }), +}; diff --git a/packages/cli/src/schema/generate.ts b/packages/cli/src/schema/generate.ts new file mode 100644 index 0000000..3b580be --- /dev/null +++ b/packages/cli/src/schema/generate.ts @@ -0,0 +1,104 @@ +import type { ComponentDef, FieldDef, PropDef } from "./dsl.ts"; +import { LAYOUT_PROPS } from "./dsl.ts"; + +const HEADER = "// Generated by `tinyui schema` from schema/; do not edit."; + +export function generateTs(components: ComponentDef[]): string { + const out: string[] = [HEADER, 'import type { Node } from "../node.ts";', ""]; + out.push("export interface LayoutProps {"); + for (const [k, p] of Object.entries(LAYOUT_PROPS)) out.push(` ${k}?: ${tsPropType(p)};`); + out.push("}", ""); + for (const c of components) { + const id = tsIdent(c.name); + for (const [ev, fields] of Object.entries(c.events)) out.push(...tsInterface(`${id}${cap(ev.slice(2))}Payload`, fields), ""); + for (const [cmd, fields] of Object.entries(c.commands)) out.push(...tsInterface(`${id}${cap(cmd)}Args`, fields), ""); + if (Object.keys(c.commands).length > 0) { + out.push(`export type ${id}Commands = {`); + for (const cmd of Object.keys(c.commands)) out.push(` ${cmd}: ${id}${cap(cmd)}Args;`); + out.push("};", ""); + } + if (c.doc) out.push(`/** ${c.doc} */`); + out.push(`export interface ${id}Props${c.layout ? " extends LayoutProps" : ""} {`); + for (const [k, p] of Object.entries(c.props)) { + if (p.doc) out.push(` /** ${p.doc} */`); + out.push(` ${k}${p.required ? "" : "?"}: ${tsPropType(p)};`); + } + for (const ev of Object.keys(c.events)) out.push(` ${ev}?: (e: ${id}${cap(ev.slice(2))}Payload) => void;`); + if (Object.keys(c.commands).length > 0) out.push(` ref?: import("../node.ts").Ref<${id}Commands>;`); + if (c.children) out.push(" children?: Node | Node[];"); + out.push("}", ""); + } + out.push("export interface IntrinsicElements {"); + for (const c of components) out.push(` ${c.name.includes(".") ? JSON.stringify(c.name) : c.name}: ${tsIdent(c.name)}Props;`); + out.push("}", ""); + for (const c of components) if (!c.name.includes(".")) out.push(`export const ${c.name} = ${JSON.stringify(c.name)};`); + return out.join("\n") + "\n"; +} + +export function generateKt(components: ComponentDef[], pkg: string, objectName: string): string { + const out: string[] = [HEADER, `package ${pkg}`, "", "import wang.harlon.tinyui.schema.ComponentSchema", "import wang.harlon.tinyui.schema.FieldSpec", "import wang.harlon.tinyui.schema.PropSpec", "", `object ${objectName} {`]; + for (const c of components) { + out.push(` val ${ktIdent(c.name)} = ComponentSchema(`); + out.push(` type = ${JSON.stringify(c.name)},`); + out.push(` props = mapOf(`); + for (const [k, p] of Object.entries(c.props)) out.push(` ${JSON.stringify(k)} to ${ktPropSpec(p)},`); + out.push(` ),`); + out.push(` events = mapOf(`); + for (const [ev, fields] of Object.entries(c.events)) out.push(` ${JSON.stringify(ev)} to ${ktFields(fields)},`); + out.push(` ),`); + out.push(` commands = mapOf(`); + for (const [cmd, fields] of Object.entries(c.commands)) out.push(` ${JSON.stringify(cmd)} to ${ktFields(fields)},`); + out.push(` ),`); + out.push(` children = ${c.children},`); + out.push(` layout = ${c.layout},`); + out.push(` )`, ""); + } + out.push(` val all: List = listOf(${components.map((c) => ktIdent(c.name)).join(", ")})`); + out.push("}"); + return out.join("\n") + "\n"; +} + +function tsInterface(name: string, fields: Record): string[] { + const lines = [`export interface ${name} {`]; + for (const [k, f] of Object.entries(fields)) { + if (f.doc) lines.push(` /** ${f.doc} */`); + lines.push(` ${k}: ${f.kind};`); + } + lines.push("}"); + return lines; +} + +function tsPropType(p: PropDef): string { + switch (p.kind) { + case "string": case "color": return "string"; + case "number": case "dp": case "sp": return "number"; + case "boolean": return "boolean"; + case "size": return 'number | "fill" | "wrap"'; + case "enum": return (p.values ?? []).map((v) => JSON.stringify(v)).join(" | "); + } +} + +function ktPropSpec(p: PropDef): string { + const flags = `required = ${!!p.required}, initial = ${!!p.initial}`; + const d = p.default; + switch (p.kind) { + case "string": return `PropSpec.Str(default = ${d === undefined ? "null" : JSON.stringify(d)}, ${flags})`; + case "number": return `PropSpec.Num(default = ${ktDouble(d)}, ${flags})`; + case "boolean": return `PropSpec.Bool(default = ${d === undefined ? "null" : `${d}`}, ${flags})`; + case "dp": return `PropSpec.Dp(default = ${ktDouble(d)}, ${flags})`; + case "sp": return `PropSpec.Sp(default = ${ktDouble(d)}, ${flags})`; + case "color": return `PropSpec.ColorSpec(default = ${d === undefined ? "null" : JSON.stringify(d)}, ${flags})`; + case "size": return `PropSpec.Size(default = ${d === undefined ? "null" : JSON.stringify(String(d))}, ${flags})`; + case "enum": return `PropSpec.Enum(values = setOf(${(p.values ?? []).map((v) => JSON.stringify(v)).join(", ")}), default = ${d === undefined ? "null" : JSON.stringify(d)}, ${flags})`; + } +} + +function ktFields(fields: Record): string { + const entries = Object.entries(fields).map(([k, f]) => `${JSON.stringify(k)} to FieldSpec.${cap(f.kind === "boolean" ? "Bool" : f.kind === "number" ? "Num" : "Str")}`); + return entries.length === 0 ? "emptyMap()" : `mapOf(${entries.join(", ")})`; +} + +const ktDouble = (d: string | number | boolean | undefined) => (d === undefined ? "null" : Number.isInteger(Number(d)) ? `${Number(d)}.0` : `${Number(d)}`); +const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); +const tsIdent = (name: string) => name.split(".").map(cap).join(""); +const ktIdent = (name: string) => name.split(".").map(cap).join(""); diff --git a/packages/cli/src/schema/index.ts b/packages/cli/src/schema/index.ts new file mode 100644 index 0000000..1b675fc --- /dev/null +++ b/packages/cli/src/schema/index.ts @@ -0,0 +1,2 @@ +// What schema authors import; the generator and loader stay inside the CLI. +export * from "./dsl.ts"; diff --git a/packages/cli/src/schema/load.ts b/packages/cli/src/schema/load.ts new file mode 100644 index 0000000..8fe231c --- /dev/null +++ b/packages/cli/src/schema/load.ts @@ -0,0 +1,28 @@ +import { build as esbuild } from "esbuild"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import type { ComponentDef } from "./dsl.ts"; + +/** Bundles a schema entry (TS) and imports it; the entry's default export is the component list. */ +export async function loadSchema(entry: string): Promise { + const dir = await mkdtemp(join(tmpdir(), "tinyui-schema-")); + try { + const outfile = join(dir, "schema.mjs"); + await esbuild({ entryPoints: [resolve(entry)], bundle: true, format: "esm", platform: "node", target: "node22", outfile, logLevel: "silent" }); + const mod = (await import(pathToFileURL(outfile).href)) as { default?: unknown }; + const list = mod.default; + if (!Array.isArray(list) || list.some((c) => typeof c !== "object" || typeof (c as ComponentDef).name !== "string")) { + throw new Error(`${entry} must default-export an array of defineComponent() results`); + } + const names = new Set(); + for (const c of list as ComponentDef[]) { + if (names.has(c.name)) throw new Error(`duplicate component ${c.name}`); + names.add(c.name); + } + return list as ComponentDef[]; + } finally { + await rm(dir, { recursive: true, force: true }); + } +} diff --git a/packages/core/src/components.ts b/packages/core/src/components.ts index 60db75e..60511e4 100644 --- a/packages/core/src/components.ts +++ b/packages/core/src/components.ts @@ -1,7 +1,4 @@ -// Built-in component names for JSX; the prop types live in jsx-runtime.ts, hand-written for M1 and generated from schema/ later (roadmap C). -export const Column = "Column"; -export const Row = "Row"; -export const Text = "Text"; -export const Button = "Button"; +// Built-in component names and prop types come from schema/ (docs/components.md). +export * from "./generated/components.ts"; export interface ClickPayload {} diff --git a/packages/core/src/control.ts b/packages/core/src/control.ts index 883a249..10ac9c6 100644 --- a/packages/core/src/control.ts +++ b/packages/core/src/control.ts @@ -1,6 +1,6 @@ // For / Show: docs/runtime-api.md §4; slot mechanics in node.ts. import { disposeOwner, effect, onCleanup, signal, untrack, type Owner } from "./reactive.ts"; -import { CONTROL, renderInOwner, Slot, type Component, type Node } from "./node.ts"; +import { CONTROL, renderInOwner, Slot, type Node } from "./node.ts"; export interface ShowProps { when: T; @@ -8,7 +8,9 @@ export interface ShowProps { fallback?: () => Node; } -export const Show: Component> = Object.assign(function Show(props: ShowProps): Node { +type Control = F & { [CONTROL]: true }; + +export const Show: Control<(props: ShowProps) => Node> = Object.assign(function Show(props: ShowProps): Node { const slot = new Slot(); let current: { owner: Owner; node: number } | null = null; let shown: boolean | undefined; @@ -46,7 +48,7 @@ interface Row { setIndex: (i: number) => void; } -export const For: Component> = Object.assign(function For(props: ForProps): Node { +export const For: Control<(props: ForProps) => Node> = Object.assign(function For(props: ForProps): Node { const slot = new Slot(); const rows = new Map>(); let order: (string | number)[] = []; diff --git a/packages/core/src/generated/components.ts b/packages/core/src/generated/components.ts new file mode 100644 index 0000000..01e59c1 --- /dev/null +++ b/packages/core/src/generated/components.ts @@ -0,0 +1,159 @@ +// Generated by `tinyui schema` from schema/; do not edit. +import type { Node } from "../node.ts"; + +export interface LayoutProps { + width?: number | "fill" | "wrap"; + height?: number | "fill" | "wrap"; + cornerRadius?: number; + background?: string; + padding?: number; +} + +export interface ColumnClickPayload { +} + +/** Vertical stack. */ +export interface ColumnProps extends LayoutProps { + /** space between children */ + gap?: number; + /** cross axis */ + align?: "start" | "center" | "end"; + /** main axis */ + justify?: "start" | "center" | "end" | "spaceBetween"; + onClick?: (e: ColumnClickPayload) => void; + children?: Node | Node[]; +} + +export interface RowClickPayload { +} + +/** Horizontal stack. */ +export interface RowProps extends LayoutProps { + /** space between children */ + gap?: number; + /** cross axis */ + align?: "start" | "center" | "end"; + /** main axis */ + justify?: "start" | "center" | "end" | "spaceBetween"; + onClick?: (e: RowClickPayload) => void; + children?: Node | Node[]; +} + +export interface BoxClickPayload { +} + +/** Children stacked on top of each other. */ +export interface BoxProps extends LayoutProps { + align?: "topStart" | "topCenter" | "topEnd" | "centerStart" | "center" | "centerEnd" | "bottomStart" | "bottomCenter" | "bottomEnd"; + onClick?: (e: BoxClickPayload) => void; + children?: Node | Node[]; +} + +export interface TextClickPayload { +} + +export interface TextProps extends LayoutProps { + text: string; + color?: string; + fontSize?: number; + fontWeight?: "normal" | "medium" | "bold"; + /** 0 = unlimited */ + maxLines?: number; + align?: "start" | "center" | "end"; + onClick?: (e: TextClickPayload) => void; +} + +export interface ButtonClickPayload { +} + +export interface ButtonProps extends LayoutProps { + text: string; + enabled?: boolean; + variant?: "filled" | "outlined" | "text"; + onClick?: (e: ButtonClickPayload) => void; +} + +export interface TextFieldChangePayload { + text: string; +} + +export interface TextFieldCommitPayload { + /** on IME action or focus loss */ + text: string; +} + +export interface TextFieldSetTextArgs { + text: string; +} + +export interface TextFieldFocusArgs { +} + +export interface TextFieldBlurArgs { +} + +export type TextFieldCommands = { + setText: TextFieldSetTextArgs; + focus: TextFieldFocusArgs; + blur: TextFieldBlurArgs; +}; + +/** Text and cursor live on the Kotlin side; JS gets notified and issues commands (docs/adr-004 §3.3). */ +export interface TextFieldProps extends LayoutProps { + initialText?: string; + placeholder?: string; + singleLine?: boolean; + keyboard?: "text" | "number" | "email" | "phone" | "password"; + onChange?: (e: TextFieldChangePayload) => void; + onCommit?: (e: TextFieldCommitPayload) => void; + ref?: import("../node.ts").Ref; +} + +export interface LazyColumnReachEndPayload { +} + +export interface LazyColumnScrollEndPayload { + /** first visible child */ + index: number; +} + +export interface LazyColumnScrollToArgs { + index: number; +} + +export type LazyColumnCommands = { + scrollTo: LazyColumnScrollToArgs; +}; + +/** Virtualised vertical list; children are usually a . */ +export interface LazyColumnProps extends LayoutProps { + gap?: number; + onReachEnd?: (e: LazyColumnReachEndPayload) => void; + onScrollEnd?: (e: LazyColumnScrollEndPayload) => void; + ref?: import("../node.ts").Ref; + children?: Node | Node[]; +} + +/** Empty space; size it with width / height. */ +export interface SpacerProps extends LayoutProps { +} + +export interface IntrinsicElements { + Column: ColumnProps; + Row: RowProps; + Box: BoxProps; + Text: TextProps; + Button: ButtonProps; + TextField: TextFieldProps; + LazyColumn: LazyColumnProps; + Spacer: SpacerProps; +} + +export const Column = "Column"; +export const Row = "Row"; +export const Box = "Box"; +export const Text = "Text"; +export const Button = "Button"; +export const TextField = "TextField"; +export const LazyColumn = "LazyColumn"; +export const Spacer = "Spacer"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6ad173a..ab52b54 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,8 +9,7 @@ export type { ForProps, ShowProps } from "./control.ts"; export { createResource } from "./resource.ts"; export type { ResourceActions } from "./resource.ts"; export { call, query, send, HostError } from "./host.ts"; -export { Column, Row, Text, Button } from "./components.ts"; -export type { ClickPayload } from "./components.ts"; +export * from "./components.ts"; // Globals the engine provides to page code; there is no DOM lib to declare them (docs/adr-005-engine.md). declare global { diff --git a/packages/core/src/jsx-runtime.ts b/packages/core/src/jsx-runtime.ts index 0ea2a29..56f61ce 100644 --- a/packages/core/src/jsx-runtime.ts +++ b/packages/core/src/jsx-runtime.ts @@ -1,7 +1,7 @@ // Type-check entry for `jsx: "react-jsx"` + `jsxImportSource: "@tiny-ui/core"`; the CLI still emits h() calls // (docs/jsx-transform.md). The runtime functions exist so a react-jsx transform would also work. import { Fragment as FragmentImpl, h, type Node, type Props } from "./node.ts"; -import type { ClickPayload } from "./components.ts"; +import type { IntrinsicElements as Generated } from "./generated/components.ts"; export function jsx(type: string | ((props: Props) => Node), props: Props & { children?: unknown }): Node { const { children, ...rest } = props; @@ -11,17 +11,8 @@ export function jsx(type: string | ((props: Props) => Node), props: Props & { ch export { jsx as jsxs, jsx as jsxDEV }; export const Fragment = FragmentImpl; -interface Container { - children?: Node | Node[]; -} - export declare namespace JSX { type Element = Node; interface ElementChildrenAttribute { children: {} } - interface IntrinsicElements { - Column: Container; - Row: Container; - Text: { text: string; color?: string; fontSize?: number }; - Button: { text: string; onClick?: (e: ClickPayload) => void }; - } + interface IntrinsicElements extends Generated {} } diff --git a/packages/core/src/node.ts b/packages/core/src/node.ts index 8ea218f..3d76e61 100644 --- a/packages/core/src/node.ts +++ b/packages/core/src/node.ts @@ -79,16 +79,20 @@ export class Slot implements SlotEntry { } } -export interface Ref { - cmd(name: string, args?: Record): void; +/** Handle to a node for one-shot commands (docs/runtime-api.md §5); `C` maps command names to their args. */ +export interface Ref = Record>> { + cmd(name: K, ...args: {} extends C[K] ? [args?: C[K]] : [args: C[K]]): void; } -export function ref(): Ref { - const r = { id: 0, cmd(name: string, args: Record = {}) { - if (r.id === 0) throw new Error("ref.cmd() before the ref was attached to a node"); - patches.push(["x", r.id, name, args]); - } }; - return r; +export function ref = Record>>(): Ref { + const r = { + id: 0, + cmd(name: string, args: object = {}) { + if (r.id === 0) throw new Error("ref.cmd() before the ref was attached to a node"); + patches.push(["x", r.id, name, args]); + }, + }; + return r as unknown as Ref; } /** `<>…`: its children are spliced into the parent's; only valid in a children position. */ diff --git a/packages/native/package.json b/packages/native/package.json index 6e318e7..41c9807 100644 --- a/packages/native/package.json +++ b/packages/native/package.json @@ -16,12 +16,18 @@ "scripts": { "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "node --test --experimental-strip-types 'test/**/*.test.ts'" + "test": "node --test 'test/**/*.test.ts'" }, "repository": { "type": "git", "url": "git+https://github.com/HarlonWang/tinyui.git", "directory": "packages/native" }, - "sideEffects": false + "sideEffects": false, + "dependencies": { + "@tiny-ui/core": "workspace:*" + }, + "devDependencies": { + "@types/node": "latest" + } } diff --git a/packages/native/src/events.ts b/packages/native/src/events.ts new file mode 100644 index 0000000..cf4c154 --- /dev/null +++ b/packages/native/src/events.ts @@ -0,0 +1,13 @@ +// docs/native-api.md §4: one bus for host events and business events. +import { onEmit, send } from "@tiny-ui/core"; + +/** J4: delivered to every page subscribed to `topic`, including native listeners. */ +export function emit(topic: string, payload: Record = {}): void { + send("events.emit", { topic, payload }); +} + +/** K5: call during render; the subscription dies with the owner. */ +export function on(topic: string, fn: (payload: unknown) => void): void { + send("events.subscribe", { topic }); + onEmit(topic, fn); +} diff --git a/packages/native/src/http.ts b/packages/native/src/http.ts new file mode 100644 index 0000000..2600ecb --- /dev/null +++ b/packages/native/src/http.ts @@ -0,0 +1,21 @@ +// docs/native-api.md §5: J3 `http.request`, errors are HostError with the E3 codes. +import { call } from "@tiny-ui/core"; + +export interface HttpOptions { + headers?: Record; + /** ms; the host rejects with E_TIMEOUT */ + timeout?: number; +} + +export interface HttpResponse { + status: number; + /** parsed JSON body, or the text when it is not JSON */ + body: T; +} + +export function request(method: string, url: string, body?: unknown, options: HttpOptions = {}): Promise> { + return call>("http.request", { method, url, ...(body !== undefined && { body }), ...options }); +} + +export const get = (url: string, options?: HttpOptions) => request("GET", url, undefined, options); +export const post = (url: string, body?: unknown, options?: HttpOptions) => request("POST", url, body, options); diff --git a/packages/native/src/index.ts b/packages/native/src/index.ts index 2e47a88..d518351 100644 --- a/packages/native/src/index.ts +++ b/packages/native/src/index.ts @@ -1 +1,7 @@ export const VERSION = "0.0.0"; +export * as navigation from "./navigation.ts"; +export * as store from "./store.ts"; +export * as events from "./events.ts"; +export * as http from "./http.ts"; +export type { HttpResponse, HttpOptions } from "./http.ts"; +export { HostError } from "@tiny-ui/core"; diff --git a/packages/native/src/navigation.ts b/packages/native/src/navigation.ts new file mode 100644 index 0000000..ece68e2 --- /dev/null +++ b/packages/native/src/navigation.ts @@ -0,0 +1,19 @@ +// docs/native-api.md §2; the host implements Navigator (docs/app-model.md). +import { onEmit, send } from "@tiny-ui/core"; + +export type Params = Record; + +/** J4: opens `page` (a module name such as `pages/detail`) on top of this one. */ +export function push(page: string, params: Params = {}): void { + send("navigation.push", { page, params }); +} + +/** J4: closes this page; `result` reaches the page below through `onResult`. */ +export function pop(result: unknown = null): void { + send("navigation.pop", { result }); +} + +/** K5 `navigation.result`: the page that was pushed from here popped with a result. Call during render. */ +export function onResult(fn: (result: unknown, from: string) => void): void { + onEmit("navigation.result", (p) => { const r = p as { result: unknown; from: string }; fn(r.result, r.from); }); +} diff --git a/packages/native/src/store.ts b/packages/native/src/store.ts new file mode 100644 index 0000000..023a700 --- /dev/null +++ b/packages/native/src/store.ts @@ -0,0 +1,21 @@ +// docs/native-api.md §3: the truth lives in Kotlin; pages read snapshots and subscribe to changes. +import { onEmit, query, send, signal } from "@tiny-ui/core"; + +/** J2: the current value, or undefined. */ +export function get(key: string): T | undefined { + const v = query("store.get", { key }); + return v === null ? undefined : v; +} + +/** J4: writes the value; every page subscribed to `key` receives it. */ +export function set(key: string, value: unknown): void { + send("store.set", { key, value: JSON.stringify(value) }); +} + +/** An accessor kept current by K5; call during render, read it inside effects and thunks. */ +export function watch(key: string): () => T | undefined { + const [value, setValue] = signal(get(key)); + send("store.subscribe", { key }); + onEmit(`store:${key}`, (p) => setValue(() => ((p as { value: T | null }).value ?? undefined) as T | undefined)); + return value; +} diff --git a/packages/native/test/host-stub.ts b/packages/native/test/host-stub.ts new file mode 100644 index 0000000..272a6c7 --- /dev/null +++ b/packages/native/test/host-stub.ts @@ -0,0 +1,13 @@ +// Fake `__host_*` globals installed before the runtime is imported (same shape as packages/core/test). +export const bridge = { + calls: [] as { name: string; cbId: number; args: unknown }[], + sent: [] as { name: string; args: unknown }[], + queries: {} as Record, + applied: [] as unknown[][][], +}; +const g = globalThis as Record; +g["__host_apply"] = (json: string) => bridge.applied.push(JSON.parse(json)); +g["__host_call"] = (name: string, cbId: number, args: string) => bridge.calls.push({ name, cbId, args: JSON.parse(args) }); +g["__host_send"] = (name: string, args: string) => bridge.sent.push({ name, args: JSON.parse(args) }); +g["__host_query"] = (name: string) => JSON.stringify(bridge.queries[name] ?? null); +g["__host_report"] = () => {}; diff --git a/packages/native/test/native.test.ts b/packages/native/test/native.test.ts new file mode 100644 index 0000000..4e1f3ec --- /dev/null +++ b/packages/native/test/native.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it } from "node:test"; +import { bridge } from "./host-stub.ts"; +import { Column, h, Text, thunk } from "@tiny-ui/core"; +import { events, http, navigation, store } from "../src/index.ts"; + +type Entries = { mount(page: unknown, props: string, host: string): void; unmount(): void; flush(): void; resolve(id: number, json: string): void; emit(topic: string, json: string): void }; +const tinyui = (): Entries => (globalThis as Record)["__tinyui"] as Entries; +const HOST = JSON.stringify({ components: {}, capabilities: [] }); +const drain = () => new Promise((r) => setTimeout(r, 0)); +afterEach(() => tinyui().unmount()); + +describe("@tiny-ui/native", () => { + it("http.get is a J3 http.request that resolves through K3", async () => { + const p = http.get<{ ok: boolean }>("/me", { timeout: 100 }); + const call = bridge.calls.at(-1)!; + assert.equal(call.name, "http.request"); + assert.deepEqual(call.args, { method: "GET", url: "/me", timeout: 100 }); + tinyui().resolve(call.cbId, JSON.stringify({ status: 200, body: { ok: true } })); + await drain(); + assert.deepEqual(await p, { status: 200, body: { ok: true } }); + }); + + it("store.watch reads the snapshot, subscribes, and follows K5", () => { + bridge.queries["store.get"] = { n: 1 }; + tinyui().mount(() => { + const cart = store.watch<{ n: number }>("cart"); // at render, like any subscription + return h(Column, null, h(Text, { text: thunk(() => String((cart() ?? { n: 0 }).n)) })); + }, "{}", HOST); + tinyui().flush(); + assert.deepEqual(bridge.sent.at(-1), { name: "store.subscribe", args: { key: "cart" } }); + assert.deepEqual(bridge.applied.at(-1)!.filter((o) => o[0] === "p"), [["p", 1, "text", "1"]]); + tinyui().emit("store:cart", JSON.stringify({ value: { n: 5 } })); + tinyui().flush(); + assert.deepEqual(bridge.applied.at(-1), [["p", 1, "text", "5"]]); + store.set("cart", { n: 6 }); + assert.deepEqual(bridge.sent.at(-1), { name: "store.set", args: { key: "cart", value: '{"n":6}' } }); + }); + + it("events.on subscribes once per handler and navigation is fire-and-forget", () => { + const seen: unknown[] = []; + tinyui().mount(() => { events.on("net", (p) => seen.push(p)); navigation.onResult((r) => seen.push(r)); return h(Text, { text: "x" }); }, "{}", HOST); + tinyui().flush(); + assert.deepEqual(bridge.sent.at(-1), { name: "events.subscribe", args: { topic: "net" } }); + tinyui().emit("net", JSON.stringify({ online: true })); + tinyui().emit("navigation.result", JSON.stringify({ result: 42, from: "pages/x" })); + assert.deepEqual(seen, [{ online: true }, 42]); + navigation.push("pages/detail", { id: 1 }); + assert.deepEqual(bridge.sent.at(-1), { name: "navigation.push", args: { page: "pages/detail", params: { id: 1 } } }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6814cb..2be0ea4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,7 +36,15 @@ importers: packages/core: {} - packages/native: {} + packages/native: + dependencies: + '@tiny-ui/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@types/node': + specifier: latest + version: 22.20.3 sample/js: dependencies: @@ -51,6 +59,12 @@ importers: specifier: workspace:* version: link:../../packages/cli + schema: + devDependencies: + '@tiny-ui/cli': + specifier: workspace:* + version: link:../packages/cli + packages: '@esbuild/aix-ppc64@0.28.2': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index aa1b561..2775820 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - packages/* - sample/js + - schema onlyBuiltDependencies: - esbuild diff --git a/sample/js/src/pages/todos.tsx b/sample/js/src/pages/todos.tsx new file mode 100644 index 0000000..d00e160 --- /dev/null +++ b/sample/js/src/pages/todos.tsx @@ -0,0 +1,69 @@ +import { Button, Column, createResource, effect, For, LazyColumn, ref, Row, Show, signal, Text, TextField, untrack, type LazyColumnCommands, type TextFieldCommands } from "@tiny-ui/core"; +import { http } from "@tiny-ui/native"; + +interface Todo { id: number; title: string; done: boolean } +interface Page { items: Todo[]; next: number | null } + +export default function Todos() { + const [todos, setTodos] = signal([]); + const [next, setNext] = signal(1); + const [loading, setLoading] = signal(false); + const [first, firstMeta] = createResource(() => http.get("/todos?page=1")); + effect(() => { + const r = first(); + if (r) untrack(() => { setTodos(r.body.items); setNext(r.body.next); }); + }); + const list = ref(); + const input = ref(); + + async function loadMore() { + const page = next(); + if (page === null || loading()) return; + setLoading(true); + try { + const { body } = await http.get(`/todos?page=${page}`); + setTodos([...todos(), ...body.items]); + setNext(body.next); + } finally { + setLoading(false); + } + } + + function add(title: string) { + if (!title.trim()) return; + setTodos([...todos(), { id: Date.now(), title: title.trim(), done: false }]); + input.cmd("setText", { text: "" }); + list.cmd("scrollTo", { index: todos().length - 1 }); + } + + const toggle = (id: number) => setTodos(todos().map((t) => (t.id === id ? { ...t, done: !t.done } : t))); + const remove = (id: number) => setTodos(todos().filter((t) => t.id !== id)); + + return ( + + t.done).length} done`} fontSize={20} fontWeight="bold" /> + + add(e.text)} /> +