docs/guide/ui-tree
核心概念 · Nodes

UI 树与布局

用节点构造器和 Flex 风格布局,建立 zenit 界面的空间模型。

预计阅读 10 分钟

心智模型

挂载函数创建一棵真实、常驻的 *ui.Node 树,只运行一次。容器决定子节点如何排列,叶子节点承载文本、图片或图标。之后每一帧,框架只从「脏」的节点重新进入管线:布局、生成绘制项、交给 Metal。

FRAME PIPELINE
每个样式字段都有一个脏级别。改背景只重新生成绘制项;改宽度会让父节点也重排。树干净且没有动画时,整帧 GPU 提交会被跳过。

常用构造器

构造器用途
ui.box通用容器,完整控制方向、尺寸、间距、背景。默认 direction 为 column,宽高为 fit
ui.hstack / ui.vstack固定 direction 为 row / column 的 box
ui.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,这样换主题和统一调整间距时不用逐个改字面量。

card.zig
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) 分别设置上下与左右内边距。

row.zig
// 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));
真实 Stack Story:水平、垂直与嵌套容器最终落到同一套 rect、gap、align 与 sizing 规则。

尺寸与坐标

宽高是一个 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
SIZING
父节点宽度变化时:.fixed 纹丝不动,.pct 跟随 content box 等比变化,.fill 拿走扣除固定尺寸、百分比与 gap 后剩下的全部空间。

布局结果不存在节点字段里。node.rectFromWorldOrFallback() 返回上一次布局得到的、相对父节点左上角的矩形;需要窗口坐标时用 node.globalRect(),它会沿祖先链累加位置、translate 与 sticky 偏移。

zig
// 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;
LayoutBox Story:盒模型、百分比、绝对定位、Flex / Grid 与裁剪,每组都带 padding box 与 content box 参照线。

正确更新节点

直接修改节点时,要让框架知道哪一段管线需要重跑。优先用 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不触发帧工作
update.zig
// 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 neither

setText 会比较新旧文本签名:影响测量时标记 sizing,只影响外观时标记 render,完全相同则什么也不做。所以更新文本后不需要再调用 markRenderDirty。

要临时隐藏一段子树,用 node.setDisplay(.none)(或在 BoxStyle 里写 .display = .none):节点连同子树不占空间、不计 gap、不绘制,也退出命中测试、Tab 遍历与无障碍树;节点和状态保持存活,.flex 即可恢复,不必卸载重建。setDisplay 会自己标脏本节点与父节点的布局。只把 opacity 设为 0 虽然也不再命中,但仍然占位、仍在 Tab 顺序里。

zenit · 双授权开源项目可按 GPL-3.0-only 免费使用;闭源或商业产品需要商业授权。可联系作者:zongyi.xzy#gmail.com(# 换成 @)zenit 5f9add5+wip 2026-09-30