docs/guide/ui-tree
핵심 개념 · Nodes

UI 트리와 레이아웃

노드 생성자와 Flex 스타일 레이아웃으로 zenit의 공간 모델을 구성합니다.

약 10분 분량

멘탈 모델

마운트 함수는 실제로 유지되는 *ui.Node 트리를 한 번만 만듭니다. 컨테이너는 자식의 배치 방식을 정하고, 리프는 텍스트, 이미지, 아이콘을 담습니다. 이후 매 프레임마다 프레임워크는 dirty 노드에서만 파이프라인에 다시 진입합니다. 레이아웃, display item으로 페인트, Metal로 전달의 순서입니다.

FRAME PIPELINE
모든 스타일 필드에는 dirty 레벨이 있습니다. 배경 변경은 페인트만 다시 생성하고, 너비 변경은 부모까지 다시 레이아웃합니다. 트리가 깨끗하고 실행 중인 애니메이션이 없으면 GPU 제출 전체를 건너뜁니다.

생성자

생성자용도
ui.box방향, 크기, 간격, 배경을 완전히 제어하는 범용 컨테이너. 기본값은 column, 너비와 높이는 fit
ui.hstack / ui.vstackdirection이 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을 우선 사용하세요. 컴파일 시점에 필드로부터 dirty 레벨을 고르므로 수동 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-dirty, 외형만 바뀌면 render-dirty, 동일하면 아무것도 하지 않습니다. 따라서 텍스트를 업데이트한 뒤 markRenderDirty를 호출할 필요가 없습니다.

서브트리를 잠시 숨기려면 node.setDisplay(.none)를 호출하세요(또는 BoxStyle에 .display = .none 설정). 노드와 서브트리는 공간을 차지하지 않고 gap에도 포함되지 않으며 그려지지 않고, 히트 테스트, Tab 순회, 접근성 트리에서도 빠집니다. 노드와 상태는 살아 있으며 .flex로 되돌릴 수 있어 언마운트 후 재구성할 필요가 없습니다. setDisplay는 노드와 부모의 레이아웃을 스스로 dirty로 표시합니다. opacity를 0으로만 해도 히트 테스트는 멈추지만, 노드는 여전히 공간을 차지하고 Tab 순서에 남습니다.

zenit · 이중 라이선스오픈 소스 프로젝트는 GPL-3.0-only로 무료 사용할 수 있으며, 비공개 소스나 상용 제품에는 상용 라이선스가 필요합니다.작성자 연락처: zongyi.xzy#gmail.com (#을 @로 바꾸세요)zenit 5f9add5+wip 2026-09-30