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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,6 @@ packages/*/dist/
sample/iosApp/*.xcodeproj/xcuserdata/
sample/iosApp/*.xcodeproj/project.xcworkspace/xcuserdata/
DerivedData/

# editor swap files
*.swp
55 changes: 53 additions & 2 deletions compose/api/compose.klib.api

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions compose/src/commonMain/kotlin/wang/harlon/tinyui/PageHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ 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.graphics.Color
import androidx.compose.ui.unit.Dp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
Expand Down Expand Up @@ -318,12 +317,13 @@ class PageHost(
override fun dispatch(event: String, payload: String) = this@PageHost.dispatch(node.id, event, payload)

/** docs/components.md §2: size → clip → background → gesture → padding. */
@Composable
override fun modifier(clickable: Boolean): Modifier {
var m: Modifier = Modifier
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)) }
get<Color>("background")?.let { m = m.background(it) }
color("background")?.let { m = m.background(it) }
if (clickable && has("onClick")) m = m.clickable { dispatch("onClick") }
get<Dp>("padding")?.let { m = m.padding(it) }
return m
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
Expand Down Expand Up @@ -46,6 +48,7 @@ import wang.harlon.tinyui.components.generated.BuiltinSchemas
import wang.harlon.tinyui.schema.Commands
import wang.harlon.tinyui.schema.ComponentRegistry
import wang.harlon.tinyui.schema.NodeScope
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 {
Expand All @@ -70,9 +73,10 @@ fun ComponentRegistry.registerBuiltins(): ComponentRegistry = apply {
val maxLines = scope.get<Double>("maxLines")?.toInt()?.takeIf { it > 0 } ?: Int.MAX_VALUE
Text(
text = scope["text"] ?: "",
color = scope["color"] ?: Color.Unspecified,
style = scope.get<String>("style")?.let { Theme.textStyle(MaterialTheme.typography, it) } ?: LocalTextStyle.current,
color = scope.color("color") ?: Color.Unspecified,
fontSize = scope["fontSize"] ?: TextUnit.Unspecified,
fontWeight = when (scope.get<String>("fontWeight")) { "medium" -> FontWeight.Medium; "bold" -> FontWeight.Bold; else -> FontWeight.Normal },
fontWeight = when (scope.get<String>("fontWeight")) { "normal" -> FontWeight.Normal; "medium" -> FontWeight.Medium; "bold" -> FontWeight.Bold; else -> null },
maxLines = maxLines,
overflow = if (maxLines == Int.MAX_VALUE) TextOverflow.Clip else TextOverflow.Ellipsis,
textAlign = when (scope.get<String>("align")) { "center" -> TextAlign.Center; "end" -> TextAlign.End; else -> TextAlign.Start },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,10 @@ object BuiltinSchemas {
type = "Text",
props = mapOf(
"text" to PropSpec.Str(default = null, required = true, initial = false),
"style" to PropSpec.Enum(values = setOf("displayLarge", "displayMedium", "displaySmall", "headlineLarge", "headlineMedium", "headlineSmall", "titleLarge", "titleMedium", "titleSmall", "bodyLarge", "bodyMedium", "bodySmall", "labelLarge", "labelMedium", "labelSmall"), default = null, required = false, initial = false),
"color" to PropSpec.ColorSpec(default = null, required = false, initial = false),
"fontSize" to PropSpec.Sp(default = null, required = false, initial = false),
"fontWeight" to PropSpec.Enum(values = setOf("normal", "medium", "bold"), default = "normal", required = false, initial = false),
"fontWeight" to PropSpec.Enum(values = setOf("normal", "medium", "bold"), default = null, required = false, initial = false),
"maxLines" to PropSpec.Num(default = 0.0, required = false, initial = false),
"align" to PropSpec.Enum(values = setOf("start", "center", "end"), default = "start", required = false, initial = false),
),
Expand Down Expand Up @@ -137,4 +138,8 @@ object BuiltinSchemas {
)

val all: List<ComponentSchema> = listOf(Column, Row, Box, Text, Button, TextField, LazyColumn, Spacer)

/** 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")
val textStyles: Set<String> = setOf("displayLarge", "displayMedium", "displaySmall", "headlineLarge", "headlineMedium", "headlineSmall", "titleLarge", "titleMedium", "titleSmall", "bodyLarge", "bodyMedium", "bodySmall", "labelLarge", "labelMedium", "labelSmall")
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,10 @@ sealed class PropSpec(val kind: String, val required: Boolean, val initial: Bool
override fun convert(value: JsonPrimitive): Any? = value.takeUnless { it.isString }?.doubleOrNull?.sp
}

/** `#RRGGBB` or `#AARRGGBB`. */
/** `#RRGGBB` / `#AARRGGBB`, or a theme token name (Theme.kt). */
class ColorSpec(default: String?, required: Boolean = false, initial: Boolean = false) : PropSpec("color", required, initial) {
override val default: Color? = default?.let(::parseColor)
override fun convert(value: JsonPrimitive): Any? = value.takeIf { it.isString }?.content?.let(::parseColor)
override val default: ColorValue? = default?.let(::parseColorValue)
override fun convert(value: JsonPrimitive): Any? = value.takeIf { it.isString }?.content?.let(::parseColorValue)
}

/** Stored as the enum name; the composable maps it. */
Expand Down Expand Up @@ -87,6 +87,10 @@ enum class FieldSpec {
}
}

internal fun parseColorValue(text: String): ColorValue? =
if (text.startsWith("#")) parseColor(text)?.let { ColorValue.Literal(it) }
else text.takeIf(Theme::isColorToken)?.let { ColorValue.Token(it) }

internal fun parseColor(text: String): Color? {
if (!text.startsWith("#")) return null
val hex = text.substring(1)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import wang.harlon.tinyui.node.Command
import wang.harlon.tinyui.node.UINode

Expand All @@ -20,7 +21,12 @@ interface NodeScope {
/** K2: fire and forget, on any thread. [payload] is the flat JSON object the event schema declares. */
fun dispatch(event: String, payload: String = "{}")

/** A `color` prop with theme tokens resolved against the current MaterialTheme. */
@Composable
fun color(key: String): Color? = get<ColorValue>(key)?.resolve()

/** Layout modifier from the common props; with [clickable] the click gesture is attached when `onClick` is registered. */
@Composable
fun modifier(clickable: Boolean = true): Modifier

@Composable
Expand Down
99 changes: 99 additions & 0 deletions compose/src/commonMain/kotlin/wang/harlon/tinyui/schema/Theme.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package wang.harlon.tinyui.schema

import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.Typography
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle

/** A `color` prop: a literal, or a theme token the host resolves at composition (docs/components.md §6). */
sealed interface ColorValue {
data class Literal(val color: Color) : ColorValue
data class Token(val name: String) : ColorValue

@Composable
fun resolve(): Color = when (this) {
is Literal -> color
is Token -> Theme.color(MaterialTheme.colorScheme, name) ?: Color.Unspecified
}
}

/** Token names → MaterialTheme values. The name lists in the schema DSL (cli `schema/tokens.ts`) must match these. */
object Theme {
fun isColorToken(name: String): Boolean = color(PROBE, name) != null

fun color(scheme: ColorScheme, token: String): Color? = when (token) {
"primary" -> scheme.primary
"onPrimary" -> scheme.onPrimary
"primaryContainer" -> scheme.primaryContainer
"onPrimaryContainer" -> scheme.onPrimaryContainer
"inversePrimary" -> scheme.inversePrimary
"secondary" -> scheme.secondary
"onSecondary" -> scheme.onSecondary
"secondaryContainer" -> scheme.secondaryContainer
"onSecondaryContainer" -> scheme.onSecondaryContainer
"tertiary" -> scheme.tertiary
"onTertiary" -> scheme.onTertiary
"tertiaryContainer" -> scheme.tertiaryContainer
"onTertiaryContainer" -> scheme.onTertiaryContainer
"background" -> scheme.background
"onBackground" -> scheme.onBackground
"surface" -> scheme.surface
"onSurface" -> scheme.onSurface
"surfaceVariant" -> scheme.surfaceVariant
"onSurfaceVariant" -> scheme.onSurfaceVariant
"surfaceTint" -> scheme.surfaceTint
"inverseSurface" -> scheme.inverseSurface
"inverseOnSurface" -> scheme.inverseOnSurface
"error" -> scheme.error
"onError" -> scheme.onError
"errorContainer" -> scheme.errorContainer
"onErrorContainer" -> scheme.onErrorContainer
"outline" -> scheme.outline
"outlineVariant" -> scheme.outlineVariant
"scrim" -> scheme.scrim
"surfaceBright" -> scheme.surfaceBright
"surfaceDim" -> scheme.surfaceDim
"surfaceContainer" -> scheme.surfaceContainer
"surfaceContainerHigh" -> scheme.surfaceContainerHigh
"surfaceContainerHighest" -> scheme.surfaceContainerHighest
"surfaceContainerLow" -> scheme.surfaceContainerLow
"surfaceContainerLowest" -> scheme.surfaceContainerLowest
"primaryFixed" -> scheme.primaryFixed
"primaryFixedDim" -> scheme.primaryFixedDim
"onPrimaryFixed" -> scheme.onPrimaryFixed
"onPrimaryFixedVariant" -> scheme.onPrimaryFixedVariant
"secondaryFixed" -> scheme.secondaryFixed
"secondaryFixedDim" -> scheme.secondaryFixedDim
"onSecondaryFixed" -> scheme.onSecondaryFixed
"onSecondaryFixedVariant" -> scheme.onSecondaryFixedVariant
"tertiaryFixed" -> scheme.tertiaryFixed
"tertiaryFixedDim" -> scheme.tertiaryFixedDim
"onTertiaryFixed" -> scheme.onTertiaryFixed
"onTertiaryFixedVariant" -> scheme.onTertiaryFixedVariant
else -> null
}

fun textStyle(typography: Typography, name: String): TextStyle? = when (name) {
"displayLarge" -> typography.displayLarge
"displayMedium" -> typography.displayMedium
"displaySmall" -> typography.displaySmall
"headlineLarge" -> typography.headlineLarge
"headlineMedium" -> typography.headlineMedium
"headlineSmall" -> typography.headlineSmall
"titleLarge" -> typography.titleLarge
"titleMedium" -> typography.titleMedium
"titleSmall" -> typography.titleSmall
"bodyLarge" -> typography.bodyLarge
"bodyMedium" -> typography.bodyMedium
"bodySmall" -> typography.bodySmall
"labelLarge" -> typography.labelLarge
"labelMedium" -> typography.labelMedium
"labelSmall" -> typography.labelSmall
else -> null
}

private val PROBE = lightColorScheme()
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package wang.harlon.tinyui.node

import androidx.compose.ui.graphics.Color
import wang.harlon.tinyui.schema.ColorValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import wang.harlon.tinyui.components.registerBuiltins
Expand Down Expand Up @@ -30,7 +31,7 @@ class NodeTreeTest {
fun convertsPropsOnWriteAndRestoresDefaultsOnNull() {
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(Color(0xFFFF0000), node.props["color"])
assertEquals(ColorValue.Literal(Color(0xFFFF0000)), node.props["color"])
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package wang.harlon.tinyui.schema

import androidx.compose.material3.Typography
import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color
import kotlinx.serialization.json.JsonPrimitive
import wang.harlon.tinyui.components.generated.BuiltinSchemas
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull

class ThemeTest {
@Test
fun everyTokenTheSchemaDslKnowsResolves() {
val scheme = lightColorScheme()
for (token in BuiltinSchemas.colorTokens) assertNotNull(Theme.color(scheme, token), "color token $token")
val typography = Typography()
for (name in BuiltinSchemas.textStyles) assertNotNull(Theme.textStyle(typography, name), "text style $name")
}

@Test
fun colorPropsTakeLiteralsAndTokensOnly() {
val spec = PropSpec.ColorSpec(default = "primary")
assertEquals(ColorValue.Token("primary"), spec.default)
assertEquals(ColorValue.Literal(Color(0xFF112233)), spec.convert(JsonPrimitive("#112233")))
assertEquals(ColorValue.Token("onSurfaceVariant"), spec.convert(JsonPrimitive("onSurfaceVariant")))
assertNull(spec.convert(JsonPrimitive("Primary")), "token names are case-sensitive")
assertNull(spec.convert(JsonPrimitive("#12")), "malformed literal")
assertNull(spec.convert(JsonPrimitive(1)))
}
}
13 changes: 11 additions & 2 deletions docs/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ defineComponent("TextField", {
});
```

prop 类型:`string` / `number` / `boolean` / `dp` / `sp` / `color`(`#RRGGBB` / `#AARRGGBB`)/ `enumOf([...])` / `size`(数字 dp,或 `"fill"` / `"wrap"`)。选项:`required`、`default`、`initial`(只认创建时的值,之后的写入按 E5 跳过并上报)、`doc`。事件 payload 与命令参数的字段只有 `field.string / number / boolean`,扁平。
prop 类型:`string` / `number` / `boolean` / `dp` / `sp` / `color`(`#RRGGBB` / `#AARRGGBB`,或 §6 的主题 token 名)/ `enumOf([...])` / `size`(数字 dp,或 `"fill"` / `"wrap"`)。选项:`required`、`default`、`initial`(只认创建时的值,之后的写入按 E5 跳过并上报)、`doc`。事件 payload 与命令参数的字段只有 `field.string / number / boolean`,扁平。

## 2. 公共布局 prop 与 Modifier 顺序

Expand All @@ -56,7 +56,7 @@ prop 类型:`string` / `number` / `boolean` / `dp` / `sp` / `color`(`#RRGGBB
|---|---|---|---|---|
| `Column` / `Row` | `gap`、`align`(交叉轴 start / center / end)、`justify`(主轴 start / center / end / spaceBetween) | `onClick` | | 是 |
| `Box` | `align`(九宫格) | `onClick` | | 是 |
| `Text` | `text`(必填)、`color`、`fontSize`、`fontWeight`(normal / medium / bold)、`maxLines`(0 = 不限,超出省略号)、`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` | | |
| `TextField` | `initialText`(initial)、`placeholder`、`singleLine`、`keyboard` | `onChange{text}`、`onCommit{text}`(IME Done 或失焦) | `setText{text}`、`focus`、`blur` | |
| `LazyColumn` | `gap` | `onReachEnd`、`onScrollEnd{index}` | `scrollTo{index}` | 是,通常是一个 `<For>` |
Expand All @@ -73,3 +73,12 @@ prop 类型:`string` / `number` / `boolean` / `dp` / `sp` / `color`(`#RRGGBB
## 5. `Placeholder`

未注册的类型渲染 `Placeholder`,同时 E5 上报。debug 构建(Android `FLAG_DEBUGGABLE`、iOS debug binary)画红框加类型名与 id;release 渲染零尺寸的空 `Box`。

## 6. 主题 token

定于 2026-09-17。**主题色与文字样式只能以 token 名过桥,Kotlin 在组合期从 `MaterialTheme` 解析;JS 永远拿不到主题的具体值。** 深浅切换、宿主换主题色时页面不发任何 patch 就跟着变——主题是宿主的运行时状态,不是页面的数据(与 ADR-004"高频状态留 Kotlin"同一原理)。需要派生色(如 primary 变浅)时由宿主注册成新 token,不在页面里算。

- `color` 类型的 prop 同时接受字面量(`#RRGGBB` / `#AARRGGBB`)与 token 名;生成器出联合类型(`` `#${string}` | ColorToken ``),拼错的 token 编译期报,Kotlin 侧按首字符 `#` 分流。字面量留给品牌色、图表色这类本来就不属于主题的颜色
- token 词表就是 Material 3 的字段名:颜色为 `ColorScheme` 的全部字段,文字样式为 `Typography` 的十五个名字(cli `schema/tokens.ts` 是词表真值,`compose/.../schema/Theme.kt` 逐一解析,测试保证两边一致)
- 暂不做:宿主自定义 token(触发条件:某宿主出现 M3 之外的语义色要跨页复用,届时走清单下发 + 宿主类型包生成,与宿主组件同一条路);间距 / 圆角 / 阴影的 token

1 change: 1 addition & 0 deletions packages/cli/src/schema/dsl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export const number = (o: PropOptions & { default?: number } = {}) => prop("numb
export const boolean = (o: PropOptions & { default?: boolean } = {}) => prop("boolean", o);
export const dp = (o: PropOptions & { default?: number } = {}) => prop("dp", o);
export const sp = (o: PropOptions & { default?: number } = {}) => prop("sp", o);
/** `#RRGGBB` / `#AARRGGBB`, or a theme token name (tokens.ts). */
export const color = (o: PropOptions & { default?: string } = {}) => prop("color", o);
/** A number in dp, or "fill" / "wrap". */
export const size = (o: PropOptions & { default?: number | "fill" | "wrap" } = {}) => prop("size", o);
Expand Down
Loading
Loading