Something went wrong. Try again.
A charm-like tui library
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869//! 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;}