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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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 库。两种来源:
Expand Down
357 changes: 303 additions & 54 deletions compose/api/compose.klib.api

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package wang.harlon.tinyui

import android.os.Build

internal actual fun platformInfo(): Map<String, String> = mapOf(
"os" to "android",
"osVersion" to Build.VERSION.RELEASE.orEmpty(),
"model" to Build.MODEL.orEmpty(),
)
Original file line number Diff line number Diff line change
@@ -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)
}
}
120 changes: 120 additions & 0 deletions compose/src/commonMain/kotlin/wang/harlon/tinyui/HostServices.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
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<String, String> = emptyMap(),
) {
companion object {
/** One shared instance, so a page whose host passes nothing keeps a stable `remember` key. */
val Default = HostServices()
}
}

/** 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?) {}
}
}

/** A store value with the monotonic version of its write; listeners drop what arrives out of order. */
class StoreValue(val json: String, val version: Long)

/** 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 every write to [key], possibly out of order across threads; the returned handle removes it. */
fun observe(key: String, listener: (StoreValue) -> 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<Map<String, StoreValue>>(emptyMap())
private val listeners = Listeners<StoreValue>()

override fun get(key: String): String? = values.load()[key]?.json

override fun set(key: String, json: String) {
var written: StoreValue? = null
values.update { map -> StoreValue(json, (map[key]?.version ?: 0) + 1).also { written = it }.let { map + (key to it) } }
listeners.notify(key, written!!)
}

override fun observe(key: String, listener: (StoreValue) -> Unit): AutoCloseable = listeners.add(key, listener)
}

class HttpRequest(val method: String, val url: String, val headers: Map<String, String>, 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<String>()

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<T> {
private val byKey = AtomicReference<Map<String, List<(T) -> Unit>>>(emptyMap())

fun add(key: String, listener: (T) -> 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, value: T) {
byKey.load()[key]?.forEach { it(value) }
}
}

internal expect fun platformInfo(): Map<String, String>
Loading
Loading