v0.1.0-alpha
官网Home
docs/guide/ui-tree
核心概念 · NodesCore concepts · Nodes

UI 树与布局UI tree & layout

用节点构造器和 Flex 风格布局,建立 zenit 界面的空间模型。Build zenit’s spatial model with node constructors and Flex-style layout.

预计阅读 10 分钟10 min read

心智模型Mental model

挂载函数创建一棵真实、常驻的 *ui.Node 树,只运行一次。容器决定子节点如何排列,叶子节点承载文本、图片或图标。之后每一帧,框架只从「脏」的节点重新进入管线:布局、生成绘制项、交给 Metal。Your mount function builds a real, retained tree of *ui.Nodes — once. Containers decide how children are arranged; leaves carry text, images or icons. On every later frame the framework re-enters the pipeline only from dirty nodes: layout, paint into display items, hand off to Metal.

FRAME PIPELINE
每个样式字段都有一个脏级别。改背景只重新生成绘制项;改宽度会让父节点也重排。树干净且没有动画时,整帧 GPU 提交会被跳过。Every style field has a dirty level. A background change only regenerates paint; a width change re-lays out the parent too. With a clean tree and no animation running, the whole GPU submission is skipped.

常用构造器Constructors

构造器Constructor用途Use
ui.box通用容器,完整控制方向、尺寸、间距、背景。默认 direction 为 column,宽高为 fitGeneral container with full control of direction, size, spacing and background. Defaults to column, fit width and height
ui.hstack / ui.vstack固定 direction 为 row / column 的 boxA box with direction fixed to row / column
ui.text / ui.textFmt静态文本 / 订阅 Signal 的格式化文本:textFmt(cx, scope, fmt, .{signals}, props)Static text / formatted text that subscribes to Signals: textFmt(cx, scope, fmt, .{signals}, props)
ui.icon / ui.iconTint / ui.svg / ui.image矢量图标、着色图标、SVG 与位图纹理Vector icons, tinted icons, SVG and bitmap textures
ui.grid按列 / 行轨道排布的网格容器A grid container laid out on column / row tracks
ui.spacer宽高都为 grow 的弹性空白Flexible blank space, grow in both axes
ui.clickable给任意节点挂上 on_click 回调,返回同一个节点Attaches an on_click handler to any node and returns it
ui.boxStyled / hstackStyled / vstackStyled / textStyled接收具名样式函数,换主题时自动重放,见样式与主题Take a named style function and replay it on theme change — see Styling & themes

布局示例A layout example

容器先创建,再用 appendChild 挂上子节点。样式值优先来自 cx.tokens,这样换主题和统一调整间距时不用逐个改字面量。Create the container, then attach children with appendChild. Style values come from cx.tokens first, so theme switches and spacing changes don’t mean hunting down literals.

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) 分别设置上下与左右内边距。Children can also be passed as a tuple at construction; ui.Padding.symmetric(v, h) sets vertical and horizontal padding separately.

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 规则。The real Stack story: horizontal, vertical and nested containers all resolve through the same rect, gap, align and sizing rules.

尺寸与坐标Sizing and coordinates

宽高是一个 ui.Sizing 联合体,四个成员各有一个简写:Width and height are a ui.Sizing union; each of its four members has a shorthand:

简写Shorthand成员Member含义Meaning
.fixed(120).{ .px = 120 }固定像素Fixed pixels
.fill().{ .grow = .{} }分配剩余空间;grow 带 min / max 载荷,例如 .{ .grow = .{ .min = 200 } }Takes the remaining space; grow carries a min / max payload, e.g. .{ .grow = .{ .min = 200 } }
.pct(50).{ .percent = 50 }父节点 content box(已扣除 padding)的百分比,取值 0–100Percent (0–100) of the parent’s content box, padding excluded
—.{ .fit = .{} }由内容决定,box 的默认值,同样可带 min / maxSized by content; the box default, also with min / max
SIZING
父节点宽度变化时:.fixed 纹丝不动,.pct 跟随 content box 等比变化,.fill 拿走扣除固定尺寸、百分比与 gap 后剩下的全部空间。As the parent resizes, .fixed doesn’t move, .pct tracks the content box proportionally, and .fill takes whatever is left after fixed sizes, percentages and gaps.

布局结果不存在节点字段里。node.rectFromWorldOrFallback() 返回上一次布局得到的、相对父节点左上角的矩形;需要窗口坐标时用 node.globalRect(),它会沿祖先链累加位置、translate 与 sticky 偏移。Layout results aren’t stored as a node field. node.rectFromWorldOrFallback() returns the rect from the last layout pass, relative to the parent’s top-left; for window coordinates use node.globalRect(), which accumulates positions, translate and sticky offsets up the ancestor chain.

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 参照线。The LayoutBox story: box model, percentages, absolute positioning, Flex / Grid and clipping, each with padding-box and content-box guides.

正确更新节点Updating nodes

直接修改节点时,要让框架知道哪一段管线需要重跑。优先用 setStyle:它在编译期根据字段选出脏级别,不需要手动 markLayoutDirty / markRenderDirty。When you change a node directly, the framework has to know which part of the pipeline to re-run. Prefer setStyle: it picks the dirty level from the field at compile time, so there’s no manual markLayoutDirty / markRenderDirty.

级别Level典型字段Typical fields下一帧Next frame
.sizingwidth, height自身与父节点重新布局Relayout self and parent
.layoutpadding, margin, gap, direction, justify, align_items, min/max_*重新布局此节点Relayout this node
.interactionopacity, translate_*, corner_radius, border, z_index, cursor更新命中索引并重绘Update hit index and repaint
.renderbackground, shadow, gradient, outline, text_color只重新生成绘制项Regenerate paint only
.nonetab_index, layout_isolation不触发帧工作No frame work
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。setText compares the old and new text signatures: sizing-dirty if measurement changes, render-dirty if only appearance does, nothing at all if identical. No markRenderDirty needed after updating text.

要临时隐藏一段子树,用 node.setDisplay(.none)(或在 BoxStyle 里写 .display = .none):节点连同子树不占空间、不计 gap、不绘制,也退出命中测试、Tab 遍历与无障碍树;节点和状态保持存活,.flex 即可恢复,不必卸载重建。setDisplay 会自己标脏本节点与父节点的布局。只把 opacity 设为 0 虽然也不再命中,但仍然占位、仍在 Tab 顺序里。To hide a subtree for a while, call node.setDisplay(.none) (or set .display = .none in a BoxStyle): the node and its subtree take no space, count no gap and don’t paint, and they leave hit-testing, Tab traversal and the accessibility tree. Nodes and state stay alive, and .flex brings them back — no unmount and rebuild. setDisplay marks the node’s and its parent’s layout dirty itself. Opacity 0 alone also stops hit-testing, but the node still takes up space and stays in the Tab order.

zenit · 双授权Dual-licensed开源项目可按 GPL-3.0-only 免费使用;闭源或商业产品需要商业授权。Free for open-source projects under GPL-3.0-only; closed-source or commercial products need a commercial license.可联系作者:Contact the author: zongyi.xzy#gmail.com(# 换成 @) (replace # with @)zenit 5f9add5+wip 2026-09-30