前端化设计:样式 / 组件 / 响应式 / 视图
本文件是设计文档:写清楚 native-gui 往 Vue 靠的四件事各自做成什么样、边界在哪、分几批落地。实施细节与实测坑按仓库惯例回写 docs/zh/adaptation.md。
0. 目标与三条原则
- 简单优先,向前端直觉靠。 命名一律取前端最泛的那套写法(
view/row/col、color/background_color、class=@style.card),不沿用 desktop GUI 的历史叫法 (container/vbox/picker/fg/bg)。 - 平台原生做不到的不强推。 CSS 是参照系不是目标:布局是 flex +
absolute/relative定位(底层是 Yoga),做不到的东西在类型里根本不存在——不支持的属性名连字符串都不出现,见 §1.2。 - 能在编译期查的就不放到运行时。 MoonBit 无 Proxy、无反射、无 eval,也没有宏/插件 编译与编译期嵌文件能力(实测见 §4),所以视图写 MoonBit 函数、样式写 MoonBit 值、 响应式走显式信号——类型错误与拼错名字都由 moon 报,不自己实现任何运行时求值或 外部 codegen。
1. 样式层:样式表写在 MoonBit 里
样式落在新包 NoahLiu/native-gui/style(gui/style),theme 只管色板 token, ui 只管组件,三包职责不混。
样式只有一种形态:MoonBit 值。 一个规则块 = 一次 style(...) 调用,一个 class = 一个具名 Style 常量。属性名即参数名,因此同时进补全和编译期检查。不提供 .css 文本入口——同一批属性只有一套名字。旧 .css 资产按 §1.1 的参数名人工对照搬迁,认不出的 名字丢掉。构造形态是 kwargs 工厂 + 状态方法(工具链依据见 §1.6)。
1.1 属性集合(字段名 = CSS 属性名,连字符写下划线)
值:
- 长度:
Double,单位是逻辑像素(px即默认单位);百分比不引入值类型,只在width/height上各开一条*_percent入口(定位组 inset 需要时同此形态,§1.5)。 - 颜色:一个字符串参数搞定,
#开头按 hex、命名色按 named、其余按 theme token 名(§1.4), 三种来源在包内分别是Color::Hex/Named/Var。
| 分组 | 字段(对应 CSS 属性) | 落到哪 |
|---|---|---|
| 文本与颜色 | color、background_color、font_size、font_family、font_weight、text_align、line_height、letter_spacing、text_decoration、white_space | 全部在 ui/base.mbt 的 text_eff 合流(样式给了用样式的,没给回落文本源),绘制期读 style_for(w)、测量期读 resolved_by_id(w.id):font_* 与 white_space 参与测量,text_align 定绘制对齐,line_height 覆盖单行行盒高,text_decoration=underline 由 ui 自绘一条基线下 1px 线(文本层 range 只有 font+color)。letter_spacing 是唯一还没接的:要文本层先开字距接口(Pango/DWrite 都有该能力,本层未暴露) |
| 尺寸 | width、height、min_width、min_height、max_width、max_height | Yoga 节点(set_width / set_min_width …) |
| 内外边距 | padding(只表示四边同值)、padding_left/right/top/bottom、margin、margin_* | Yoga(set_padding / set_margin)。不做 CSS 的 2/3 值简写(padding: 8px 16px 那种按边数变语义的写法):要么四边同值,要么逐边单给,档位就这两档 |
| 边框圆角 | border_width、border_color、border_radius | 绘制阶段;圆角矩形复用 ui/basic.mbt 的 fill_round_rect |
| flex 布局 | display(flex / none)、flex_direction、flex_wrap(nowrap / wrap / wrap-reverse)、justify_content、align_items、align_self、flex(grow)、gap、row_gap、column_gap | Yoga |
| 定位 | position(relative / absolute)、top、right、bottom、left、z_index | Yoga(set_position / set_top …底层现成,含百分比 inset;z_index 由 yoga-mbt 随 P1 补齐,见 §5)。Style 首批只开逻辑像素值,百分比入口同 width_percent 形态另开 |
| 状态 | hover、active、focus(各是一个嵌套 Style) | 绘制期按控件状态选桶,见 §1.3 |
| 效果 | opacity(投影档 box_shadow 已于 2026-10-15 撤除) | opacity 落 Widget.alpha_paint(绘制遍历累乘)。投影曾做过两版:先是一圈等浓度描边(突兀),后改同心外扩分层填充 + CSS 四位 blur/x/y/color([Painter::drop_shadow]),真机复验仍判「不如人意」,用户拍板整条撤掉——字段、渲染入口、Widget.shadow_paint 与裁剪/剔除放大一并删除,将来有真高斯 blur 再重新评估;过程与读数见 docs/zh/adaptation.md「阴影」节 |
| 过渡 | transition_ms(状态桶切换的时长,毫秒) | 只在绘制期生效:style_for 的出口按真实时间把「当前显示值 → 目标桶值」插一帧出来。参与插值的是 color、background_color、border_color、border_width、border_radius、字号 font_size 与行高 line_height(后两条 2026-10-15 补:用户反馈「字号动画不是动画,是直接的变化」;CSS 里这两条都是可动画的 <length>)——字号/行高是绘制与测量共用的属性,测量期一并读同一份插值值(resolved_for_measure),否则字放大后盒子还按旧字号量、看着仍像跳变;本栈每帧本来就走一圈 relayout,插值它们不额外排一次。这两条在动画期间改走整窗置脏(局部脏区按上一帧的矩形并,长大那圈会被裁掉),其余属性仍局部置脏。opacity 的生效点在 apply 落 Widget 字段(绘制遍历不逐帧读槽),尺寸类布局属性切换即生效 |
| 其它 | visibility、cursor | 宿主 Widget(set_visible / cursor) |
1.2 没有的属性(写不出来,而不是运行时报错)
grid 相关(grid_template_*、grid_area)、@media、后代/子代/兄弟选择器、id 与属性 选择器、通配符、!important、CSS 的自动继承——Style 里没有对应字段,setter 也没有 同名方法,调用点写不出来。
1.3 组合、状态与优先级
- 组合:
class? : Array[Style](也可传单值),组件内按顺序Style::merge; 优先级class> 散参数(散参数折成一个匿名Style排在最前,见 §1.5)。 - 覆盖顺序:数组里后出现的覆盖先出现的,且逐属性取并集——只覆盖写过的字段, 没写的字段保留前一条的值;
hover/active/focus三个状态桶递归按同一条规则合并, 不是整块互换。承载优先级的只有调用点的数组顺序:常量彼此的定义先后不承载优先级。 - 样式表粒度:App 级一份,与
theme同为进程级单份(参照theme/theme.mbt的theme_box),不提供每窗各叠一份。class是值、随调用点传,不需要注册表。 - 状态:
hover/active/focus三个状态桶,状态长在块上,不长在属性名上 (W3C 规则模型)。两条入口分工明确:工厂的状态参数收整块(hover=@style.card_hover, 复用具名块);状态方法.hover(...)/.active(...)/.focus(...)收属性参数现场 开块。签名里只有绘制期属性,布局参数不在其列,布局值进状态块连编译都过不了(hover 改布局需要布局期也选桶,随 P3 帧末 relayout 再评估)。存储端是三个嵌套Style字段, 绘制期按Widget已有的hovered/pressed/focused选桶。因此没有伪类字符串、 没有状态机、也不依赖事件传播。三桶可同时命中(对应 CSS 的层叠),合并/覆盖按桶内逐属性 递归(上条)。 - 元素级基调:没有「元素选择器」这个机制;同名基调就是一个常量 (
pub let button_base : Style = ...),组件在自己的默认值里引用它,使用者传class即覆盖——效果与 CSS 的元素选择器一致,但可跳转、可补全。 - 基调表发全量:内置常量表覆盖 §2 全部组件的默认观感;组件库自己也从
class拿样式, 组件内不再自持颜色/圆角/间距/字号的硬编码分支(现状是on_draw里直接读@theme.theme_fill_hover()那类),一律读控件上已解析好的Style。因此 P1 的产出是 「新包 + 全部组件的绘制路径」,见 §1.5 的Widget样式槽一条与 §5。 - 不设
!important通道。
// class=[card, primary]:数组尾赢,逐属性取并集
@ui.button("保存", class=[@style.card, @style.primary], on_click=on_save)
// 规则块 = kwargs 调用:属性名即参数名。前端对照:
// .primary { background-color: primary; color: #fff }
// .primary:hover { background-color: primary_hover }
pub let primary : Style = @style.style(
background_color="primary", // token 名;`#` 开头按 hex、命名色按 named(§1.4)
color="#fff",
border_radius=4.0,
cursor=@widget.CURSOR_HAND,
)
.hover(background_color="primary_hover") // 状态方法现场开块;active(...) / focus(...) 同形
// 具名块复用:工厂的状态参数收整块(W3C 的「预定义规则」)
pub let card_hover : Style = @style.style(background_color="primary_hover")
pub let primary_v2 : Style = @style.style(
background_color="primary",
hover=@style.card_hover,
)1.4 主题联动
Color::Var("primary") 存的是 token 名,落色发生在绘制期,主题切换后随重绘生效—— 样式表不固化颜色快照。现状同口径可参照:TextSource::fg 空色现取 @theme.theme_text_regular()(ui/base.mbt:55-61),主题变化经一次进程级订阅请求重绘 (ui/base.mbt:128)。
token 名单不另设一套,就是 Theme 的 20 个颜色字段加一个尺寸:primary、 primary_light、primary_hover、danger、danger_light、danger_hover、success、 success_light、warning、warning_light、info、info_light、text_primary、 text_regular、text_secondary、border、fill_hover、fill_zebra、bg_page、 bg_panel(theme/theme.mbt 的 Theme 字段),另有 control_height (theme.theme.control_height,32.0)。写法就是 .background_color("bg_panel")——没有 var(--…) 那层壳,token 名不认识时在应用启动一次性出声(§1.5 末条)。
1.5 API 形态
形态:字段是 Option(
merge靠「非空才覆盖」、apply靠「None不动」),但调用点不出现Some、也不出现值类型构造名——属性名 = CSS 属性名(连字符写下划线),值 = 基元,Some、 单位、四边展开都关在style包里。构造走 kwargs:可选 labelled 参数(width? : Double) 在调用点自动包Some(§1.6);记录字面量要写满 全部字段,with .. { }覆写与「裸T传显式T?参数」都不支持(§1.6)。
pub enum Color { Hex(String) Named(String) Var(String) }
pub struct Style {
// 文本与颜色
mut color : Color?
mut background_color : Color?
mut font_size : Double?
...
// 尺寸 / 内外边距 / 边框 / flex 布局 / 其它
mut width : Double? // 逻辑像素;百分比另开 width_percent 一条入口
mut padding_left : Double?
mut padding_right : Double?
mut padding_top : Double?
mut padding_bottom : Double?
mut cursor : Int?
mut visibility : Visibility?
// 状态桶(构造端:工厂状态参数收整块 + 三个状态方法收属性参数,§1.3)
mut hover : Style?
mut active : Style?
mut focus : Style?
}
// 工厂:全部属性是可选 labelled 参数(属性名即参数名),末尾三个状态参数收整块
pub fn style(width? : Double, /* …其余布局与外观属性… */
color? : String, background_color? : String, /* … */
hover? : Style, active? : Style, focus? : Style) -> Style
// 状态方法:只收绘制期属性(布局参数不在签名里),非 None 写进对应状态桶
pub fn Style::hover(self : Style, color? : String, background_color? : String,
/* …绘制期属性… */) -> Style
// 样式表 = 一组具名常量:内置一份(gui/style/tokens.mbt),使用者可叠自己的一份
pub let card : Style = style(
padding=14.0, // 四边同值,包内展开成四个字段
gap=10.0,
background_color="bg_panel", // # 开头按 hex、命名色命中按 named、否则当 theme token 名
border_radius=8.0,
hover=style(border_color="primary"),
)
pub let card_flat : Style = merge(card, style(border_width=0.0))
pub fn merge(base : Style, over : Style) -> Style // 逐属性并集,后者赢;状态桶递归
pub fn apply(w : @widget.Widget, s : Style) -> Unit // 布局字段走 Yoga;外观字段存进
// widget 的 Style 槽,绘制期读
// self 并按状态选桶九条实现口径:
Style是引用语义:merge造新值、绝不改入参,内置常量表只读用。merge递归进状态桶:两条都带hover时逐属性覆盖,不是整块hover互换——整块换会 抹掉前者hover里没写的那些边。- 组件侧统一一个参数:
class? : Array[Style] = []。没有字符串内联通道——一次性微调写class=[@style.card, @style.style(padding=4.0)](数组尾赢)。 - 散参数(
font_size=/background_color=那批)是糖,只有一条去向:折成一个匿名Style排在解析序列最前,class在后,于是「class> 散参数」由数组顺序直接成立,不需要第二 条优先级规则;组件内部也只有「读 resolved Style」一条路径。改名对齐 CSS 属性名并进 §2 那批。 布局散参数(width/height/padding/row_gap/column_gap/grow/direction/align_items/justify_content)走同一条折法:折进匿名块后由apply落到 Yoga,控件节点上 不留第二条写入路径——否则class给的宽与内边距会被散参数覆写。 - class 落点分两层:组件的主根控件吃完整
class(布局属性 + 绘制属性);标记类内部件(勾选点、 单选环、开关滑珠、滑块拇指、星级、子行、开在浮层图层上的面板)画在子控件上,class却落在 外层宿主——子控件的on_draw只读自己的样式槽,所以建树时把class里属于绘制期的那几条 (color/background_color/border_color/border_width/border_radius与三个桶)摘出来, 叠在标记控件的内置基调之上(paint_subset)。布局属性不外溢给标记——一个组件(一个「组」)的class同时管得住整体排版与标记配色。 - 状态块两条入口,分工不同:工厂状态参数收整块(
hover=@style.card_hover,具名块 复用),状态方法.hover(...)收属性参数(现场开块);状态方法签名里只有绘制期属性 ——布局参数不在签名中,布局值进状态块编译都过不了。状态桶是绘制期消费的,布局属性混 进去会把「画的时候」和「量的时候」搅在一起。 几态同时命中时按 hover < focus < active 层叠(后并入的赢,与 CSS 的:active盖过:hover同向):按下必然也在悬停里,反过来的话按下反馈永远画不出来。 - 数据态不是状态桶:状态桶只有
hover/active/focus三条(指针与焦点态)。 选中、勾选、当前项这类由数据决定的态没有对应的桶(CSS 的:checked在这里没有对应物), 所以组件代码保留「取哪个态」的分支,态里用的颜色一律读样式槽;样式里没有对应属性的 (如选中行的底色)继续用内置主题色,不为此再加字段或桶。 - 组件的内置皮肤也走同一条槽:变体配色(按钮的 solid/soft/…)折成「基块 + 状态桶」, 存的是 token 名而非某一刻的色值,所以换主题后绘制期现取。由此两条覆盖路径分工清楚: 散参数(
color=/background_color=)覆盖的是所有状态(基块与状态桶取同一个值, 悬停不再变色);class只覆盖它写到的那些属性,点亮态仍由内置桶决定——要把悬停态也 定死就写.hover(...),这与 CSS 的.btn/.btn:hover分层同向。 - 不挂运算符:组合只写
@style.merge(a, b)。 Widget需要一个存放 resolvedStyle的位置:现有Widget只有node/kids/rect/visible/ 各回调 /hovered/pressed/focused/cursor/focus_id, 外观散落在每个组件on_draw捕获的参数里(core/widget.mbt)。P1 加这个位置,P2 把每个 组件的绘制改成「读self上的样式并按状态选桶」。这与既有纪律同向:on_draw首参本来就是self,而现规则明令不要把控件句柄捕获进闭包(引用环,计数式回收断不掉,core/widget.mbt:82-87)。- 四边类 setter 一律 labelled + 默认值:
set_padding(core/widget.mbt:691)已同形,set_margin(:681,42 处调用点)随 P1 改成top/right/bottom/left。位置参数 的类型全是Double,错序编译期查不出;两套形态也会让样式层写两份四边展开的翻译。改法机械, 但须整批一次改完并逐处核对,靠既有布局用例兜。 Edges/Size两个值类型不设:padding 只有两档(四边同值 / 逐边单给),一个padding参数够;长度默认 px,百分比另开width_percent,fixed / percent 的分派 留在包内。- 颜色字符串的判定在 setter 内做;token 名不认识时在应用启动一次性出声报错并列出可用 token,不留到绘制期静默回退——值形态没有 CSS 的编译期拼写检查,这条是兜底。
1.6 MoonBit 工具链边界(moon 0.1.20260920 / moonc v0.10.14)
工具链边界决定 API 形态,读数随版本升级要重测:
| 设想写法 | 结果 | 诊断 / 含义 |
|---|---|---|
记录字面量省略字段 T::{ color: Some("x") } | ✗ | [4044] Record fields padding, radius are undefined for type T——字面量要写满全部字段 |
字面量里 ..base 填充剩余字段 | ✗ | [3002] Parse error, unexpected token .. |
base with .. { f: v } / base with f: v 记录覆写 | ✗ | [3002] Parse error, unexpected token with |
裸 T 传给 T? 型 labelled 参数(指望自动包 Some) | ✗ | [4014] has type: String / wanted: String?——显式 T? 类型形态没有隐式提升(与下条的可选 labelled 形态不同) |
可选 labelled 参数(fn f(x? : Int)),调用点给裸值 f(x=1) | ✓ | 调用点自动包 Some,全省略得 None——kwargs 工厂形态的地基 |
以类型名当构造函数调用 S(w=None) | ✗ | [4021] Value S not found in package——struct 没有函数式构造语法,工厂只能是自己定义的包级函数(style(...)) |
数组展开字面量 [..xs, y] | ✓ | 追加/过滤重建的惯用形态(items.update(fn(ts) { [..ts, t] }));Array::filter 返回新数组可直接作表达式 |
链式 setter(方法收 self、就地写字段、返回自身) | ✓ | 可用;kwargs 工厂形态下调用点不出现 setter 链 |
pub alias 给工厂起短调用名(pub alias g = f) | ✗ | [3002] 解析失败 + [0035] The word 'alias' is reserved——本工具链没有 alias 语法,alias 是保留字;类型别名走 pub abbrev,函数没有对应设施 |
pub let g = f 绑函数值再以 g(width=1.0) 调用 | ✗ | [4117] Function with labelled arguments can only be applied directly——带 labelled 参数的函数不能间接调用;转发改成显式 f(width=width) 又撞 [4014] has type: Double? / wanted: Double(体内可选参数是 T?,无自动 unwrap),f(width!) / f(width=width?) / f(...) 三种透传写法全部 [3002] 不解析 |
pub abbrev t = S 类型别名当工厂调用名 | ✗ | 别名只能当类型用;构造调用仍撞 [4021](类型名不是函数),给不了工厂短名 |
moon.pkg 里给依赖包配短名("…/style": "st" 键值形态) | ✗ | moon 直接报 Parsing error: unexpected token :——import 只收字符串数组,消费端 @style.style(...) 的包名缩不了 |
方法名与字段名同名(Style 有字段 padding,同时有方法 Style::padding) | ✓ | kwargs 形态下属性名转移到参数名上(见下两条) |
方法返回值当语句用(s.set_x(v) 单独一行) | ✗ | [4139] its value cannot be implicitly ignored——只能写成链,包内 setter 体固定为「赋值 + 返回 self」 |
字段有 mut 但没有对应 setter | 警告 | [0015] The mutability of field 'padding' is never used——零警告门禁逼着字段与属性一一对应,留不下死字段 |
探针结论(2026-10-13 补测,工具链同上):工厂调用点定死 @style.style(...)——短名通道全部不存在(alias 是保留字、labelled 函数不可间接调用、moon.pkg 不支持 import 配名),P1 直接按此形态实现。使用者若嫌重复,可在自己模块里手写收拢函数(如 pub fn card_style(width? : Double) -> Style { @style.style(width=width?, padding=14.0, …) }——显式逐参传,无 ... 透传可用),库侧不提供这种糖。
2. 组件层:前端化命名
策略:直接改名,调用点一起改,不留别名。 全仓 @ui. 调用点 910 处,按包逐批替换, 每批以 cd gui && moon check 零错零警 + 分包测试通过为收口条件。
这一批同时收口两件事:
- 事件参数统一成
on_*:现状有的组件走on_change?,有的只留handle?逃逸闭包 (ui/declarative.mbt:34、:91)。命名齐了,on_click=/on_change=才不用逐组件记。 - 散参数改名对齐 CSS 属性名:
bg→background_color、fg→color、size→font_size,与 §1.1 的Style字段同名。labelled 参数名本身进 IDE 补全, 这一步不引入任何新机制就拿到前端直觉。 改的判据是「这个数是字号还是几何尺寸」:字号才叫font_size(text/text_dyn/input/textarea/button/checkbox/link/tag*/bind*/text_source); 图标与圆点的边长仍是size(icon/badge_dot/avatar/rate),图标按钮本来就写成icon_size/box_size——把几何尺寸也叫font_size才是错的。
_t 后缀是历史上的 themed 变体标记,与同名基础版是两套实现 (label / label_t、button / button_t)。改名时合并为单入口,差异参数取并集。
| 新名 | 旧名 | 说明 |
|---|---|---|
view | container | 通用盒子,direction 决定行列 |
row / col | hbox / vbox | |
text | label + label_t | 合并:role 可选,给了就按角色取字号字重 |
button | button + button_t | 合并:variant = solid / soft / border / text / danger |
input | entry | 单行输入 |
textarea | textarea / text_edit | 多行 |
select | select_t / picker / combo | 合并:combo(可输入)用 editable 参数区分 |
switch | switch_t | |
tabs | tabs_t / tab | |
table | table_t;table_v ← table_v_t | 虚拟表格保持独立名 |
dialog | dialog_t | |
tooltip | tooltip_t | |
popover | popover_t | |
dropdown | dropdown_menu | |
menu | side_menu | |
scroll_view | scroll_v / scroll | |
split | hsplit / vsplit | 合并:direction 参数 |
divider | separator / divider | |
panel | group | 带标题的面板 |
icon_button | icon_button_t | |
rate | rate_t | |
color_picker | color_picker_t | |
date_picker | date_picker_t | |
date_range_picker | date_range_picker_t | 三只区间选择器不在旧映射表里,_t 尾巴随改名批去掉,与 date_picker 同形 |
time_range_picker | time_range_picker_t | 同上 |
datetime_range_picker | datetime_range_picker_t | 同上 |
calendar | calendar_t | |
carousel | carousel_t | |
message_box | message_box_t |
保持不变(已与 Element Plus 同形):badge、tag、card、statistic、descriptions、 result、timeline、tree、transfer、steps、breadcrumb、pagination、collapse、 segmented、slider、progress、checkbox、radio_group、form、form_item、empty、 avatar、alert、image、icon、gif、code_view、virt_list、sticky_list。
3. 响应式:内核已有,补四件
现状:core/signal.mbt 的 Signal::computed 在求值期自动收集依赖, 另有 batch() 合并通知、sub_bag_* 订阅回收袋防泄漏。不重写内核,只补上层:
ListStore[T]+ keyed 差分(Vuev-for :key的对应物):现在 table / virt_list / transfer 各造各的轮子;抽成通用列表容器后,push/remove/update/splice只 重挂载变化的子项,是下面第 4 项for_list的地基,也是收益最大的一块。effect/watch一等 API:computed能当 effect 用,但没有正式入口,也没有 "随这一代子树一起退订"的语义(应进订阅回收袋)。帧末统一 relayout + redraw:现有
batch只合并通知、不合并重绘,一帧内多次set会重复布局与绘制;补一个帧末队列(nextTick 对应物)。落点已有:invalidate()是 全局单钩子(core/node.mbt:56-71)。flush 口径:同一事件回调内合并、回调尾同步 flush——跨拍队列会让反馈晚一帧。for_list/when两个薄入口:v-for/v-if的对应物,§1 到 §3 三层能独立用起来 就靠这两个入口。moonbit// v-for 对应物:keyed 差分,改一条只重挂一条;回调拿 (item, index), // key 由 key 提取器给(不给就按位置,等价无 :key) @ui.for_list(ctx.items, fn(t, i) { @ui.row([@ui.text(t.title)], class=[@style.item]) }, key=fn(t) { t.id }) // v-if / v-else 对应物:收 Signal,两支都常驻、切显隐(状态不丢) @ui.when(ctx.has_items, then=@ui.for_list(ctx.items, fn(t, i) { /* …行… */ }, key=fn(t) { t.id }), else=@ui.empty("暂无任务")) // 单支:假时隐藏不重建 @ui.when(ctx.busy, @ui.text("保存中…"))when就是 if/else:else=?可选——不给是单支(假时显隐),给了是双支常驻切显隐 (swap_node落地,core/node.mbt:119);要销毁重建的场合(大子树省内存、重置内部 状态如输入光标)仍走bind_node(:77-113),两个原语原样保留,when是它们上面的糖。 上面示例的busy/has_items直接传信号、不是.get()(绑定 语义通则见下第 5 条)。for_list与「对象属性」:JS 的v-for能枚举对象属性,MoonBit 无反射、struct 字段 是静态的(与下条不做字符串路径同一原因),没有「动态对象」可枚举;字典数据就是Map[K, V],entries()转条目列表进for_list(条目是(K, V)值),不为对象单独开 入口。下标就是回调第二参i,写fn(t, _)即可忽略。绑定语义通则:输入类组件第一参数收可写
Store[T](现状约 48 处,ctx.draft即是); 展示类组件一律「x收值 +x_dyn收Signal[T]」——叶子展示件已齐(text_dyn、tag_dyn/tag_of_type_dyn、badge_count_dyn、avatar_dyn、statistic_dyn, 值变就地改字并按内容重算宽度,退订随本代回收袋);容器类(card/descriptions/alert/timeline/empty/result)收子节点数组,内容变了走for_list/when/bind_node,不再叠一层*_dyn;progress一族本就直接读Store,已是动态形态;for_list/when入参收Signal; 结构级整棵换内容走bind_node(core/node.mbt:77-113)显式重建。不做全树 patch ——列表项里改一格只重绘那一格,粒度由数据层(字段各自是Store)承载。
没有字符串路径(data.set("a.b", v) 那类):MoonBit 无反射,路径读写必须类型擦除, 换来的是编译期查不出错的运行时失败。口径是:数据就是普通 struct,字段各自是 Store,调用点在 render 里直接写字段访问(ctx.form.name.get())——类型安全, 更新粒度不变。
4. 视图层:render 函数就是最终形态
没有模板语言,也没有 *.gui 单文件组件:§1 到 §3 收口完之后,使用者手写的就是原本要生成的 那段代码,形态同构、能力齐全,中间多放一个编译器只增加成本不增加能力。
4.0 数据与挂载:render 之前那两块
数据就是普通 struct:会变的字段各自是 Store(§3 口径),派生值用 computed(依赖 自动收集,现成实现),动作是普通函数:
struct Task {
id : Int
title : String
done : Store[Bool] // 会变的最小粒度单独开 Store:勾选只重绘那一格
}
struct Ctx {
// —— 可变状态:Store。输入类组件直接吃它(v-model 对应物,现状约 48 处参数口径)
draft : Store[String]
items : Store[Array[Task]]
busy : Store[Bool]
// —— 派生:computed 只读,任一依赖变化后失效、下次 get() 重算
has_items : Signal[Bool]
// —— 动作:普通函数
add : () -> Unit
remove : (Int) -> Unit
}
fn ctx_new() -> Ctx {
let next_id : Ref[Int] = Ref(1)
let draft : Store[String] = @base.Store::new("")
let items : Store[Array[Task]] = @base.Store::new([])
let busy : Store[Bool] = @base.Store::new(false)
{
draft,
items,
busy,
has_items: @base.Signal::computed(fn() { items.get().length() > 0 }),
add: fn() {
let title = draft.get().trim()
if title == "" { return }
let t = { id: next_id.val, title, done: @base.Store::new(false) }
next_id.val += 1
@base.batch(fn() { // 多次 set 合并为出批时的一轮通知
draft.set("")
items.update(fn(ts) { [..ts, t] })
})
},
remove: fn(id) {
items.update(fn(ts) { ts.filter(fn(t) { t.id != id }) })
},
}
}render 怎么被调用:挂载时调用一次,此后框架不再重跑它。响应靠 render 建出的树里 收 Signal 的入口自己订阅、自己差分(§3 第 5 条通则:for_list 管列表 keyed 差分、 when 管显隐、*_dyn 管展示文本);整棵换内容走 bind_node 显式重建,不做全树 patch。 挂载入口今天收静态节点数组(hello-themed / sysmonitor 就是这个形态:先建好树再传 [nodes]);P3 补 mount_window_render(render 槽入口),二选一——mount_window 的 children 保持首位位置参数(数百示例调用点不动):
fn main {
let ctx = ctx_new()
let app = match @ui.mount_window_render( // render 槽入口(与节点数组二选一)
fn() { [render(ctx)] }, // 挂载时调用一次建首帧;此后由各入口自响应
title="任务",
width=420,
height=560,
) {
None => {
println("SKIP: 本平台没有窗口后端或没有显示服务")
return
}
Some(a) => a
}
app.run()
}最终形态的视图部分:
// 目标形态:一个「任务页」——P1–P3 落地后可编译、可响应
fn render(ctx : Ctx) -> @widget.Node {
@ui.col(
[
// 工具栏:input 直接收 Store(v-model 对应物,现状能力),
// 每敲一个字只重绘输入框本身,不碰列表
@ui.row(
[
@ui.input(value=ctx.draft, placeholder="新任务…", on_submit=ctx.add),
@ui.button("添加", class=[@style.primary], on_click=ctx.add),
],
class=[@style.toolbar],
),
// v-if / v-else:收 Signal,两支常驻切显隐;列表走 keyed 差分,
// 勾选一条只重绘那一行,其余行的焦点/滚动状态原样保留
@ui.when(
ctx.has_items, // Signal[Bool],computed 依赖自动收集
then=@ui.for_list(
ctx.items,
fn(t, i) {
@ui.row(
[
@ui.checkbox(checked=t.done),
@ui.text(t.title, class=[@style.item_title]),
@ui.icon_button(kind=@icons.trash, on_click=fn() { ctx.remove(t.id) }),
],
class=[@style.item],
)
},
key=fn(t) { t.id },
),
else=@ui.empty("暂无任务,先添加一条"),
),
// 单支 when:busy 为假时隐藏,状态不丢;保存中的转圈动画是 P5 的 transition
@ui.when(ctx.busy, @ui.text("保存中…", class=[@style.hint])),
],
class=[@style.page],
)
}这段是 P1/P2/P3 落地后的目标形态:kwargs 样式与
class来自 P1+P2,for_list/when/render槽来自 P3(数据与挂载见 §4.0),今天写出来还编译不过。ctx.draft是Store[String](输入类组件收 Store),ctx.has_items/ctx.busy是Signal[Bool], 传信号才响应(§3 第 5 条)。ctx.add/ctx.remove里只写 Store,往后的差分、显隐、 帧末合并重绘全是框架的事:这就是「写起来像改数据,动起来像改了那一块 UI」的整个设计。
4.1 工具链边界(moon 0.1.20260920 / moonc v0.10.14)
没有编译期语法扩展的可用通道(读数随工具链版本,升级要重测):
moon version --all→moon 0.1.20260920 / moonc v0.10.14;moonc -h没有 plugin / macro 相关开关(自编译 Rust 侧插件编译器要锁工具链版本,每次升级重编,不接)。moon ide doc '@moonbit'→package moonbit not found in workspace,即当前工具链里 没有ins自定义中缀语法可用(想给 DSL 加->、?=一类新语法这条路暂时不通)。moon ide doc '*include*'→ 无结果,没有编译期嵌文件能力,所以.css/.gui文本 进不了类型检查(§1 值形态的前提)。- 仓内唯一被验证可用的「构建期外部脚本」通道是
--moonbit-unstable-prebuild(gui/moon.mod,实做gui/scripts/prebuild.py),而它只能给本模块内的包下发配置, 看不见使用者模块里的.gui文件——真要上 codegen,每个 app 自己配一遍。
表达式层已经是 MoonBit,树的外壳不必换成另一种语法。
5. 分期与门禁
| 批 | 内容 | 收口条件 |
|---|---|---|
| P1 | 新建 gui/style 包:Style 字段集(含定位组 position / top / right / bottom / left——底层 yoga 现成,含百分比 inset 的能力后补入口)+ Color + kwargs 构造(style(...) 工厂属性名即参数名 + .hover(...) / .active(...) / .focus(...) 状态方法,§1.3,W3C 规则模型)+ merge(逐属性并集、状态桶递归)+ 全量内置常量表(§2 每个组件的基调,tokens.mbt)+ Widget 上存 resolved Style 的位置 + apply;yoga-mbt 补 z_index(set_z_index + 布局输出按 z_index 稳定排序,Yoga 上游同构;gui 侧绘制按 resolved 值重排 kids,Style 同步开 z_index 字段);顺带 Widget::set_margin labelled 化(同形统一) | 新包测试 + Widget 样式槽用例 + 定位字段落到 yoga 的布局用例 + yoga-mbt 模块 z_index 用例(正序/负值/并列稳定)+ cd gui && moon check 零错零警 |
| P2 | 组件改名(按 §2 映射表,含 _t 合并)+ on_* 统一 + 散参数改名并折算成匿名 Style(排解析序列最前)+ 展示类组件补 _dyn 信号版(§3 第 5 条通则)+ 逐组件把 on_draw 从「读捕获的散参数」改成「读 self 上的样式并按状态选桶」 | 全仓 moon check 零错零警,910 处调用点全切完,观感逐页比对不回退 |
| P3 | 响应式补强:ListStore keyed 差分 / effect+watch / 帧末合并重绘 / for_list+when / mount_window 补 render 槽(挂载时调用一次建首帧,§4.0;与节点数组二选一) | 分包测试 + 现有 Store 用例不回归 + hello-themed/sysmonitor 静态挂载形态不回归 |
| P4 | 绘制能力补齐:Painter 全局 alpha(opacity:alpha 进绘制状态机,铺满填充 / 描边 / 文本 / 图片全路径,Style 开 opacity 字段——颜色级 alpha 已有,本批补整层)+ box_shadow(阴影档:2026-10-15 曾由硬边描环改为同心外扩分层填充并补齐 CSS 四位,真机复验不过关,同日整条撤除——本栈当前不提供投影) | 各自用例(opacity 含全路径覆盖验证)+ 回写 docs/zh/adaptation.md |
| P5 | 动画:transition(状态桶属性插值 + 帧驱动)——Style 开 transition_ms,style_for 出口按真实时间插帧(插值集合见 §1.1「过渡」行),帧驱动复用进程级 set_timeout,每拍只给活动中的控件局部置脏(invalidate_id),全部收敛即停表 | 插值与状态机用例 + 真机观感(showcase「过渡与效果」页)+ 回写 docs/zh/adaptation.md |
on_* 与散参数改名都在 P2 的改名范围内,合批做,P1 之前不加批。
动画的代价就是「连续置脏重绘」每帧多少钱,所以它排在脏矩形与文本布局缓存之后: 局部脏区一帧的实测读数决定过渡能开多长,不决定它怎么设计(读数与判据写在 docs/zh/adaptation.md,不在这里重复)。P4 的 opacity 是静态属性 (画一帧就够),不参与插值集合。
每批单独提交;实测坑按 AGENTS.md 规则 7 回写 docs/zh/adaptation.md;使用类说明写进 docs/zh/ 下的使用文档,不写进本文件。
6. 口径记录
Store内建去重(2026-10-13 定案,原 §6 唯一待定项):T : Eq的 Store,set/update写相同值时只存不发(Signal::set_eq,通知发射器sig_deliver不动,Signal::set仍无条件通知);没有Eq或需要「同值也重发」的场合走set_always/update_always。复合值类型(DateYMD/DateRange/TableSort/SemanticType等)补了impl Eq;图表数据窗口(Array[LineSeries]等元素无Eq且比较代价高于收益)在 chart 与示例里一律走set_always。- 自重排的周期表:句柄每拍都要记回(2026-10-14):本栈的延时只有单次形态 (
Loop::timer/@shell.set_timeout都是一次性),周期任务靠回调尾自己重排。 重排拿到的新句柄必须覆盖调用方存着的那一份,否则「停表」取消的是早已跑掉的 旧号,取消返回 true/false 都停不住(core/loop_wbtest.mbt钉住这条语义)。 - 三份文档的分工与状态口径(2026-10-16 定案):
roadmap.md是功能面全景——功能点 × 三平台状态四列,计划项与已完成项都在同一张目录里,不写接口与过程;仓库根TODO.md只跟踪未完成项;批次过程、根因与实测细节只在adaptation.md(中英两份)。三处互不复述。平台状态五档:✅ 有实现且有逐项回证(真机 / 探针 / 分包测试绿任一,且适配文档留了记录)· 🚧 有实现但回证只到「分支在位 + 分包绿」或分支未合流 · ❌ 未接 · — 该平台无对应面 · ?查过仍无定论。「代码里有分支」不构成 ✅,最多 🚧;查不出的格子留 ?进待核清单,不填假值。 - 组件命名去
_t尾巴、对齐 Web 元素名(2026-10-16):媒体播放器组件叫audio/video(与 HTML 同名元素),player_t作废;_t是历史「themed 变体」标记,§2 改名批的既定方向,新组件一律不带。共用件按bar_*前缀(bar_hit/bar_speed_at),带平台前缀的历史名(vp_*)随各自批次收。 - 两条播放时钟不互相代打(2026-10-16):
video的位置是墙钟 + 帧时间戳,audio的是设备已消费字节。纯音频文件不进VideoPlayer(没有帧就没有时钟,硬撑等于在视频引擎里重做一套设备时钟),带视频轨的文件不进audio;组件层不做「自动识别后二选一」,调用方按媒体类型选组件。 - 窗口级按键兜底的三条边界(2026-10-16):① 焦点链消费掉的键不外溢,所以输入框打字天然不被抢;② 只在本窗事件循环生效,与
sys/win那条系统级热键(X11 grab /RegisterHotKey)是两层,不混用一个门面;③ 通道没有反注册口,处理器必须自己用Widget::is_painted()判活体——被摘掉的组件不能再吃键。 - 拆模块的判据与迁移清单口径(2026-10-16):外部重资产 + 必须强制下发链接参数 → 开模块;纯内部层 → 留子包。跨包改名的迁移清单必须含模块外消费者(
modules/*、examples/、experiment/)——它们经moon.work本地解析,不在主模块门禁的视野里,漏一个就是「已推送的树断链」。 - 文档互相矛盾 = 核查触发器(2026-10-16):两份文档对同一事实口径不同时(本轮两例:媒体线 Windows 解码档、
recent_files头部「非 Linux 给 Unsupported」的注释),一律以代码 + CI 配置现场定案,并回改过期的一侧,不留「以某处为准」的悬案。