//! The painter: one walk of the retained tree per frame, in dvui widgets. //! //! The tree carries no sizes and no widget state of its own. Everything here //! reads props and answers events back into the tree, so the same tree could //! be walked by a different painter — which is the whole point of the shape //! `jolt-tui` already proves from the other side. const std = @import("std"); const dvui = @import("dvui"); const tree_mod = @import("tree.zig"); const Tree = tree_mod.Tree; /// Every widget here is keyed by the tree's `(generation, index)` identity, so /// one `@src()` per tag is enough to keep dvui's ids apart. fn opts(t: *Tree, id: u32) dvui.Options { var o: dvui.Options = .{ .id_extra = t.widgetId(id) }; if (t.getNum(id, "width-request") > 0) { o.min_size_content = .{ .w = @floatCast(t.getNum(id, "width-request")), .h = 0 }; } if (t.getNum(id, "margin") > 0) { const m: f32 = @floatCast(t.getNum(id, "margin")); o.margin = .{ .x = m, .y = m, .w = m, .h = m }; } // `align` is the cross-axis pull a box gives a child: 0 start, 1 end. const a = t.getStr(id, "align"); if (a.len != 0) { if (std.mem.eql(u8, a, "center")) { o.gravity_x = 0.5; } else if (std.mem.eql(u8, a, "end")) { o.gravity_x = 1.0; } else if (std.mem.eql(u8, a, "fill")) { o.expand = .horizontal; } } if (t.getBool(id, "fill-height")) o.expand = .both; return o; } fn direction(t: *Tree, id: u32) dvui.enums.Direction { const tag = (t.get(id) orelse return .vertical).tag; if (std.mem.eql(u8, tag, "hbox")) return .horizontal; if (std.mem.eql(u8, tag, "vbox")) return .vertical; const o = t.getStr(id, "orientation"); if (std.mem.eql(u8, o, "horizontal")) return .horizontal; return .vertical; } /// The label a widget shows: `text` first, `label` second. Two names because /// the tags disagree about which one reads better, not because they differ. fn caption(t: *Tree, id: u32) []const u8 { const text = t.getStr(id, "text"); if (text.len != 0) return text; return t.getStr(id, "label"); } pub fn walk(t: *Tree, id: u32) void { const n = t.get(id) orelse return; const tag = n.tag; if (std.mem.eql(u8, tag, "window") or std.mem.eql(u8, tag, "box") or std.mem.eql(u8, tag, "hbox") or std.mem.eql(u8, tag, "vbox")) { var o = opts(t, id); if (o.expand == null) o.expand = .horizontal; var b = dvui.box(@src(), .{ .dir = direction(t, id) }, o); defer b.deinit(); children(t, id, @floatCast(t.getNum(id, "spacing"))); return; } if (std.mem.eql(u8, tag, "page")) { // A page is a column with a reading width, centred in whatever it got. var o = opts(t, id); o.expand = .both; var outer = dvui.box(@src(), .{ .dir = .horizontal }, o); defer outer.deinit(); const max = t.getNum(id, "max-width"); var inner = dvui.box(@src(), .{ .dir = .vertical }, .{ .id_extra = t.widgetId(id), .expand = .vertical, .gravity_x = 0.5, .min_size_content = .{ .w = if (max > 0) @floatCast(max) else 0, .h = 0 }, .max_size_content = if (max > 0) .width(@floatCast(max)) else null, }); defer inner.deinit(); children(t, id, @floatCast(t.getNum(id, "spacing"))); return; } if (std.mem.eql(u8, tag, "card") or std.mem.eql(u8, tag, "frame")) { var o = opts(t, id); if (o.expand == null) o.expand = .horizontal; o.background = true; o.corners = dvui.CornerRect.all(6); o.border = dvui.Rect.all(1); o.padding = dvui.Rect.all(8); var b = dvui.box(@src(), .{ .dir = direction(t, id) }, o); defer b.deinit(); children(t, id, @floatCast(t.getNum(id, "spacing"))); return; } if (std.mem.eql(u8, tag, "scroll")) { var o = opts(t, id); o.expand = .both; var s = dvui.scrollArea(@src(), .{}, o); defer s.deinit(); children(t, id, @floatCast(t.getNum(id, "spacing"))); return; } if (std.mem.eql(u8, tag, "label") or std.mem.eql(u8, tag, "title") or std.mem.eql(u8, tag, "dim-label")) { var o = opts(t, id); if (std.mem.eql(u8, tag, "title")) { o.font = dvui.themeGet().font_title; } else if (std.mem.eql(u8, tag, "dim-label")) { o.color_text = .{ .color = dvui.themeGet().color(.window, .text).opacity(0.6) }; } dvui.labelNoFmt(@src(), caption(t, id), .{ .align_x = o.gravity_x orelse 0 }, o); return; } if (std.mem.eql(u8, tag, "separator")) { var o = opts(t, id); if (o.expand == null) o.expand = .horizontal; o.min_size_content = .{ .w = 0, .h = 1 }; _ = dvui.separator(@src(), o); return; } if (std.mem.eql(u8, tag, "spacer") or std.mem.eql(u8, tag, "gap")) { var o = opts(t, id); const size: f32 = @floatCast(t.getNum(id, "size")); o.min_size_content = .{ .w = size, .h = size }; _ = dvui.spacer(@src(), o); return; } if (std.mem.eql(u8, tag, "button")) { var o = opts(t, id); // `sensitive` is absent by default, and absent means enabled — an // unset prop must not read as a dead button. const enabled = t.get(id).?.props.contains("sensitive") == false or t.getBool(id, "sensitive"); if (!enabled) o.color_text = .{ .color = dvui.themeGet().color(.window, .text).opacity(0.4) }; const kind = t.getStr(id, "kind"); if (std.mem.eql(u8, kind, "primary")) { o.color_fill = .{ .color = dvui.themeGet().color(.highlight, .fill) }; } else if (std.mem.eql(u8, kind, "destructive")) { o.color_fill = .{ .color = dvui.themeGet().color(.err, .fill) }; } if (dvui.button(@src(), caption(t, id), .{}, o) and enabled) { t.emit(id, "click", "", 0); } return; } if (std.mem.eql(u8, tag, "checkbox") or std.mem.eql(u8, tag, "checkbutton")) { // dvui writes through the target, so the prop is the store: read it in, // let the widget edit it, write back what changed and say so. var checked = t.getBool(id, "value") or t.getBool(id, "active"); const before = checked; _ = dvui.checkbox(@src(), &checked, caption(t, id), opts(t, id)); if (checked != before) { t.setBool(id, "value", checked); t.setBool(id, "active", checked); t.emit(id, "toggled", "", if (checked) 1 else 0); } return; } if (std.mem.eql(u8, tag, "entry")) { entry(t, id); return; } if (std.mem.eql(u8, tag, "progress")) { var o = opts(t, id); if (o.expand == null) o.expand = .horizontal; o.min_size_content = .{ .w = 0, .h = 10 }; dvui.progress(@src(), .{ .percent = @floatCast(t.getNum(id, "value")) }, o); return; } // An unknown tag is a container, not an error: a tree written against a // richer painter should still show its contents here rather than vanish. var b = dvui.box(@src(), .{ .dir = .vertical }, opts(t, id)); defer b.deinit(); children(t, id, @floatCast(t.getNum(id, "spacing"))); } fn entry(t: *Tree, id: u32) void { const n = t.get(id) orelse return; if (n.entry == null) n.entry = .empty; if (!n.entry_seeded) { // The caller's `text` wins until the person typing changes it. const want = t.getStr(id, "text"); n.entry.?.clearRetainingCapacity(); n.entry.?.appendSlice(t.gpa, want) catch {}; n.entry_seeded = true; } const placeholder = t.getStr(id, "placeholder"); var o = opts(t, id); if (o.expand == null) o.expand = .horizontal; var te = dvui.textEntry(@src(), .{ .text = .{ .array_list = .{ .backing = &n.entry.?, .allocator = t.gpa } }, .placeholder = if (placeholder.len != 0) placeholder else null, .multiline = t.getBool(id, "multiline"), }, o); const changed = te.text_changed; const entered = te.enter_pressed; te.deinit(); // Read the buffer back only after deinit: the widget updates the list there. const now = if (t.get(id)) |m| (if (m.entry) |buf| buf.items else "") else ""; if (changed) { // Mirror into the prop so `zvui_node_get_str(node, "text")` answers what // is on screen, then emit — without disturbing `entry_seeded`, which // `setStr` would clear and so wipe the cursor position every keystroke. const owned = t.gpa.dupe(u8, now) catch return; defer t.gpa.free(owned); t.setStr(id, "text", owned); if (t.get(id)) |m| m.entry_seeded = true; t.emit(id, "change", owned, 0); } if (entered) t.emit(id, "activate", now, 0); } fn children(t: *Tree, id: u32, spacing: f32) void { const n = t.get(id) orelse return; // Copy the child list before walking it: painting emits events, and a // caller is free to restructure the tree between frames on the same thread. const kids = t.gpa.dupe(u32, n.children.items) catch return; defer t.gpa.free(kids); for (kids, 0..) |c, i| { if (spacing > 0 and i > 0) { _ = dvui.spacer(@src(), .{ .id_extra = t.widgetId(c), .min_size_content = .{ .w = spacing, .h = spacing }, }); } walk(t, c); } }