diff --git a/compose/api/compose.klib.api b/compose/api/compose.klib.api index 1040f48..4be1d73 100644 --- a/compose/api/compose.klib.api +++ b/compose/api/compose.klib.api @@ -51,6 +51,7 @@ abstract interface wang.harlon.tinyui.schema/NodeScope { // wang.harlon.tinyui.s 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(kotlin/Boolean, androidx.compose.runtime/Composer?, kotlin/Int): androidx.compose.ui/Modifier // wang.harlon.tinyui.schema/NodeScope.modifier|modifier(kotlin.Boolean;androidx.compose.runtime.Composer?;kotlin.Int){}[0] + open fun Children(kotlin/Function1, androidx.compose.runtime/Composer?, kotlin/Int) // wang.harlon.tinyui.schema/NodeScope.Children|Children(kotlin.Function1;androidx.compose.runtime.Composer?;kotlin.Int){}[0] open fun color(kotlin/String, androidx.compose.runtime/Composer?, kotlin/Int): androidx.compose.ui.graphics/Color? // wang.harlon.tinyui.schema/NodeScope.color|color(kotlin.String;androidx.compose.runtime.Composer?;kotlin.Int){}[0] final class ComposeDefaultImpls { // wang.harlon.tinyui.schema/NodeScope.ComposeDefaultImpls|null[0] diff --git a/compose/src/commonMain/kotlin/wang/harlon/tinyui/PageHost.kt b/compose/src/commonMain/kotlin/wang/harlon/tinyui/PageHost.kt index bc6391d..4952d7c 100644 --- a/compose/src/commonMain/kotlin/wang/harlon/tinyui/PageHost.kt +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/PageHost.kt @@ -1,6 +1,7 @@ package wang.harlon.tinyui import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth @@ -10,7 +11,9 @@ 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.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf @@ -18,6 +21,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers @@ -50,7 +54,9 @@ import wang.harlon.quickjs.JsValue import wang.harlon.quickjs.ObjectTransport import wang.harlon.tinyui.node.NodeTree import wang.harlon.tinyui.node.UINode +import androidx.compose.ui.graphics.RectangleShape import wang.harlon.tinyui.schema.ComponentRegistry +import wang.harlon.tinyui.schema.LocalChildModifier import wang.harlon.tinyui.schema.NodeScope import wang.harlon.tinyui.schema.SizeValue @@ -316,26 +322,34 @@ class PageHost( override fun has(event: String): Boolean = node.events[event] == true override fun dispatch(event: String, payload: String) = this@PageHost.dispatch(node.id, event, payload) - /** docs/components.md §2: size → clip → background → gesture → padding. */ + /** docs/components.md §2: parent-scoped (weight) → size → clip → background → border → gesture → padding. */ @Composable override fun modifier(clickable: Boolean): Modifier { - var m: Modifier = Modifier + var m: Modifier = LocalChildModifier.current 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)) } + val shape = get("cornerRadius")?.let { RoundedCornerShape(it) } ?: RectangleShape + if (shape != RectangleShape) m = m.clip(shape) color("background")?.let { m = m.background(it) } + // 0.dp is Compose's hairline (one pixel), not "no border" + get("borderWidth")?.takeIf { it > 0.dp }?.let { m = m.border(it, color("borderColor") ?: MaterialTheme.colorScheme.outline, shape) } if (clickable && 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) } + override fun Children() = Children { Modifier } + + @Composable + override fun Children(childModifier: (UINode) -> Modifier) { + for (child in node.children) key(child.id) { + CompositionLocalProvider(LocalChildModifier provides childModifier(child)) { Render(child) } + } } @Composable - override fun RenderChild(child: UINode) = Render(child) + override fun RenderChild(child: UINode) = CompositionLocalProvider(LocalChildModifier provides Modifier) { Render(child) } } @Composable 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 40515e9..3278d56 100644 --- a/compose/src/commonMain/kotlin/wang/harlon/tinyui/components/Builtins.kt +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/components/Builtins.kt @@ -1,5 +1,6 @@ package wang.harlon.tinyui.components +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -8,6 +9,8 @@ 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.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button @@ -26,6 +29,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged @@ -45,6 +49,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import wang.harlon.tinyui.components.generated.BuiltinSchemas +import wang.harlon.tinyui.node.UINode import wang.harlon.tinyui.schema.Commands import wang.harlon.tinyui.schema.ComponentRegistry import wang.harlon.tinyui.schema.NodeScope @@ -53,18 +58,20 @@ import wang.harlon.tinyui.schema.Theme /** First batch of built-ins (docs/components.md §3); schemas come from schema/ via `tinyui schema`. */ fun ComponentRegistry.registerBuiltins(): ComponentRegistry = apply { register(BuiltinSchemas.Column) { scope -> + val scroll = scope.get("scroll") == true Column( - modifier = scope.modifier(), + modifier = scope.modifier().then(if (scroll) Modifier.verticalScroll(rememberScrollState()) else Modifier), verticalArrangement = verticalArrangement(scope["justify"], scope["gap"]), horizontalAlignment = when (scope.get("align")) { "center" -> Alignment.CenterHorizontally; "end" -> Alignment.End; else -> Alignment.Start }, - ) { scope.Children() } + ) { scope.Children { child -> if (scroll) Modifier else weightOf(child)?.let { Modifier.weight(it) } ?: Modifier } } } register(BuiltinSchemas.Row) { scope -> + val scroll = scope.get("scroll") == true Row( - modifier = scope.modifier(), + modifier = scope.modifier().then(if (scroll) Modifier.horizontalScroll(rememberScrollState()) else Modifier), horizontalArrangement = horizontalArrangement(scope["justify"], scope["gap"]), verticalAlignment = when (scope.get("align")) { "center" -> Alignment.CenterVertically; "end" -> Alignment.Bottom; else -> Alignment.Top }, - ) { scope.Children() } + ) { scope.Children { child -> if (scroll) Modifier else weightOf(child)?.let { Modifier.weight(it) } ?: Modifier } } } register(BuiltinSchemas.Box) { scope -> Box(modifier = scope.modifier(), contentAlignment = boxAlignment(scope["align"])) { scope.Children() } @@ -99,6 +106,9 @@ fun ComponentRegistry.registerBuiltins(): ComponentRegistry = apply { register(BuiltinSchemas.Spacer) { scope -> Spacer(scope.modifier()) } } +// a scrolling axis is unbounded, so there is no remaining space to share: weight is ignored there rather than collapsing the child to 0 +private fun weightOf(child: UINode): Float? = (child.props["weight"] as? Double)?.toFloat()?.takeIf { it > 0f } + private fun verticalArrangement(justify: String?, gap: Dp?): Arrangement.Vertical { val spaced = gap != null && gap > 0.dp return when (justify) { 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 index b57f326..72f2085 100644 --- a/compose/src/commonMain/kotlin/wang/harlon/tinyui/components/generated/BuiltinSchemas.kt +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/components/generated/BuiltinSchemas.kt @@ -12,6 +12,7 @@ object BuiltinSchemas { "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), + "scroll" to PropSpec.Bool(default = false, required = false, initial = false), ), events = mapOf( "onClick" to emptyMap(), @@ -28,6 +29,7 @@ object BuiltinSchemas { "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), + "scroll" to PropSpec.Bool(default = false, required = false, initial = false), ), events = mapOf( "onClick" to emptyMap(), 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 cd24402..745e391 100644 --- a/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/ComponentSchema.kt +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/ComponentSchema.kt @@ -118,10 +118,13 @@ class ComponentSchema( /** The common layout props; the order here is the Modifier order (docs/components.md §2). */ object LayoutProps { val specs: Map = linkedMapOf( + "weight" to PropSpec.Num(default = null), "width" to PropSpec.Size(default = null), "height" to PropSpec.Size(default = null), "cornerRadius" to PropSpec.Dp(default = null), "background" to PropSpec.ColorSpec(default = null), + "borderWidth" to PropSpec.Dp(default = null), + "borderColor" to PropSpec.ColorSpec(default = null), "padding" to PropSpec.Dp(default = null), ) } 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 1c0dda1..19ae3c4 100644 --- a/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/NodeScope.kt +++ b/compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/NodeScope.kt @@ -2,6 +2,7 @@ package wang.harlon.tinyui.schema import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -32,6 +33,10 @@ interface NodeScope { @Composable fun Children() + /** Renders the children with a parent-scoped modifier each (`weight` inside Row / Column); it goes outermost on the child. */ + @Composable + fun Children(childModifier: (UINode) -> Modifier) = Children() + /** Renders one child; for components that lay children out themselves (LazyColumn). */ @Composable fun RenderChild(child: UINode) @@ -49,3 +54,7 @@ fun NodeScope.Commands(handler: suspend (Command) -> Unit) { } } } + +/** What the parent wants on the child's outermost modifier; Row / Column set it per child, everyone else resets it. */ +internal val LocalChildModifier = compositionLocalOf { Modifier } + 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 9606a8f..47e419f 100644 --- a/compose/src/commonTest/kotlin/wang/harlon/tinyui/node/NodeTreeTest.kt +++ b/compose/src/commonTest/kotlin/wang/harlon/tinyui/node/NodeTreeTest.kt @@ -32,6 +32,12 @@ class NodeTreeTest { tree.apply("""[["c",1,"Text"],["p",1,"text","t"],["p",1,"color","#FF0000"],["p",1,"fontSize",18],["i",0,1,0]]""") val node = tree.node(1)!! assertEquals(ColorValue.Literal(Color(0xFFFF0000)), node.props["color"]) + tree.apply("""[["p",1,"weight",2],["p",1,"borderWidth",1.5],["p",1,"borderColor","outline"],["p",1,"scroll",true]]""") + assertEquals(2.0, node.props["weight"]) + assertEquals(1.5.dp, node.props["borderWidth"]) + assertEquals(ColorValue.Token("outline"), node.props["borderColor"]) + assertEquals(1, problems.size, "scroll is not a Text prop: ${problems}") + problems.clear() assertEquals(18.sp, node.props["fontSize"]) tree.apply("""[["p",1,"color",null]]""") assertNull(node.props["color"], "null restores the schema default, which Text.color does not have") diff --git a/docs/components.md b/docs/components.md index 0416607..f740055 100644 --- a/docs/components.md +++ b/docs/components.md @@ -34,19 +34,23 @@ prop 类型:`string` / `number` / `boolean` / `dp` / `sp` / `color`(`#RRGGBB ## 2. 公共布局 prop 与 Modifier 顺序 -`layout: true` 的组件都接受这五个,运行时按下面的顺序合成 `Modifier`,从外到内: +`layout: true` 的组件都接受这些,运行时按下面的顺序合成 `Modifier`,从外到内: | 顺序 | prop | 类型 | Modifier | |---|---|---|---| +| 0 | `weight` | number | `weight(f)`,由父 `Row` / `Column` 施加(2026-09-17 加) | | 1 | `width` / `height` | size | `width(dp)` / `fillMaxWidth()` / `wrapContentWidth()` | | 2 | `cornerRadius` | dp | `clip(RoundedCornerShape)` | | 3 | `background` | color | `background(color)` | +| 3.5 | `borderWidth` / `borderColor` | dp / color | `border(width, color, 同一个 shape)`;宽 0 即无边框(Compose 的 0.dp 是一像素 hairline,这里不沿用);色缺省 `outline` token(2026-09-17 加) | | 4 | (有 `onClick` handler 时) | | `clickable { dispatch("onClick") }` | | 5 | `padding` | dp,四边同值 | `padding(dp)` | -顺序固定:以后加 prop 只能插进这个序列,不能重排(重排会改变已有页面的视觉)。`padding` 在最里面,所以它是内容内边距;背景和点击区域包含它。 +顺序固定:以后加 prop 只能插进这个序列,不能重排(重排会改变已有页面的视觉)。`padding` 在最里面,所以它是内容内边距;背景、边框和点击区域包含它。 -不在首批的:`margin`(Compose 没有对应,用父容器的 `gap` / `padding`)、`weight`、`alignSelf`(要父作用域;`Row` / `Column` 里给 child 写 `width="fill"` 会吃掉后面的兄弟,需要按比例分配时等 `weight`)。 +`weight` 需要父作用域:`Row` / `Column` 渲染 children 时按每个 child 的 `weight` 造 `Modifier.weight` 经 CompositionLocal 交给它,child 的 `modifier()` 把它放在最外层;其他容器把这个 local 重置为空,所以写在 `Box` / `LazyColumn` 的直接 child 上不生效、也不报错;`scroll` 开着的 `Column` / `Row` 主轴无界、没有剩余空间可分,其 child 的 `weight` 同样忽略(否则 Compose 会把它压成 0)。 + +不在首批的:`margin`(Compose 没有对应,用父容器的 `gap` / `padding`)、`alignSelf`(等需求)。 ## 3. 首批八个组件 @@ -54,7 +58,7 @@ prop 类型:`string` / `number` / `boolean` / `dp` / `sp` / `color`(`#RRGGBB | 组件 | prop | 事件 | 命令 | children | |---|---|---|---|---| -| `Column` / `Row` | `gap`、`align`(交叉轴 start / center / end)、`justify`(主轴 start / center / end / spaceBetween) | `onClick` | | 是 | +| `Column` / `Row` | `gap`、`align`(交叉轴 start / center / end)、`justify`(主轴 start / center / end / spaceBetween)、`scroll`(沿主轴滚动;`padding` 在滚动内容之外,不随内容滚) | `onClick` | | 是 | | `Box` | `align`(九宫格) | `onClick` | | 是 | | `Text` | `text`(必填)、`style`(M3 文字样式名,§6)、`color`、`fontSize`、`fontWeight`(normal / medium / bold;设了就覆盖 `style` 的对应字段,不设则跟 `style`)、`maxLines`(0 = 不限,超出省略号)、`align` | `onClick` | | | | `Button` | `text`(必填)、`enabled`、`variant`(filled / outlined / text) | `onClick` | | | diff --git a/docs/roadmap.md b/docs/roadmap.md index 56708f3..aac44c5 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -57,7 +57,7 @@ | 应用级服务 Runtime(不挂 UI、随 App 生命周期的引擎) | app-model.md | 业务出现不属于任何页面的常驻 JS 逻辑 | | 手势组合内置组件 | ADR-004 | 业务需求出现 | | `Image` 与图片加载管线(coil3 vs 宿主 loader) | components.md §3 | 出现需要图片的页面 | -| `weight` / `alignSelf` 等需要父作用域的布局 prop | components.md §2 | `Row` / `Column` 里按比例分配的需求 | +| ~~`weight`~~ / `alignSelf` 等需要父作用域的布局 prop | components.md §2 | `weight`、`border`、`Column` / `Row` 的 `scroll` 已加(2026-09-17,TrendingAI 订阅页触发);`alignSelf` 等需求 | ## 建议顺序与里程碑 diff --git a/packages/cli/src/schema/dsl.ts b/packages/cli/src/schema/dsl.ts index cf6cb9a..9a47854 100644 --- a/packages/cli/src/schema/dsl.ts +++ b/packages/cli/src/schema/dsl.ts @@ -101,9 +101,12 @@ export function defineComponent(name: string, input: ComponentInput = {}): Compo /** The common layout props every `layout: true` component accepts; order = Modifier order (docs/components.md §2). */ export const LAYOUT_PROPS: Record = { + weight: number({ doc: "share of the parent's remaining main-axis space; only as a direct child of Row / Column" }), width: size({ doc: "dp, or fill / wrap" }), height: size({ doc: "dp, or fill / wrap" }), cornerRadius: dp({ doc: "clips background and content" }), background: color(), + borderWidth: dp({ doc: "drawn inside the corner radius; color defaults to the outline token" }), + borderColor: color(), padding: dp({ doc: "all four sides" }), }; diff --git a/packages/core/src/generated/components.ts b/packages/core/src/generated/components.ts index a9a8930..c9c1160 100644 --- a/packages/core/src/generated/components.ts +++ b/packages/core/src/generated/components.ts @@ -6,10 +6,13 @@ export type ColorToken = "primary" | "onPrimary" | "primaryContainer" | "onPrima export type ColorValue = `#${string}` | ColorToken; export interface LayoutProps { + weight?: number; width?: number | "fill" | "wrap"; height?: number | "fill" | "wrap"; cornerRadius?: number; background?: ColorValue; + borderWidth?: number; + borderColor?: ColorValue; padding?: number; } @@ -24,6 +27,8 @@ export interface ColumnProps extends LayoutProps { align?: "start" | "center" | "end"; /** main axis */ justify?: "start" | "center" | "end" | "spaceBetween"; + /** scrolls along the main axis; padding stays outside the scrolling content */ + scroll?: boolean; onClick?: (e: ColumnClickPayload) => void; children?: Node | Node[]; } @@ -39,6 +44,8 @@ export interface RowProps extends LayoutProps { align?: "start" | "center" | "end"; /** main axis */ justify?: "start" | "center" | "end" | "spaceBetween"; + /** scrolls along the main axis; padding stays outside the scrolling content */ + scroll?: boolean; onClick?: (e: RowClickPayload) => void; children?: Node | Node[]; } diff --git a/sample/js/src/pages/todos.tsx b/sample/js/src/pages/todos.tsx index 90ca236..b4371cb 100644 --- a/sample/js/src/pages/todos.tsx +++ b/sample/js/src/pages/todos.tsx @@ -63,8 +63,8 @@ export default function Todos() { function TodoRow(props: { todo: Todo; onRemove: () => void }) { return ( - { props.todo.done = !props.todo.done; }}> - + { props.todo.done = !props.todo.done; }}> +