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
6 changes: 6 additions & 0 deletions compose/api/compose.klib.api
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,8 @@ 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 BackHandler // app.tinyui.components.generated/BuiltinSchemas.BackHandler|{}BackHandler[0]
final fun <get-BackHandler>(): app.tinyui.schema/ComponentSchema // app.tinyui.components.generated/BuiltinSchemas.BackHandler.<get-BackHandler>|<get-BackHandler>(){}[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]
Expand All @@ -703,6 +705,8 @@ final object app.tinyui.components.generated/BuiltinSchemas { // app.tinyui.comp
final fun <get-LazyColumn>(): app.tinyui.schema/ComponentSchema // app.tinyui.components.generated/BuiltinSchemas.LazyColumn.<get-LazyColumn>|<get-LazyColumn>(){}[0]
final val Loading // app.tinyui.components.generated/BuiltinSchemas.Loading|{}Loading[0]
final fun <get-Loading>(): app.tinyui.schema/ComponentSchema // app.tinyui.components.generated/BuiltinSchemas.Loading.<get-Loading>|<get-Loading>(){}[0]
final val Menu // app.tinyui.components.generated/BuiltinSchemas.Menu|{}Menu[0]
final fun <get-Menu>(): app.tinyui.schema/ComponentSchema // app.tinyui.components.generated/BuiltinSchemas.Menu.<get-Menu>|<get-Menu>(){}[0]
final val Option // app.tinyui.components.generated/BuiltinSchemas.Option|{}Option[0]
final fun <get-Option>(): app.tinyui.schema/ComponentSchema // app.tinyui.components.generated/BuiltinSchemas.Option.<get-Option>|<get-Option>(){}[0]
final val Popover // app.tinyui.components.generated/BuiltinSchemas.Popover|{}Popover[0]
Expand All @@ -719,6 +723,8 @@ final object app.tinyui.components.generated/BuiltinSchemas { // app.tinyui.comp
final fun <get-Text>(): app.tinyui.schema/ComponentSchema // app.tinyui.components.generated/BuiltinSchemas.Text.<get-Text>|<get-Text>(){}[0]
final val TextField // app.tinyui.components.generated/BuiltinSchemas.TextField|{}TextField[0]
final fun <get-TextField>(): app.tinyui.schema/ComponentSchema // app.tinyui.components.generated/BuiltinSchemas.TextField.<get-TextField>|<get-TextField>(){}[0]
final val TopAppBar // app.tinyui.components.generated/BuiltinSchemas.TopAppBar|{}TopAppBar[0]
final fun <get-TopAppBar>(): app.tinyui.schema/ComponentSchema // app.tinyui.components.generated/BuiltinSchemas.TopAppBar.<get-TopAppBar>|<get-TopAppBar>(){}[0]
final val all // app.tinyui.components.generated/BuiltinSchemas.all|{}all[0]
final fun <get-all>(): kotlin.collections/List<app.tinyui.schema/ComponentSchema> // app.tinyui.components.generated/BuiltinSchemas.all.<get-all>|<get-all>(){}[0]
final val colorTokens // app.tinyui.components.generated/BuiltinSchemas.colorTokens|{}colorTokens[0]
Expand Down
2 changes: 2 additions & 0 deletions compose/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ kotlin {
api(libs.compose.runtime.retain)
implementation(compose.foundation)
implementation(libs.compose.material3)
// BackHandler takes system back through the same dispatcher as the host's navigation
implementation(libs.navigationevent.compose)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
api(libs.okio)
Expand Down
83 changes: 83 additions & 0 deletions compose/src/commonMain/kotlin/app/tinyui/components/Builtins.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
Expand All @@ -12,6 +13,8 @@ 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.layout.WindowInsets
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
Expand All @@ -29,6 +32,7 @@ import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
Expand All @@ -52,8 +56,11 @@ import androidx.compose.material3.SheetValue
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
Expand Down Expand Up @@ -102,6 +109,11 @@ import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import androidx.navigationevent.DirectNavigationEventInput
import androidx.navigationevent.NavigationEventInfo
import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner
import androidx.navigationevent.compose.NavigationBackHandler
import androidx.navigationevent.compose.rememberNavigationEventState
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
Expand All @@ -112,6 +124,7 @@ import app.tinyui.schema.Commands
import app.tinyui.schema.IconValue
import app.tinyui.schema.ComponentRegistry
import app.tinyui.schema.NodeScope
import app.tinyui.schema.SizeValue
import app.tinyui.schema.Theme

/** First batch of built-ins (docs/components.md §3); schemas come from schema/ via `tinyui schema`. */
Expand Down Expand Up @@ -194,6 +207,9 @@ fun ComponentRegistry.registerBuiltins(): ComponentRegistry = apply {
register(BuiltinSchemas.Loading) { scope -> LoadingComponent(scope) }
register(BuiltinSchemas.BottomSheet) { scope -> BottomSheetComponent(scope) }
register(BuiltinSchemas.Popover) { scope -> PopoverComponent(scope) }
register(BuiltinSchemas.TopAppBar) { scope -> TopAppBarComponent(scope) }
register(BuiltinSchemas.BackHandler) { scope -> BackHandlerComponent(scope) }
register(BuiltinSchemas.Menu) { scope -> MenuComponent(scope) }
register(BuiltinSchemas.Divider) { scope ->
HorizontalDivider(
modifier = scope.modifier(),
Expand Down Expand Up @@ -558,6 +574,73 @@ private fun PopoverComponent(scope: NodeScope) {

private val POPOVER_GAP = 8.dp

/**
* M3 small top app bar under the status bar; the children are the actions. The status bar padding sits outside the
* common modifier, so `height` sizes the bar itself; inset padding consumed by the host already (a Scaffold) is not
* added again. The back button injects a back into the system dispatcher, as a toolbar calls
* `onBackPressedDispatcher.onBackPressed()`: whatever would take system back takes it (a `BackHandler`, the host's
* navigation). Without a dispatcher there is no system back, so it does nothing, like a `BackHandler`.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TopAppBarComponent(scope: NodeScope) {
val container = scope.color("background") ?: MaterialTheme.colorScheme.surface
val backIcon = scope.get<IconValue>("backIcon")
val dispatcher = LocalNavigationEventDispatcherOwner.current?.navigationEventDispatcher
val input = remember { DirectNavigationEventInput() }
if (dispatcher != null) {
DisposableEffect(dispatcher, input) {
dispatcher.addInput(input)
onDispose { dispatcher.removeInput(input) }
}
}
Box(Modifier.background(container).windowInsetsPadding(TopAppBarDefaults.windowInsets)) {
TopAppBar(
title = { scope.get<String>("title")?.takeIf { it.isNotEmpty() }?.let { Text(it, maxLines = 1, overflow = TextOverflow.Ellipsis) } },
navigationIcon = {
if (backIcon != null) {
IconButton(onClick = { if (dispatcher != null) input.backCompleted() }) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[建议] 这里在 dispatcher == null 时不再执行任何操作,使得配置了 backIcon 的顶栏返回按钮在无 dispatcher 的宿主中必然无响应。PR 描述明确约定此场景应退回 Navigator.pop(null, from);应恢复通过 LocalClosePage 的兜底关闭,或者重新确认并更新 PR 的行为约定。

Icon(backIcon.vector, contentDescription = scope.get<String>("backLabel"))
}
}
},
actions = { scope.Children() },
// the insets are padded above, outside the height the page gives
windowInsets = WindowInsets(0),
expandedHeight = (scope.get<SizeValue>("height") as? SizeValue.Fixed)?.dp ?: TopAppBarDefaults.TopAppBarExpandedHeight,
colors = TopAppBarDefaults.topAppBarColors(containerColor = container),
modifier = scope.modifier(),
)
}
}

/** Takes back from the system dispatcher while enabled, which is also where the `TopAppBar` button sends it. */
@Composable
private fun BackHandlerComponent(scope: NodeScope) {
// without a dispatcher there is no system back to take, from the gesture or the top bar button
if (LocalNavigationEventDispatcherOwner.current == null) return
val enabled = (scope.get<Boolean>("enabled") ?: true) && scope.has("onBack")
NavigationBackHandler(state = rememberNavigationEventState(NavigationEventInfo.None), isBackEnabled = enabled, onBackCompleted = { scope.dispatch("onBack") })
}

/**
* M3 dropdown menu anchored to the parent node, like [PopoverComponent]; the items are the `Option` children, read as
* data. `open` is the page's, but a pick or a tap outside closes it here at once and is then reported, as
* [BottomSheetComponent] does: it stays closed until `open` goes false and true again.
*/
@Composable
private fun MenuComponent(scope: NodeScope) {
val open = scope.get<Boolean>("open") == true
var showing by remember { mutableStateOf(false) }
LaunchedEffect(open) { showing = open }
val options = scope.node.children.filter { it.type == "Option" }.map { (it.props["value"] as? String ?: "") to (it.props["text"] as? String ?: "") }
DropdownMenu(expanded = showing, onDismissRequest = { showing = false; if (scope.has("onDismiss")) scope.dispatch("onDismiss") }) {
for ((value, text) in options) {
DropdownMenuItem(text = { Text(text) }, onClick = { showing = false; if (scope.has("onSelect")) scope.dispatch("onSelect", payload("value", value)) })
}
}
}

/**
* Where a popover of [content] size goes: on the [above] / below side of [anchor] with [gap] px between, start edges
* aligned. The preferred side wins when it fits, else the other side when that fits, else the roomier side; the result
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,51 @@ object BuiltinSchemas {
layout = false,
)

val all: List<ComponentSchema> = listOf(Column, Row, Box, Text, Button, RadioButton, TextField, LazyColumn, Spacer, Icon, Loading, BottomSheet, Divider, Checkbox, CodeField, Select, Option, Popover)
val TopAppBar = ComponentSchema(
type = "TopAppBar",
props = mapOf(
"title" to PropSpec.Str(default = null, required = false, initial = false),
"backIcon" to PropSpec.IconSpec(required = false, initial = false),
"backLabel" to PropSpec.Str(default = null, required = false, initial = false),
),
events = mapOf(
),
commands = mapOf(
),
children = true,
layout = true,
)

val BackHandler = ComponentSchema(
type = "BackHandler",
props = mapOf(
"enabled" to PropSpec.Bool(default = true, required = false, initial = false),
),
events = mapOf(
"onBack" to emptyMap(),
),
commands = mapOf(
),
children = false,
layout = false,
)

val Menu = ComponentSchema(
type = "Menu",
props = mapOf(
"open" to PropSpec.Bool(default = null, required = true, initial = false),
),
events = mapOf(
"onSelect" to mapOf("value" to FieldSpec.Str),
"onDismiss" to emptyMap(),
),
commands = mapOf(
),
children = true,
layout = false,
)

val all: List<ComponentSchema> = listOf(Column, Row, Box, Text, Button, RadioButton, TextField, LazyColumn, Spacer, Icon, Loading, BottomSheet, Divider, Checkbox, CodeField, Select, Option, Popover, TopAppBar, BackHandler, Menu)

/** Theme token names the schema DSL knows; Theme.kt must resolve each (checked by test). */
val colorTokens: Set<String> = setOf("primary", "onPrimary", "primaryContainer", "onPrimaryContainer", "inversePrimary", "secondary", "onSecondary", "secondaryContainer", "onSecondaryContainer", "tertiary", "onTertiary", "tertiaryContainer", "onTertiaryContainer", "background", "onBackground", "surface", "onSurface", "surfaceVariant", "onSurfaceVariant", "surfaceTint", "inverseSurface", "inverseOnSurface", "error", "onError", "errorContainer", "onErrorContainer", "outline", "outlineVariant", "scrim", "surfaceBright", "surfaceDim", "surfaceContainer", "surfaceContainerHigh", "surfaceContainerHighest", "surfaceContainerLow", "surfaceContainerLowest", "primaryFixed", "primaryFixedDim", "onPrimaryFixed", "onPrimaryFixedVariant", "secondaryFixed", "secondaryFixedDim", "onSecondaryFixed", "onSecondaryFixedVariant", "tertiaryFixed", "tertiaryFixedDim", "onTertiaryFixed", "onTertiaryFixedVariant")
Expand Down

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion compose/src/commonTest/kotlin/app/tinyui/PageHostTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ class PageHostTest {
val host = host("""{"name":"tinyui"}""")
host.start()
host.await { host.tree.root.children.isNotEmpty() }
assertEquals("hello tinyui / 18", host.tree.node(1)!!.props["text"])
assertEquals("hello tinyui / 21", host.tree.node(1)!!.props["text"])
assertNull(host.failure)
host.close()
}
Expand Down
Loading
Loading