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 compose/api/compose.klib.api
Original file line number Diff line number Diff line change
Expand Up @@ -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<wang.harlon.tinyui.node/UINode, androidx.compose.ui/Modifier>, androidx.compose.runtime/Composer?, kotlin/Int) // wang.harlon.tinyui.schema/NodeScope.Children|Children(kotlin.Function1<wang.harlon.tinyui.node.UINode,androidx.compose.ui.Modifier>;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]
Expand Down
26 changes: 20 additions & 6 deletions compose/src/commonMain/kotlin/wang/harlon/tinyui/PageHost.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -10,14 +11,17 @@ 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
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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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<SizeValue>("width")) { is SizeValue.Fixed -> m = m.width(w.dp); SizeValue.Fill -> m = m.fillMaxWidth(); SizeValue.Wrap -> m = m.wrapContentWidth(); null -> {} }
when (val h = get<SizeValue>("height")) { is SizeValue.Fixed -> m = m.height(h.dp); SizeValue.Fill -> m = m.fillMaxHeight(); SizeValue.Wrap -> m = m.wrapContentHeight(); null -> {} }
get<Dp>("cornerRadius")?.let { m = m.clip(RoundedCornerShape(it)) }
val shape = get<Dp>("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<Dp>("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<Dp>("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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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<Boolean>("scroll") == true
Column(
modifier = scope.modifier(),
modifier = scope.modifier().then(if (scroll) Modifier.verticalScroll(rememberScrollState()) else Modifier),
Comment thread
HarlonWang marked this conversation as resolved.
verticalArrangement = verticalArrangement(scope["justify"], scope["gap"]),
horizontalAlignment = when (scope.get<String>("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<Boolean>("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<String>("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() }
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, PropSpec> = 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),
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Comment thread
HarlonWang marked this conversation as resolved.

/** Renders one child; for components that lay children out themselves (LazyColumn). */
@Composable
fun RenderChild(child: UINode)
Expand All @@ -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> { Modifier }

Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
12 changes: 8 additions & 4 deletions docs/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,27 +34,31 @@ 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. 首批八个组件

`onClick` 所有容器和 `Text` 都有。事件的 payload 字段、命令的参数字段见 `schema/components/*.ts`,这里只列名字。

| 组件 | 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` | | |
Expand Down
2 changes: 1 addition & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 等需求 |

## 建议顺序与里程碑

Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/schema/dsl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, PropDef> = {
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" }),
};
7 changes: 7 additions & 0 deletions packages/core/src/generated/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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[];
}
Expand All @@ -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[];
}
Expand Down
4 changes: 2 additions & 2 deletions sample/js/src/pages/todos.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ export default function Todos() {

function TodoRow(props: { todo: Todo; onRemove: () => void }) {
return (
<Row width="fill" gap={8} align="center" justify="spaceBetween" padding={8} background={props.todo.done ? "secondaryContainer" : "surfaceContainer"} cornerRadius={8} onClick={() => { props.todo.done = !props.todo.done; }}>
<Text text={props.todo.title} style="bodyLarge" color={props.todo.done ? "onSurfaceVariant" : "onSurface"} />
<Row width="fill" gap={8} align="center" padding={8} background={props.todo.done ? "secondaryContainer" : "surfaceContainer"} cornerRadius={8} borderWidth={props.todo.done ? 2 : 0} borderColor="primary" onClick={() => { props.todo.done = !props.todo.done; }}>
<Text text={props.todo.title} style="bodyLarge" color={props.todo.done ? "onSurfaceVariant" : "onSurface"} weight={1} />
<Button text="remove" variant="text" onClick={props.onRemove} />
</Row>
);
Expand Down
Loading
Loading