//! Tree component: a collapsible, navigable tree of nodes with per-node //! styling, enumeration/indentation, a cursor column, and an optional help //! footer. Ported from charm's `bubbles/tree` (v2.2.x). //! //! Build a tree with the builder API, then hand it to a model: //! //! const root = try Tree.Node.new(gpa, "prj"); //! try root.child(gpa, "ayman"); //! const bash = try Tree.Node.new(gpa, "bash"); //! const tools = try Tree.Node.new(gpa, "tools"); //! try tools.child(gpa, "zsh"); //! try bash.childNode(gpa, tools); //! try root.childNode(gpa, bash); //! //! var tree: Tree = .{}; //! defer tree.deinit(); //! tree.gpa = gpa; //! tree.setRoot(root); //! tree.setSize(70, 13); //! //! A node created with [`Node.new`] renders its open/closed indicator; a //! leaf created via [`Node.child`] renders without one (mirrors bubbles' //! `Root` vs plain-string children). //! //! Skipped from bubbles for v1: light/dark style variants (`DefaultLightStyles` //! and a `DefaultStyles(isDark)`), per-node `StyleFunc` conditionals exposed //! directly on `Node` (styles are set globally on the model), the //! `EnumeratorStyleFunc`/`IndenterStyleFunc` per-node variants, and the //! full-help multi-column footer (the short footer is shown in both modes). //! Navigation, open/close, scroll-off, the cursor column, and rendering match //! bubbles' default dark tree. const std = @import("std"); const tea = @import("blacktea"); const matte = @import("matte"); const key = @import("key.zig"); const util = @import("util.zig"); const Help = @import("Help.zig"); const Viewport = @import("Viewport.zig"); const Allocator = std.mem.Allocator; const Writer = std.Io.Writer; const ArrayList = std.ArrayList; const Tree = @This(); /// Generates the branch character for a child at `index` among `count` /// siblings (e.g. "├──" / "└──"). const Enumerator = *const fn (count: usize, index: usize) []const u8; /// Generates the indentation connecting a child at `index` among `count` /// siblings to its descendants (e.g. "│ " / " "). const Indenter = *const fn (count: usize, index: usize) []const u8; pub fn defaultEnumerator(count: usize, index: usize) []const u8 { return if (count - 1 == index) "└──" else "├──"; } pub fn roundedEnumerator(count: usize, index: usize) []const u8 { return if (count - 1 == index) "╰──" else "├──"; } pub fn defaultIndenter(count: usize, index: usize) []const u8 { return if (count - 1 == index) " " else "│ "; } const Styles = struct { tree_style: matte.Style = .none, help_style: matte.Style = .{ .padding = .{ .top = 1 } }, selected_node_style: matte.Style = .none, node_style: matte.Style = .none, root_node_style: matte.Style = .none, parent_node_style: matte.Style = .none, cursor_style: matte.Style = .none, enumerator_style: matte.Style = .none, selected_enumerator_style: matte.Style = .none, indenter_style: matte.Style = .none, open_indicator_style: matte.Style = .none, pub const dark: Styles = .{ .node_style = .{ .foreground = .{ .rgb = .{ .r = 0xB0, .g = 0xB0, .b = 0xB0 } } }, .selected_node_style = .{ .foreground = .{ .ansi256 = 212 }, .bold = true }, .root_node_style = .{ .foreground = .{ .rgb = .{ .r = 0xEE, .g = 0x6F, .b = 0xF8 } } }, .parent_node_style = .{ .foreground = .{ .ansi256 = 99 } }, .cursor_style = .{ .foreground = .{ .ansi256 = 212 }, .bold = true, .padding = .{ .right = 1 }, }, .enumerator_style = .{ .foreground = .{ .rgb = .{ .r = 0x3C, .g = 0x3C, .b = 0x3C } } }, .selected_enumerator_style = .{ .foreground = .{ .rgb = .{ .r = 0x3C, .g = 0x3C, .b = 0x3C } } }, .indenter_style = .{ .foreground = .{ .rgb = .{ .r = 0x3C, .g = 0x3C, .b = 0x3C } } }, .open_indicator_style = .{ .foreground = .{ .rgb = .{ .r = 0x5C, .g = 0x5C, .b = 0x5C } } }, }; }; pub const Node = struct { value: []const u8, /// True for container nodes (created via [`Node.new`]); such nodes render /// the open/closed indicator. Leaf nodes (created via [`Node.child`]) do /// not. is_branch: bool, hidden: bool = false, open: bool = false, /// Y offset in nodes within the flattened tree. Computed by the model. y_offset: i32 = 0, /// Y offset in rendered lines. Differs from `y_offset` for multi-line /// nodes. Computed by the model. line_offset: i32 = 0, children: ArrayList(*Node) = .empty, allocator: Allocator = undefined, /// Creates a branch node. The returned node is owned by the model once /// passed to `setRoot` and freed via `Tree.deinit`. pub fn new(allocator: Allocator, value: []const u8) Allocator.Error!*Node { const node = try allocator.create(Node); node.* = .{ .value = value, .is_branch = true, .open = true, .allocator = allocator, }; return node; } pub fn deinit(self: *Node) void { for (self.children.items) |c| c.deinit(); self.children.deinit(self.allocator); self.allocator.destroy(self); } /// Appends an existing node (subtree) as a child. pub fn childNode(self: *Node, allocator: Allocator, sub: *Node) Allocator.Error!void { try self.children.append(allocator, sub); } /// Appends a leaf node with the given text value (borrowed). pub fn child(self: *Node, allocator: Allocator, text: []const u8) Allocator.Error!void { const leaf = try allocator.create(Node); leaf.* = .{ .value = text, .is_branch = false, .open = false, .allocator = allocator, }; try self.children.append(allocator, leaf); } pub fn setOpen(self: *Node, open: bool) void { self.open = open; } pub fn setHidden(self: *Node, hidden: bool) void { self.hidden = hidden; } pub fn isOpen(self: *const Node) bool { return self.open; } pub fn isSelected(self: *const Node, tree_y_offset: i32) bool { return self.y_offset == tree_y_offset; } /// Number of nodes in this subtree (self included). Closed nodes count 1. pub fn size(self: *const Node) usize { if (!self.open) return 1; var n: usize = 1; for (self.children.items) |c| { if (c.hidden) continue; n += c.size(); } return n; } }; const KeyMap = struct { down: key.Binding = .{ .keys = &.{ key.special(.down), key.rune('j'), key.ctrl('n') }, .help = .{ .key = "↓/j", .desc = "down" }, }, up: key.Binding = .{ .keys = &.{ key.special(.up), key.rune('k'), key.ctrl('p') }, .help = .{ .key = "↑/k", .desc = "up" }, }, page_down: key.Binding = .{ .keys = &.{ key.special(.page_down), key.special(.space), key.rune('f') }, .help = .{ .key = "f/pgdn", .desc = "page down" }, }, page_up: key.Binding = .{ .keys = &.{ key.special(.page_up), key.rune('b') }, .help = .{ .key = "b/pgup", .desc = "page up" }, }, half_page_down: key.Binding = .{ .keys = &.{ key.rune('d'), key.ctrl('d') }, .help = .{ .key = "d", .desc = "½ page down" }, }, half_page_up: key.Binding = .{ .keys = &.{ key.rune('u'), key.ctrl('u') }, .help = .{ .key = "u", .desc = "½ page up" }, }, goto_top: key.Binding = .{ .keys = &.{ key.rune('g'), key.special(.home) }, .help = .{ .key = "g", .desc = "top" }, }, goto_bottom: key.Binding = .{ .keys = &.{ key.rune('G'), key.shift('G'), key.special(.end) }, .help = .{ .key = "G", .desc = "bottom" }, }, toggle: key.Binding = .{ .keys = &.{key.special(.enter)}, .help = .{ .key = "⏎", .desc = "toggle" }, }, open: key.Binding = .{ .keys = &.{ key.rune('l'), key.special(.right) }, .help = .{ .key = "→/l", .desc = "open" }, }, close: key.Binding = .{ .keys = &.{ key.rune('h'), key.special(.left) }, .help = .{ .key = "←/h", .desc = "close" }, }, show_full_help: key.Binding = .{ .keys = &.{key.rune('?')}, .help = .{ .key = "?", .desc = "more" }, }, close_full_help: key.Binding = .{ .keys = &.{key.rune('?')}, .help = .{ .key = "?", .desc = "close help" }, }, }; /// Allocator used for rendering. The matcha program sets `model.gpa` before /// `update`/`view`; set it manually when driving the tree directly (tests). gpa: Allocator = undefined, key_map: KeyMap = .{}, help: Help = .{}, show_help: bool = true, scroll_off: i32 = 5, open_character: []const u8 = "▼", closed_character: []const u8 = "▶", cursor_character: []const u8 = "→", styles: Styles = .dark, enumerator: Enumerator = defaultEnumerator, indenter: Indenter = defaultIndenter, root: ?*Node = null, viewport: Viewport = .{}, width: u16 = 0, height: u16 = 0, /// Selected node offset (in nodes). y_offset: i32 = 0, model: tea.Model = .{ .vtable = &.{ .init = vtableInit, .update = vtableUpdate, .view = vtableView, } }, fn vtableInit(_: *tea.Model) ?tea.Cmd { return null; } fn vtableUpdate(m: *tea.Model, msg: tea.Msg) ?tea.Cmd { const self: *Tree = @fieldParentPtr("model", m); self.gpa = m.gpa; return self.update(msg); } fn vtableView(m: *tea.Model, w: *Writer) Writer.Error!tea.View { const self: *Tree = @fieldParentPtr("model", m); self.gpa = m.gpa; try self.view(w); return .{}; } /// Frees the entire owned tree (the nodes allocated by `setRoot`) and the /// viewport's content buffer. pub fn deinit(self: *Tree) void { if (self.root) |r| r.deinit(); self.root = null; self.viewport.deinit(self.gpa); } /// Takes ownership of `root` and re-renders. pub fn setRoot(self: *Tree, root: *Node) void { self.root = root; self.setYOffsets(); self.renderFull(); } pub fn nodeAtOffset(self: *const Tree, y_offset: i32) ?*Node { const r = self.root orelse return null; return findNode(r, y_offset); } pub fn nodeAtCurrentOffset(self: *const Tree) ?*Node { return self.nodeAtOffset(self.y_offset); } pub fn viewportYOffset(self: *const Tree) u16 { return self.viewport.yOffset(); } pub fn setSize(self: *Tree, w: u16, h: u16) void { self.width = w; self.height = h; self.viewport.setWidth(w); if (self.show_help) { const hv = self.helpHeight(); self.viewport.setHeight(h -| hv); } else { self.viewport.setHeight(h); } self.help.width = w; if (self.root != null) self.renderFull(); } pub fn setWidth(self: *Tree, w: u16) void { self.setSize(w, self.height); } pub fn setHeight(self: *Tree, h: u16) void { self.setSize(self.width, h); } pub fn setStyles(self: *Tree, s: Styles) void { self.styles = s; self.setYOffsets(); if (self.root != null) self.renderFull(); } pub fn setOpenCharacter(self: *Tree, c: []const u8) void { self.open_character = c; if (self.root != null) self.renderFull(); } pub fn setClosedCharacter(self: *Tree, c: []const u8) void { self.closed_character = c; if (self.root != null) self.renderFull(); } pub fn setCursorCharacter(self: *Tree, c: []const u8) void { self.cursor_character = c; if (self.root != null) self.renderFull(); } pub fn setScrollOff(self: *Tree, val: i32) void { self.scroll_off = val; } pub fn setShowHelp(self: *Tree, on: bool) void { self.show_help = on; self.setSize(self.width, self.height); } pub fn setEnumerator(self: *Tree, e: Enumerator) void { self.enumerator = e; if (self.root != null) self.renderFull(); } pub fn setIndenter(self: *Tree, i: Indenter) void { self.indenter = i; if (self.root != null) self.renderFull(); } pub fn down(self: *Tree) void { self.updateViewport(1); } pub fn up(self: *Tree) void { self.updateViewport(-1); } pub fn pageDown(self: *Tree) void { self.updateViewport(@intCast(@min(self.viewport.height, std.math.maxInt(i32)))); } pub fn pageUp(self: *Tree) void { self.updateViewport(-@as(i32, @intCast(@min(self.viewport.height, std.math.maxInt(i32))))); } pub fn halfPageDown(self: *Tree) void { self.updateViewport(@intCast(@min(self.viewport.height / 2, std.math.maxInt(i32)))); } pub fn halfPageUp(self: *Tree) void { self.updateViewport(-@as(i32, @intCast(@min(self.viewport.height / 2, std.math.maxInt(i32))))); } pub fn gotoTop(self: *Tree) void { self.updateViewport(-self.y_offset); } pub fn gotoBottom(self: *Tree) void { const r = self.root orelse return; self.updateViewport(@intCast(@min(r.size(), std.math.maxInt(i32)))); } pub fn toggleCurrentNode(self: *Tree) void { const r = self.root orelse return; const n = findNode(r, self.y_offset) orelse return; self.toggleNode(n, !n.isOpen()); } pub fn openCurrentNode(self: *Tree) void { const r = self.root orelse return; const n = findNode(r, self.y_offset) orelse return; self.toggleNode(n, true); } pub fn closeCurrentNode(self: *Tree) void { const r = self.root orelse return; const n = findNode(r, self.y_offset) orelse return; self.toggleNode(n, false); } fn toggleNode(self: *Tree, node: *Node, open: bool) void { node.setOpen(open); self.setYOffsets(); self.updateViewport(0); } /// Moves selection by `movement` nodes and re-renders, honoring `scroll_off`. fn updateViewport(self: *Tree, movement: i32) void { const r = self.root orelse return; const root_size: i32 = @intCast(@max(r.size(), 1)); self.y_offset = @max(@min(root_size - 1, self.y_offset + movement), 0); self.renderFull(); if (self.y_offset == 0 and movement == 0) return; const selected = findNode(r, self.y_offset) orelse return; const line_offset = selected.line_offset; const height: i32 = @intCast(self.viewport.height); const total: i32 = @intCast(self.viewport.lineCount()); const scrolloff = @min(self.scroll_off, @divTrunc(height, 2)); const min_top = @max(line_offset - scrolloff, 0); const min_bottom = @min(total - 1, line_offset + scrolloff); if (@as(i32, @intCast(self.viewport.yOffset())) > min_top) { self.viewport.setYOffset(@intCast(min_top)); } else if (@as(i32, @intCast(self.viewport.yOffset())) + height < min_bottom + 1) { self.viewport.setYOffset(@intCast(@max(min_bottom - height + 1, 0))); } } pub fn update(self: *Tree, msg: tea.Msg) ?tea.Cmd { switch (msg) { .key_press => |k| { if (self.key_map.down.matches(k)) self.down() else if (self.key_map.up.matches(k)) self.up() else if (self.key_map.page_down.matches(k)) self.pageDown() else if (self.key_map.page_up.matches(k)) self.pageUp() else if (self.key_map.half_page_down.matches(k)) self.halfPageDown() else if (self.key_map.half_page_up.matches(k)) self.halfPageUp() else if (self.key_map.goto_top.matches(k)) self.gotoTop() else if (self.key_map.goto_bottom.matches(k)) self.gotoBottom() else if (self.key_map.toggle.matches(k)) self.toggleCurrentNode() else if (self.key_map.open.matches(k)) self.openCurrentNode() else if (self.key_map.close.matches(k)) self.closeCurrentNode() else if (self.key_map.show_full_help.matches(k) or self.key_map.close_full_help.matches(k)) { self.help.show_all = !self.help.show_all; self.renderFull(); } }, else => {}, } return null; } fn cursorWidth(self: *const Tree) u16 { if (self.cursor_character.len == 0) return 0; return matte.measure.cellWidth(self.cursor_character) + self.styles.cursor_style.padding.right; } fn treeWidth(self: *const Tree) u16 { return self.width -| self.cursorWidth(); } fn nodeStyle(self: *const Tree, node: *const Node) matte.Style { if (node.isSelected(self.y_offset)) return self.styles.selected_node_style; if (node.y_offset == 0) return self.styles.root_node_style; if (node.children.items.len > 0) return self.styles.parent_node_style; return self.styles.node_style; } fn helpHeight(self: *const Tree) u16 { var buf: [512]u8 = undefined; var w: Writer = .fixed(&buf); self.writeShortHelp(&w) catch {}; const text_h = matte.measure.lineCount(w.buffered()); return text_h +| self.styles.help_style.padding.top; } fn setYOffsets(self: *Tree) void { const r = self.root orelse return; r.y_offset = 0; r.line_offset = 0; self.setYOffsetsRec(r); } fn setYOffsetsRec(self: *Tree, node: *const Node) void { if (!node.open) return; var above: i32 = 0; var lines_above: i32 = 0; for (node.children.items) |child| { child.y_offset = node.y_offset + above + 1; child.line_offset = node.line_offset + lines_above + 1; self.setYOffsetsRec(child); above += @intCast(@max(child.size(), 1)); lines_above += @intCast(@max(self.renderHeight(child), 1)); } } /// Rendered height (lines) of a single node's value. fn nodeHeight(self: *const Tree, node: *const Node) u16 { var buf: [2048]u8 = undefined; var w: Writer = .fixed(&buf); self.nodeStyle(node).render(&w, node.value) catch {}; return matte.measure.lineCount(w.buffered()); } /// Total rendered lines of the open subtree rooted at `node`. fn renderHeight(self: *const Tree, node: *const Node) u16 { var h = self.nodeHeight(node); if (node.open) { for (node.children.items) |c| h += self.renderHeight(c); } return h; } /// Builds the viewport content: the cursor column joined to the tree text. fn renderFull(self: *Tree) void { const r = self.root orelse return; const gpa = self.gpa; const tree_w = self.treeWidth(); var tree = ArrayList(u8).empty; defer tree.deinit(gpa); self.buildTree(&tree, r, tree_w) catch return; var cursor = ArrayList(u8).empty; defer cursor.deinit(gpa); self.buildCursor(&cursor, r) catch return; if (self.cursorWidth() == 0) { self.viewport.setContent(gpa, tree.items) catch return; } else { var joined = ArrayList(u8).empty; defer joined.deinit(gpa); const blocks = [_][]const u8{ cursor.items, tree.items }; const j = matte.join.horizontal(gpa, 0, &blocks) catch return; defer gpa.free(j); joined.appendSlice(gpa, j) catch return; self.viewport.setContent(gpa, joined.items) catch return; } } /// Builds the cursor strip (one line per tree line). Each line is the cursor /// character (for the selected node's line) or a space, styled with the cursor /// style (whose right padding yields the column width). fn buildCursor(self: *Tree, cursor: *ArrayList(u8), _: *const Node) Allocator.Error!void { const gpa = self.gpa; const r = self.root orelse return; const total_h = self.renderHeight(r); const sel_line = (findNode(r, self.y_offset) orelse r).line_offset; var buf: [64]u8 = undefined; var line: u16 = 0; while (line < total_h) : (line += 1) { if (line > 0) try cursor.append(gpa, '\n'); const cell: []const u8 = if (@as(i32, @intCast(line)) == sel_line) self.cursor_character else " "; var w: Writer = .fixed(&buf); self.styles.cursor_style.render(&w, cell) catch {}; try cursor.appendSlice(gpa, w.buffered()); } } /// Renders the whole tree (root row then each child, recursively) into `out`. fn buildTree(self: *Tree, out: *ArrayList(u8), node: *const Node, tree_w: u16) Allocator.Error!void { const gpa = self.gpa; // Root row: indicator + value, padded to the tree width. var row = ArrayList(u8).empty; defer row.deinit(gpa); try self.appendIndicator(&row, node); try appendStyled(&row, gpa, self.nodeStyle(node), node.value); try padToWidth(&row, gpa, tree_w); try appendStyled(out, gpa, self.styles.tree_style, row.items); try out.append(gpa, '\n'); try self.buildChildren(out, node, "", tree_w); } fn buildChildren(self: *Tree, out: *ArrayList(u8), node: *const Node, prefix: []const u8, tree_w: u16) Allocator.Error!void { if (!node.open) return; const children = node.children.items; const n = children.len; // Farthest-visible maximum enumeration prefix width among siblings. var max_len: u16 = 0; for (children, 0..) |_, i| { const seg = self.enumerator(n, i); max_len = @max(max_len, matte.measure.cellWidth(seg)); } for (children, 0..) |child, i| { if (child.hidden) continue; const enum_seg = self.enumerator(n, i); const indent_seg = self.indenter(n, i); var line = ArrayList(u8).empty; defer line.deinit(self.gpa); try line.appendSlice(self.gpa, prefix); // Pad to the sibling max so enumeration lines up (pad goes before the // enumerator, mirroring bubbles). const pad: u16 = max_len -| matte.measure.cellWidth(enum_seg); try padSpaces(&line, self.gpa, pad); try appendStyled(&line, self.gpa, self.styles.enumerator_style, enum_seg); try self.appendIndicator(&line, child); try appendStyled(&line, self.gpa, self.nodeStyle(child), child.value); try padToWidth(&line, self.gpa, tree_w); try out.appendSlice(self.gpa, line.items); try out.append(self.gpa, '\n'); // Recurse into the child with its indent appended to the prefix. var child_prefix = ArrayList(u8).empty; defer child_prefix.deinit(self.gpa); try child_prefix.appendSlice(self.gpa, prefix); try appendStyled(&child_prefix, self.gpa, self.styles.indenter_style, indent_seg); try self.buildChildren(out, child, child_prefix.items, tree_w); } } fn appendIndicator(self: *Tree, out: *ArrayList(u8), node: *const Node) Allocator.Error!void { if (!node.is_branch) return; const ch = if (node.open) self.open_character else self.closed_character; var buf: [64]u8 = undefined; var w: Writer = .fixed(&buf); self.styles.open_indicator_style.render(&w, ch) catch {}; try out.appendSlice(self.gpa, w.buffered()); try out.append(self.gpa, ' '); _ = &buf; } fn appendStyled(out: *ArrayList(u8), gpa: Allocator, s: matte.Style, text: []const u8) Allocator.Error!void { var buf: [4096]u8 = undefined; var w: Writer = .fixed(&buf); s.render(&w, text) catch {}; try out.appendSlice(gpa, w.buffered()); } fn padToWidth(out: *ArrayList(u8), gpa: Allocator, target: u16) Allocator.Error!void { const cur = matte.measure.cellWidth(out.items); if (target > cur) try padSpaces(out, gpa, target - cur); } fn padSpaces(out: *ArrayList(u8), gpa: Allocator, count: u16) Allocator.Error!void { var i: u16 = 0; while (i < count) : (i += 1) try out.append(gpa, ' '); } fn findNode(node: *const Node, target: i32) ?*Node { if (node.y_offset == target) return @constCast(node); if (!node.open) return null; for (node.children.items) |c| { if (findNode(c, target)) |found| return found; } return null; } pub fn view(self: *Tree, w: *Writer) Writer.Error!void { try self.viewport.view(w); if (self.show_help) { try w.writeByte('\n'); var i: u16 = 0; while (i < self.styles.help_style.padding.top) : (i += 1) try w.writeByte('\n'); if (!self.help.show_all) { try self.writeShortHelp(w); } else { try self.writeFullHelp(w); } } } fn writeShortHelp(self: *const Tree, w: *Writer) Writer.Error!void { const bindings = [_]key.Binding{ self.key_map.down, self.key_map.up, self.key_map.toggle, self.key_map.show_full_help, }; try self.help.shortView(w, &bindings); } fn writeFullHelp(self: *const Tree, w: *Writer) Writer.Error!void { const g1 = [_]key.Binding{ self.key_map.down, self.key_map.up, self.key_map.open, self.key_map.close, self.key_map.toggle, }; const g2 = [_]key.Binding{ self.key_map.page_down, self.key_map.page_up, self.key_map.half_page_down, self.key_map.half_page_up, }; const g3 = [_]key.Binding{ self.key_map.goto_top, self.key_map.goto_bottom }; const groups = [_][]const key.Binding{ &g1, &g2, &g3 }; try self.help.fullView(w, &groups); } const testing = std.testing; fn testRoot(gpa: Allocator) !*Node { const root = try Node.new(gpa, "~/charm"); try root.child(gpa, "ayman"); const bash = try Node.new(gpa, "bash"); const tools = try Node.new(gpa, "tools"); try tools.child(gpa, "zsh"); try tools.child(gpa, "doom-emacs"); try bash.childNode(gpa, tools); try root.childNode(gpa, bash); const carlos = try Node.new(gpa, "carlos"); const emotes = try Node.new(gpa, "emotes"); try emotes.child(gpa, "chefkiss.png"); try emotes.child(gpa, "kekw.png"); try carlos.childNode(gpa, emotes); try root.childNode(gpa, carlos); try root.child(gpa, "maas"); return root; } fn treeText(tree: *Tree, buf: []u8) []const u8 { var w: Writer = .fixed(buf); tree.view(&w) catch {}; return w.buffered(); } test "tree node builds and sizes" { const gpa = testing.allocator; const root = try testRoot(gpa); defer root.deinit(); try testing.expectEqual(@as(usize, 11), root.size()); } test "tree renders matching bubbles default golden" { const gpa = testing.allocator; const root = try testRoot(gpa); var tree: Tree = .{}; defer tree.deinit(); tree.gpa = gpa; tree.setRoot(root); tree.setSize(70, 13); const expected = \\→ ▼ ~/charm \\ ├──ayman \\ ├──▼ bash \\ │ └──▼ tools \\ │ ├──zsh \\ │ └──doom-emacs \\ ├──▼ carlos \\ │ └──▼ emotes \\ │ ├──chefkiss.png \\ │ └──kekw.png \\ └──maas \\ \\↓/j down • ↑/k up • ⏎ toggle • ? more ; var buf: [4096]u8 = undefined; // Strip ANSI and trailing per-line padding to compare structure. var stripped = ArrayList(u8).empty; defer stripped.deinit(gpa); stripAnsi(&stripped, gpa, treeText(&tree, &buf)); const final = stripLineTrailing(gpa, stripped.items); defer gpa.free(final); try testing.expectEqualStrings(expected, final); } test "tree down past collapsed set keeps correct selection order" { const gpa = testing.allocator; const root = try testRoot(gpa); // Select "bash" (index 2) and collapse it via the real toggle path. var tree: Tree = .{}; defer tree.deinit(); tree.gpa = gpa; tree.setRoot(root); tree.setSize(70, 13); tree.y_offset = 2; tree.toggleCurrentNode(); // Collapsed "bash" should no longer expose zsh/doom-emacs, but must still // occupy a single selectable row. Walking down in preorder should then // land on "carlos", then descend into its subtree, then "maas". const expected = [_][]const u8{ "~/charm", "ayman", "bash", "carlos", "emotes", "chefkiss.png", "kekw.png", "maas", }; tree.y_offset = 0; for (expected, 0..) |expect, i| { const node = tree.nodeAtCurrentOffset(); try testing.expect(node != null); const got = if (node) |n| n.value else ""; try testing.expectEqualStrings(expect, got); if (i + 1 < expected.len) tree.down(); } // Bottom of the (collapsed) tree is reachable. tree.gotoBottom(); const b = tree.nodeAtCurrentOffset(); try testing.expectEqualStrings("maas", b.?.value); // Re-expand "bash" and confirm the hidden children come back in order. tree.y_offset = 2; tree.toggleCurrentNode(); tree.gotoBottom(); const last = tree.nodeAtCurrentOffset(); try testing.expectEqualStrings("maas", last.?.value); } fn stripAnsi(out: *ArrayList(u8), gpa: Allocator, s: []const u8) void { var i: usize = 0; while (i < s.len) { if (s[i] == 0x1b) { i += skipEscape(s, i); } else if (s[i] == '\n') { out.append(gpa, '\n') catch {}; i += 1; } else { out.append(gpa, s[i]) catch {}; i += 1; } } } /// Returns a copy of `s` with trailing whitespace removed from every line. /// Caller owns the returned slice. fn stripLineTrailing(gpa: Allocator, s: []const u8) []u8 { var new = ArrayList(u8).empty; defer new.deinit(gpa); var line_start: usize = 0; var i: usize = 0; while (i <= s.len) : (i += 1) { if (i == s.len or s[i] == '\n') { var j = i; while (j > line_start and s[j - 1] == ' ') j -= 1; new.appendSlice(gpa, s[line_start..j]) catch {}; if (i < s.len) new.append(gpa, '\n') catch {}; line_start = i + 1; } } return new.toOwnedSlice(gpa) catch return gpa.dupe(u8, "") catch &[_]u8{}; } fn skipEscape(s: []const u8, index: usize) usize { var j = index + 1; if (j < s.len and s[j] == '[') { j += 1; while (j < s.len and (s[j] < 0x40 or s[j] > 0x7e)) j += 1; return if (j < s.len) j + 1 - index else s.len - index; } return 2; }