UI 树与布局
用节点构造器和 Flex 风格布局,建立 zenit 界面的空间模型。
心智模型
挂载函数创建一棵真实、常驻的 *ui.Node 树,只运行一次。容器决定子节点如何排列,叶子节点承载文本、图片或图标。之后每一帧,框架只从「脏」的节点重新进入管线:布局、生成绘制项、交给 Metal。
常用构造器
ui.box通用容器,完整控制方向、尺寸、间距、背景。默认 direction 为 column,宽高为 fitui.hstack / ui.vstack固定 direction 为 row / column 的 boxui.text / ui.textFmt静态文本 / 订阅 Signal 的格式化文本:textFmt(cx, scope, fmt, .{signals}, props)ui.icon / ui.iconTint / ui.svg / ui.image矢量图标、着色图标、SVG 与位图纹理ui.grid按列 / 行轨道排布的网格容器ui.spacer宽高都为 grow 的弹性空白ui.clickable给任意节点挂上 on_click 回调,返回同一个节点ui.boxStyled / hstackStyled / vstackStyled / textStyled接收具名样式函数,换主题时自动重放,见样式与主题布局示例
容器先创建,再用 appendChild 挂上子节点。样式值优先来自 cx.tokens,这样换主题和统一调整间距时不用逐个改字面量。
const card = try ui.vstack(cx, .{
.width = .fixed(360),
.gap = cx.tokens.space._4,
.padding = ui.Padding.all(cx.tokens.space._6),
.background = cx.tokens.color.bg_secondary,
.corner_radius = cx.tokens.radius.xl,
.align_items = .stretch,
}, .{});
const header = try ui.hstack(cx, .{
.gap = cx.tokens.space._2,
.align_items = .center,
}, .{});
try header.appendChild(cx.allocator, try ui.iconTint(
cx,
ui.icons.star,
cx.tokens.color.accent,
.{ .width = .fixed(18), .height = .fixed(18) },
));
try header.appendChild(cx.allocator, try ui.text(cx, "Overview", .{}));
try header.appendChild(cx.allocator, try ui.spacer(cx));
try card.appendChild(cx.allocator, header);子节点也可以在构造时以 tuple 传入;ui.Padding.symmetric(v, h) 分别设置上下与左右内边距。
// Children can also be passed as a tuple at construction time.
const row = try ui.hstack(cx, .{
.width = .fill(),
.padding = ui.Padding.symmetric(cx.tokens.space._2, cx.tokens.space._4),
.gap = cx.tokens.space._3,
}, .{
try ui.text(cx, "Name", .{}),
try ui.spacer(cx),
try ui.text(cx, "42", .{ .color = cx.tokens.color.fg_secondary }),
});
// Any node can take a click handler.
// (model: *Model from cx.bindState; Model.select is fn (*Model) void)
_ = ui.clickable(row, cx.on(Model, model, Model.select));尺寸与坐标
宽高是一个 ui.Sizing 联合体,四个成员各有一个简写:
.fixed(120).{ .px = 120 }固定像素.fill().{ .grow = .{} }分配剩余空间;grow 带 min / max 载荷,例如 .{ .grow = .{ .min = 200 } }.pct(50).{ .percent = 50 }父节点 content box(已扣除 padding)的百分比,取值 0–100.{ .fit = .{} }由内容决定,box 的默认值,同样可带 min / max布局结果不存在节点字段里。node.rectFromWorldOrFallback() 返回上一次布局得到的、相对父节点左上角的矩形;需要窗口坐标时用 node.globalRect(),它会沿祖先链累加位置、translate 与 sticky 偏移。
// Parent-relative rect from the last layout pass.
const local = node.rectFromWorldOrFallback();
// Window coordinates: accumulates ancestors, translate and sticky offsets.
const screen = node.globalRect();
std.log.info("{d}x{d} at ({d}, {d})", .{ screen.w, screen.h, screen.x, screen.y });
_ = local;正确更新节点
直接修改节点时,要让框架知道哪一段管线需要重跑。优先用 setStyle:它在编译期根据字段选出脏级别,不需要手动 markLayoutDirty / markRenderDirty。
.sizingwidth, height自身与父节点重新布局.layoutpadding, margin, gap, direction, justify, align_items, min/max_*重新布局此节点.interactionopacity, translate_*, corner_radius, border, z_index, cursor更新命中索引并重绘.renderbackground, shadow, gradient, outline, text_color只重新生成绘制项.nonetab_index, layout_isolation不触发帧工作// setStyle picks the dirty level from the field at compile time.
node.setStyle(cx.allocator, .width, .fixed(240)); // sizing
node.setStyle(cx.allocator, .gap, 12); // layout
node.setStyle(cx.allocator, .background, next); // render
// Low-frequency fields live in StyleExt and need a real allocator.
node.setStyle(cx.allocator, .z_index, 10);
// Replace text: dupes the content, frees the previous owned copy,
// and re-measures only if the content actually changed.
try node.setTextContent(cx.allocator, "Updated");
// Hide without unmounting: display:none leaves layout, paint,
// hit-testing, Tab order and the a11y tree; state stays alive.
panel.setDisplay(.none);
panel.setDisplay(.flex); // back, same nodes
// Recolor an icon node whether it is icon-table or image (svgTint) backed.
_ = icon.setTint(cx.tokens.color.accent); // false: node has neithersetText 会比较新旧文本签名:影响测量时标记 sizing,只影响外观时标记 render,完全相同则什么也不做。所以更新文本后不需要再调用 markRenderDirty。
要临时隐藏一段子树,用 node.setDisplay(.none)(或在 BoxStyle 里写 .display = .none):节点连同子树不占空间、不计 gap、不绘制,也退出命中测试、Tab 遍历与无障碍树;节点和状态保持存活,.flex 即可恢复,不必卸载重建。setDisplay 会自己标脏本节点与父节点的布局。只把 opacity 设为 0 虽然也不再命中,但仍然占位、仍在 Tab 顺序里。