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
8 changes: 7 additions & 1 deletion compose/api/compose.klib.api
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,7 @@ final class app.tinyui/StoreValue { // app.tinyui/StoreValue|null[0]
}

final class app.tinyui/TinyUIHost { // app.tinyui/TinyUIHost|null[0]
constructor <init>(app.tinyui.schema/ComponentRegistry, app.tinyui/PageSink, app.tinyui/CapabilityRegistry = ..., kotlin.collections/Map<kotlin/String, app.tinyui/HttpChannel> = ..., kotlinx.coroutines.flow/StateFlow<kotlin/String>? = ..., app.tinyui/SessionSource? = ..., app.tinyui/AnalyticsSink? = ..., app.tinyui/LinkOpener? = ..., app.tinyui/Navigator = ..., okio/Path? = ...) // app.tinyui/TinyUIHost.<init>|<init>(app.tinyui.schema.ComponentRegistry;app.tinyui.PageSink;app.tinyui.CapabilityRegistry;kotlin.collections.Map<kotlin.String,app.tinyui.HttpChannel>;kotlinx.coroutines.flow.StateFlow<kotlin.String>?;app.tinyui.SessionSource?;app.tinyui.AnalyticsSink?;app.tinyui.LinkOpener?;app.tinyui.Navigator;okio.Path?){}[0]
constructor <init>(app.tinyui.schema/ComponentRegistry, app.tinyui/PageSink, app.tinyui/CapabilityRegistry = ..., kotlin.collections/Map<kotlin/String, app.tinyui/HttpChannel> = ..., kotlinx.coroutines.flow/StateFlow<kotlin/String>? = ..., kotlinx.coroutines.flow/StateFlow<kotlin/String>? = ..., app.tinyui/SessionSource? = ..., app.tinyui/AnalyticsSink? = ..., app.tinyui/LinkOpener? = ..., app.tinyui/Navigator = ..., okio/Path? = ...) // app.tinyui/TinyUIHost.<init>|<init>(app.tinyui.schema.ComponentRegistry;app.tinyui.PageSink;app.tinyui.CapabilityRegistry;kotlin.collections.Map<kotlin.String,app.tinyui.HttpChannel>;kotlinx.coroutines.flow.StateFlow<kotlin.String>?;kotlinx.coroutines.flow.StateFlow<kotlin.String>?;app.tinyui.SessionSource?;app.tinyui.AnalyticsSink?;app.tinyui.LinkOpener?;app.tinyui.Navigator;okio.Path?){}[0]

final val analytics // app.tinyui/TinyUIHost.analytics|{}analytics[0]
final fun <get-analytics>(): app.tinyui/AnalyticsSink? // app.tinyui/TinyUIHost.analytics.<get-analytics>|<get-analytics>(){}[0]
Expand Down Expand Up @@ -580,6 +580,8 @@ final class app.tinyui/TinyUIHost { // app.tinyui/TinyUIHost|null[0]
final fun <get-sink>(): app.tinyui/PageSink // app.tinyui/TinyUIHost.sink.<get-sink>|<get-sink>(){}[0]
final val store // app.tinyui/TinyUIHost.store|{}store[0]
final fun <get-store>(): app.tinyui/Store // app.tinyui/TinyUIHost.store.<get-store>|<get-store>(){}[0]
final val timeZone // app.tinyui/TinyUIHost.timeZone|{}timeZone[0]
final fun <get-timeZone>(): kotlinx.coroutines.flow/StateFlow<kotlin/String>? // app.tinyui/TinyUIHost.timeZone.<get-timeZone>|<get-timeZone>(){}[0]
}

sealed class app.tinyui.schema/PropSpec { // app.tinyui.schema/PropSpec|null[0]
Expand Down Expand Up @@ -679,12 +681,16 @@ sealed class app.tinyui.schema/PropSpec { // app.tinyui.schema/PropSpec|null[0]
}

final object app.tinyui.components.generated/BuiltinSchemas { // app.tinyui.components.generated/BuiltinSchemas|null[0]
final val BottomSheet // app.tinyui.components.generated/BuiltinSchemas.BottomSheet|{}BottomSheet[0]
final fun <get-BottomSheet>(): app.tinyui.schema/ComponentSchema // app.tinyui.components.generated/BuiltinSchemas.BottomSheet.<get-BottomSheet>|<get-BottomSheet>(){}[0]
final val Box // app.tinyui.components.generated/BuiltinSchemas.Box|{}Box[0]
final fun <get-Box>(): app.tinyui.schema/ComponentSchema // app.tinyui.components.generated/BuiltinSchemas.Box.<get-Box>|<get-Box>(){}[0]
final val Button // app.tinyui.components.generated/BuiltinSchemas.Button|{}Button[0]
final fun <get-Button>(): app.tinyui.schema/ComponentSchema // app.tinyui.components.generated/BuiltinSchemas.Button.<get-Button>|<get-Button>(){}[0]
final val Column // app.tinyui.components.generated/BuiltinSchemas.Column|{}Column[0]
final fun <get-Column>(): app.tinyui.schema/ComponentSchema // app.tinyui.components.generated/BuiltinSchemas.Column.<get-Column>|<get-Column>(){}[0]
final val Divider // app.tinyui.components.generated/BuiltinSchemas.Divider|{}Divider[0]
final fun <get-Divider>(): app.tinyui.schema/ComponentSchema // app.tinyui.components.generated/BuiltinSchemas.Divider.<get-Divider>|<get-Divider>(){}[0]
final val Icon // app.tinyui.components.generated/BuiltinSchemas.Icon|{}Icon[0]
final fun <get-Icon>(): app.tinyui.schema/ComponentSchema // app.tinyui.components.generated/BuiltinSchemas.Icon.<get-Icon>|<get-Icon>(){}[0]
final val LazyColumn // app.tinyui.components.generated/BuiltinSchemas.LazyColumn|{}LazyColumn[0]
Expand Down
11 changes: 11 additions & 0 deletions compose/src/androidMain/kotlin/app/tinyui/Platform.android.kt
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package app.tinyui

import android.content.ClipData
import android.os.Build
import androidx.compose.ui.platform.ClipEntry
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import java.util.Locale
import java.util.TimeZone
import okio.FileSystem

internal actual fun platformInfo(): Map<String, String> = mapOf(
Expand All @@ -14,6 +17,14 @@ internal actual fun platformInfo(): Map<String, String> = mapOf(

internal actual fun systemLocale(): String = Locale.getDefault().toLanguageTag()

internal actual fun systemTimeZone(): String = TimeZone.getDefault().id

// getTimeZone answers GMT for an id it does not know rather than failing
internal actual fun zoneOffsetMinutes(zone: String, epochMs: Long): Int? =
TimeZone.getTimeZone(zone).takeIf { it.id == zone }?.getOffset(epochMs)?.div(60_000)

internal actual fun plainTextClipEntry(text: String): ClipEntry = ClipEntry(ClipData.newPlainText(null, text))

internal actual val platformFileSystem: FileSystem = FileSystem.SYSTEM

internal actual fun defaultHttpClient(): HttpClient = HttpClient(OkHttp)
13 changes: 11 additions & 2 deletions compose/src/commonMain/kotlin/app/tinyui/HostInterfaces.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package app.tinyui

import androidx.compose.ui.platform.ClipEntry
import kotlin.concurrent.atomics.AtomicReference
import kotlin.concurrent.atomics.ExperimentalAtomicApi
import kotlin.concurrent.atomics.update
Expand Down Expand Up @@ -110,9 +111,9 @@ class EventBus {
/** Names the framework dispatches itself (J2 / J3 / J4); a host capability may not take one. */
internal val FrameworkNames = setOf(
"device.info", "store.get", "store.set", "store.subscribe", "storage.get", "storage.set", "storage.remove", "storage.clear",
"i18n.t", "i18n.locale", "i18n.subscribe", "session.get", "session.subscribe", "session.signIn",
"i18n.t", "i18n.locale", "i18n.timeZone", "i18n.zoneOffset", "i18n.subscribe", "session.get", "session.subscribe", "session.signIn",
"navigation.push", "navigation.pop", "events.emit", "events.subscribe", "http.request", "timer.schedule", "timer.cancel",
"ui.toast", "ui.alert", "ui.confirm", "linking.openUrl", "analytics.track",
"ui.toast", "ui.alert", "ui.confirm", "linking.openUrl", "clipboard.setText", "analytics.track",
)

/** What every host offers, listed in the mount manifest ahead of the host's own; `session.signIn` only comes with a session. */
Expand All @@ -136,3 +137,11 @@ internal expect fun platformInfo(): Map<String, String>

/** BCP 47, e.g. `zh-Hans-CN`. */
internal expect fun systemLocale(): String

/** IANA id, e.g. `Asia/Shanghai`; read fresh each time (iOS caches it otherwise), so a zone changed in system settings shows up. */
internal expect fun systemTimeZone(): String

/** Minutes [zone] is ahead of UTC at [epochMs] (daylight saving included); null when the platform does not know [zone]. */
internal expect fun zoneOffsetMinutes(zone: String, epochMs: Long): Int?

internal expect fun plainTextClipEntry(text: String): ClipEntry
43 changes: 41 additions & 2 deletions compose/src/commonMain/kotlin/app/tinyui/PageHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ class PageHost internal constructor(
private val timers = HashMap<Int, Job>()
private val subscriptions = HashMap<String, AutoCloseable>()
private val storeVersions = HashMap<String, Long>()
/** The system zone the page last got, once it subscribed without a host zone; under the runtime lock like [timers]. */
private var systemZoneSeen: String? = null
private val jsThread = newSingleThreadContext("tinyui-page")
private val runtime = JsRuntime(
JsEngineConfig(moduleScheme = MODULE_SCHEME, logger = sink::log, onUnhandledRejection = { report("E7", it.message ?: "unhandled rejection", stack = it.jsStack) }),
Expand Down Expand Up @@ -178,6 +180,20 @@ class PageHost internal constructor(
fun visible(visible: Boolean) {
wantVisible = visible
entry("visible", JsValue.Bool(visible))
if (visible) systemZoneMoved()
}

/**
* Without a host zone the page follows the system's, which has no change callback in common code: coming back on screen
* (the user may have just been in Settings) is when it is checked again.
*/
private fun systemZoneMoved() {
if (host.timeZone != null) return
val zone = host.systemZone()
var same = false
entry("emit", JsValue.Str("i18n.timeZone"), JsValue.Str(buildJsonObject { put("timeZone", zone) }.toString()), before = {
if (systemZoneSeen == null || systemZoneSeen == zone) same = true else systemZoneSeen = zone
}, skipWhen = { same })
}
fun resolve(cbId: Int, resultJson: String) = entry("resolve", JsValue.Num(cbId), JsValue.Str(resultJson))
fun reject(cbId: Int, code: String, message: String) = reject(cbId, code, message, null)
Expand Down Expand Up @@ -287,6 +303,8 @@ class PageHost internal constructor(
}
"i18n.t" -> JsonPrimitive(translate(a.str("key") ?: "")).toString()
"i18n.locale" -> JsonPrimitive(host.currentLocale()).toString()
"i18n.timeZone" -> JsonPrimitive(host.currentTimeZone()).toString()
"i18n.zoneOffset" -> JsonPrimitive(zoneOffset(a["ms"]?.jsonPrimitive?.doubleOrNull ?: 0.0)).toString()
"session.get" -> sessionJson(host.session?.state?.value ?: Session.LoggedOut)
else -> "null"
}
Expand All @@ -295,6 +313,15 @@ class PageHost internal constructor(
private fun translate(key: String): String =
page.i18n.lookup(key, host.currentLocale()) ?: key.also { if (host.missingKeys.firstTime("${page.pkg}:$it")) sink.log("i18n: ${page.pkg} has no \"$it\" in any language") }

/** A zone the platform does not know is reported once per host and shown in the system zone instead. */
private fun zoneOffset(epochMs: Double): Int {
val zone = host.currentTimeZone()
return zoneOffsetMinutes(zone, epochMs.toLong()) ?: run {
if (host.missingKeys.firstTime("timeZone:$zone")) sink.log("i18n: unknown time zone \"$zone\", using the system zone")
zoneOffsetMinutes(host.systemZone(), epochMs.toLong()) ?: 0
}
}

private fun sessionJson(s: Session): String = buildJsonObject { put("loggedIn", s.loggedIn); put("userId", s.userId) }.toString()

/** J3 (docs/native-api.md §6–§13); results come back through [resolve] / [reject] as one transaction each. */
Expand Down Expand Up @@ -328,6 +355,10 @@ class PageHost internal constructor(
)
if (name == "ui.confirm") confirmed.toString() else ""
}
"clipboard.setText" -> settle(cbId) {
context.copy(a.str("text") ?: throw HostException("E_INVALID", "clipboard.setText needs text"))
""
}
"linking.openUrl" -> settle(cbId) {
val url = a.str("url")?.takeIf { URL_SCHEME.containsMatchIn(it) } ?: throw HostException("E_INVALID", "linking.openUrl needs an absolute URL")
withContext(Dispatchers.Main) { (host.links ?: LinkOpener.System).open(url, context) }
Expand Down Expand Up @@ -401,8 +432,16 @@ class PageHost internal constructor(
"storage.clear" -> storage?.clear()
"events.emit" -> host.events.emit(a.str("topic") ?: return, a["payload"]?.toString() ?: "{}")
"events.subscribe" -> { val topic = a.str("topic") ?: return; subscriptions.getOrPut("events:$topic") { host.events.subscribe(topic) { json -> emit(topic, json) } } }
"i18n.subscribe" -> host.locale?.let { flow ->
subscriptions.getOrPut("i18n") { follow(flow) { emit("i18n.locale", buildJsonObject { put("locale", it) }.toString()) } }
"i18n.subscribe" -> {
host.locale?.let { flow ->
subscriptions.getOrPut("i18n") { follow(flow) { emit("i18n.locale", buildJsonObject { put("locale", it) }.toString()) } }
}
val zone = host.timeZone
if (zone != null) {
subscriptions.getOrPut("i18n.timeZone") { follow(zone) { emit("i18n.timeZone", buildJsonObject { put("timeZone", it) }.toString()) } }
} else if (systemZoneSeen == null) {
systemZoneSeen = host.systemZone()
}
}
"session.subscribe" -> host.session?.let { source ->
subscriptions.getOrPut("session") { follow(source.state) { emit("session", sessionJson(it)) } }
Expand Down
13 changes: 13 additions & 0 deletions compose/src/commonMain/kotlin/app/tinyui/TinyUIHost.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package app.tinyui

import androidx.compose.ui.platform.Clipboard
import androidx.compose.ui.platform.UriHandler
import app.tinyui.schema.ComponentRegistry
import kotlin.concurrent.Volatile
Expand All @@ -15,6 +16,7 @@ import okio.Path
*
* @param channels HTTP channels pages pick by name (`http.client("app")`); `default` is the framework's own and cannot be registered.
* @param locale the user's language (BCP 47); the system language when null.
* @param timeZone the zone dates are shown in (IANA id, e.g. `Asia/Shanghai`); the system zone when null.
* @param dataDir where `storage` keeps each package's file; `storage` answers E_UNSUPPORTED when null. Not the `Updates` directory.
*/
class TinyUIHost(
Expand All @@ -23,6 +25,7 @@ class TinyUIHost(
val capabilities: CapabilityRegistry = CapabilityRegistry(),
val channels: Map<String, HttpChannel> = emptyMap(),
val locale: StateFlow<String>? = null,
val timeZone: StateFlow<String>? = null,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[建议] timeZone 被插入到 locale 与 session 之间,删除了旧的 TinyUIHost 构造函数签名。已有宿主若使用位置参数会把 SessionSource 传到 StateFlow<String>? 位置而无法编译;已编译的客户端也找不到旧 ABI 对应的构造函数。应保留一个参数顺序完全一致的旧构造函数(委托给新构造函数并传入 timeZone = null),或至少将新参数追加到末尾并保留旧 ABI 重载。

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

保持现状,不加旧签名重载、也不挪到末尾:

  • 位置参数:已知宿主(TrendingAI、sample、各测试)components / sink 之后全部是具名参数;locale 之后的参数全有默认值且类型各异,按位置传到第五个以上的写法本就不推荐,文档(native-api §1)的示例也是具名
  • ABI:挪到末尾同样改变构造函数签名(Kotlin 默认参数的合成构造函数随参数表变),只有保留重载才能保住;而宿主都是从源码编译的 KMP App,没有预编译库链接 tinyui,旧 ABI 没有消费者
  • 0.x 的 minor 本就允许这种变化(feat: 框架职责边界落地(ADR-007)——框架自有 http / storage / i18n / 会话 / UI 能力,删 HostServices #28 删 HostServices 先例),本 PR 发版时走 minor;timeZone 放在 locale 旁边是为了 i18n 两个参数相邻

🤖 Generated with Claude Code

Comment thread
sourcery-ai[bot] marked this conversation as resolved.
val session: SessionSource? = null,
val analytics: AnalyticsSink? = null,
val links: LinkOpener? = null,
Expand All @@ -41,6 +44,8 @@ class TinyUIHost(
internal val storages = Storages(dataDir, sink)
internal val missingKeys = MissingKeys()
internal val defaultChannel: HttpChannel by lazy { KtorChannel(defaultHttpClient()) }
/** The platform's zone when [timeZone] is null; replaceable so tests can move it. */
internal var systemZone: () -> String = ::systemTimeZone

init {
for (name in channels.keys) {
Expand All @@ -55,6 +60,8 @@ class TinyUIHost(

internal fun currentLocale(): String = locale?.value ?: systemLocale()

internal fun currentTimeZone(): String = timeZone?.value ?: systemZone()

internal companion object {
const val DEFAULT_CHANNEL = "default"
const val SESSION_SIGN_IN = "session.signIn"
Expand Down Expand Up @@ -90,6 +97,7 @@ fun interface HostCapability {
/** The mount a call came from: the page's module name and the objects its `locals` provide. */
class PageContext internal constructor(val name: String, @Volatile internal var locals: Map<PageLocal<*>, Any>) {
internal var uriHandler: UriHandler? = null
internal var clipboard: Clipboard? = null

@Suppress("UNCHECKED_CAST")
operator fun <T : Any> get(local: PageLocal<T>): T? = locals[local] as T?
Expand All @@ -104,6 +112,11 @@ class PageContext internal constructor(val name: String, @Volatile internal var
}
}
}

internal suspend fun copy(text: String) {
val target = clipboard ?: throw HostException("E_UNSUPPORTED", "the page is not on screen")
withContext(Dispatchers.Main) { target.setClipEntry(plainTextClipEntry(text)) }
}
}

/** A typed key for an object one mount hands to capabilities, such as its screen's view model. */
Expand Down
3 changes: 3 additions & 0 deletions compose/src/commonMain/kotlin/app/tinyui/TinyUIPage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalUriHandler
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
Expand Down Expand Up @@ -43,9 +44,11 @@ fun TinyUIPage(
PageHost(page, host, propsJson, locals, sourceMaps = sourceMaps)
}
val uriHandler = LocalUriHandler.current
val clipboard = LocalClipboard.current
// a new list of the same objects each recomposition must not remount the page: the context just follows it
SideEffect {
pageHost.context.uriHandler = uriHandler
pageHost.context.clipboard = clipboard
pageHost.context.locals = locals.associate { it.local to it.value }
}
val lifecycle = LocalLifecycleOwner.current.lifecycle
Expand Down
41 changes: 41 additions & 0 deletions compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,24 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.DividerDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.LoadingIndicatorDefaults
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.SheetValue
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
Expand Down Expand Up @@ -125,6 +131,14 @@ fun ComponentRegistry.registerBuiltins(): ComponentRegistry = apply {
Icon(icon.vector, contentDescription = scope.get<String>("label"), tint = scope.color("tint") ?: LocalContentColor.current, modifier = scope.modifier().size(scope.get<Dp>("size") ?: 24.dp))
}
register(BuiltinSchemas.Loading) { scope -> LoadingComponent(scope) }
register(BuiltinSchemas.BottomSheet) { scope -> BottomSheetComponent(scope) }
register(BuiltinSchemas.Divider) { scope ->
HorizontalDivider(
modifier = scope.modifier(),
thickness = scope.get<Dp>("thickness") ?: DividerDefaults.Thickness,
color = scope.color("color") ?: DividerDefaults.color,
)
}
}

// a scrolling axis is unbounded, so there is no remaining space to share: weight is ignored there rather than collapsing the child to 0
Expand Down Expand Up @@ -233,3 +247,30 @@ private fun LoadingComponent(scope: NodeScope) {
color = scope.color("color") ?: LoadingIndicatorDefaults.indicatorColor,
)
}

/**
* `open` is the page's; going false animates the sheet out before it leaves composition. A user dismissal (swipe, scrim,
* back) has already hidden it when `onDismiss` fires: it stays down until `open` goes false and true again.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BottomSheetComponent(scope: NodeScope) {
val open = scope.get<Boolean>("open") == true
var showing by remember { mutableStateOf(false) }
// content-sized, never parked half way
val state = rememberBottomSheetState(initialValue = SheetValue.Hidden, enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded))
LaunchedEffect(open) {
when {
// reopened while the hide was still animating: still composed, so bring it back up
open && showing -> state.show()
open -> showing = true
showing -> { state.hide(); showing = false }
}
}
if (showing) {
ModalBottomSheet(
onDismissRequest = { showing = false; if (scope.has("onDismiss")) scope.dispatch("onDismiss") },
sheetState = state,
) { scope.Children() }
}
}
Loading
Loading