From 72040918362f32090b88a294e05dfced5673d14e Mon Sep 17 00:00:00 2001 From: Ben Jordan Date: Fri, 28 Aug 2026 21:05:00 -0400 Subject: [PATCH] added tree to leaves --- build.zig | 13 + demo/main.zig | 2 + demo/tree.zig | 96 +++++ flake.nix | 4 + leaves/Tree.zig | 943 ++++++++++++++++++++++++++++++++++++++++++++++++ leaves/root.zig | 1 + zli/Cmd.zig | 62 ---- zli/root.zig | 66 +++- 8 files changed, 1122 insertions(+), 65 deletions(-) create mode 100644 demo/tree.zig create mode 100644 leaves/Tree.zig diff --git a/build.zig b/build.zig index 799a1cb..e9de799 100644 --- a/build.zig +++ b/build.zig @@ -91,6 +91,19 @@ pub fn build(b: *Build) !void { }); const demo_step = b.step("demo", "run demo"); demo_step.dependOn(&b.addInstallArtifact(demo, .{}).step); + + b.installArtifact(demo); + + const run_step = b.step("run", "Run the app"); + + const run_cmd = b.addRunArtifact(demo); + run_step.dependOn(&run_cmd.step); + + run_cmd.step.dependOn(b.getInstallStep()); + + if (b.args) |args| { + run_cmd.addArgs(args); + } } fn import(name: []const u8, module: *Module) Import { diff --git a/demo/main.zig b/demo/main.zig index bed8a06..95caaa2 100644 --- a/demo/main.zig +++ b/demo/main.zig @@ -7,6 +7,7 @@ const chaos = @import("chaos.zig").chaos; const list = @import("list.zig").list; const spinner = @import("spinner.zig").spinner; const stopwatch = @import("stopwatch.zig").stopwatch; +const tree = @import("tree.zig").tree; const table = @import("table.zig").table; const animated = @import("progress-animated.zig").animated; const static = @import("progress-static.zig").static; @@ -39,6 +40,7 @@ pub fn main(init: std.process.Init) !void { .cmd(chaos, "Stress test the renderer"), .cmd(help, "Help interface example"), .cmd(spinner, "Spinner example"), + .cmd(tree, "Collapsible tree interface"), .cmd(pager, "Pager interface"), .cmd(paginator, "Paginator interface"), .cmd(dream, "SSH server"), diff --git a/demo/tree.zig b/demo/tree.zig new file mode 100644 index 0000000..b78bf7d --- /dev/null +++ b/demo/tree.zig @@ -0,0 +1,96 @@ +//! Interactive demo of the `leaves.Tree` component: a collapsible file-tree +//! style view. Navigate with `↓`/`j` and `↑`/`k`, expand/collapse the current +//! node with `⏎`, jump to top/bottom with `g`/`G`, and open the full help with +//! `?`. Quit with `q` or `Ctrl+C`. +const std = @import("std"); +const matcha = @import("matcha"); +const leaves = @import("leaves"); + +const Writer = std.Io.Writer; + +const State = struct { + model: matcha.Model = .{ .vtable = &.{ + .init = init, + .update = update, + .view = view, + } }, + tree: leaves.Tree = .{}, + quitting: bool = false, + + fn init(m: *matcha.Model) ?matcha.Cmd { + const self: *State = @fieldParentPtr("model", m); + self.tree.gpa = m.gpa; + self.tree.setRoot(buildProject(m.gpa)); + return null; + } + + fn buildProject(gpa: std.mem.Allocator) *leaves.Tree.Node { + const root = leaves.Tree.Node.new(gpa, "charm") catch unreachable; + root.setOpen(true); + + const src = leaves.Tree.Node.new(gpa, "src") catch unreachable; + src.setOpen(true); + src.child(gpa, "main.zig") catch unreachable; + const ui = leaves.Tree.Node.new(gpa, "ui") catch unreachable; + ui.setOpen(true); + ui.child(gpa, "theme.zig") catch unreachable; + ui.child(gpa, "spinner.zig") catch unreachable; + src.childNode(gpa, ui) catch unreachable; + root.childNode(gpa, src) catch unreachable; + + root.child(gpa, "build.zig") catch unreachable; + root.child(gpa, "build.zig.zon") catch unreachable; + + const examples = leaves.Tree.Node.new(gpa, "examples") catch unreachable; + examples.setOpen(false); + examples.child(gpa, "basic.zig") catch unreachable; + examples.child(gpa, "styled.zig") catch unreachable; + root.childNode(gpa, examples) catch unreachable; + + root.child(gpa, "README.md") catch unreachable; + return root; + } + + fn update(m: *matcha.Model, msg: matcha.Msg) ?matcha.Cmd { + const self: *State = @fieldParentPtr("model", m); + switch (msg) { + .key_press => |k| { + if (k.kind == .rune and k.rune == 'q') { + self.quitting = true; + return .quit; + } + if (k.kind == .rune and k.rune == 'c' and k.mods.ctrl) { + self.quitting = true; + return .quit; + } + }, + .window_size => |ws| { + self.tree.setSize(ws.width, ws.height); + return null; + }, + .interrupt => return .quit, + else => {}, + } + return self.tree.update(msg); + } + + fn view(m: *matcha.Model, w: *Writer) Writer.Error!matcha.View { + const self: *State = @fieldParentPtr("model", m); + if (self.quitting) { + try w.writeAll("\n bye!\n\n"); + return .{}; + } + try self.tree.view(w); + return .{ .alt_screen = true }; + } +}; + +pub fn tree(init: std.process.Init) !void { + var s: State = .{}; + s.tree.gpa = init.gpa; + defer s.tree.deinit(); + + var program: matcha.Program = try .init(init, &s.model, .{}); + defer program.deinit(); + try program.run(); +} diff --git a/flake.nix b/flake.nix index 4903894..252139a 100644 --- a/flake.nix +++ b/flake.nix @@ -45,6 +45,10 @@ let pkgs = nixpkgs.legacyPackages.${system}; in { default = pkgs.mkShell { + name = "hex"; + shellHook = '' + export name=hex + ''; packages = [ pkgs.zig pkgs.pkg-config pkgs.libssh ]; }; }); diff --git a/leaves/Tree.zig b/leaves/Tree.zig new file mode 100644 index 0000000..b4bb455 --- /dev/null +++ b/leaves/Tree.zig @@ -0,0 +1,943 @@ +//! 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 matcha = @import("matcha"); +const blush = @import("blush"); +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 Cmd = matcha.Cmd; +const Msg = matcha.Msg; +const ArrayList = std.ArrayList; + +const Tree = @This(); + +pub const Style = blush.Style; + +// --------------------------------------------------------------------------- +// Enumerator / Indenter +// --------------------------------------------------------------------------- + +/// Generates the branch character for a child at `index` among `count` +/// siblings (e.g. "├──" / "└──"). +pub 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. "│ " / " "). +pub 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 "│ "; +} + +// --------------------------------------------------------------------------- +// Styles +// --------------------------------------------------------------------------- + +pub const Styles = struct { + tree_style: Style = .{}, + help_style: Style = .{ .padding = .{ .top = 1 } }, + selected_node_style: Style = .{}, + node_style: Style = .{}, + root_node_style: Style = .{}, + parent_node_style: Style = .{}, + cursor_style: Style = .{}, + enumerator_style: Style = .{}, + selected_enumerator_style: Style = .{}, + indenter_style: Style = .{}, + open_indicator_style: Style = .{}, +}; + +pub const default_dark_styles: Styles = blk: { + const very_subdued: Style.Color = .{ .rgb = .{ .r = 0x3C, .g = 0x3C, .b = 0x3C } }; + const subdued: Style.Color = .{ .rgb = .{ .r = 0x5C, .g = 0x5C, .b = 0x5C } }; + break :blk .{ + .tree_style = .{}, + .help_style = .{ .padding = .{ .top = 1 } }, + .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 = very_subdued }, + .selected_enumerator_style = .{ .foreground = very_subdued }, + .indenter_style = .{ .foreground = very_subdued }, + .open_indicator_style = .{ .foreground = subdued }, + }; +}; + +// --------------------------------------------------------------------------- +// Node +// --------------------------------------------------------------------------- + +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; + } +}; + +// --------------------------------------------------------------------------- +// KeyMap +// --------------------------------------------------------------------------- + +pub 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" }, + }, +}; + +// --------------------------------------------------------------------------- +// Model +// --------------------------------------------------------------------------- + +model: matcha.Model = .{ .vtable = &vtable }, + +/// 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 = default_dark_styles, +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, + +const vtable: matcha.Model.VTable = .{ + .init = vtableInit, + .update = vtableUpdate, + .view = vtableView, +}; + +fn vtableInit(_: *matcha.Model) ?Cmd { + return null; +} + +fn vtableUpdate(m: *matcha.Model, msg: Msg) ?Cmd { + const self: *Tree = @fieldParentPtr("model", m); + self.gpa = m.gpa; + return self.update(msg); +} + +fn vtableView(m: *matcha.Model, w: *Writer) Writer.Error!matcha.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(); +} + +// ---- accessors ---- + +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(); +} + +// ---- sizing / styling ---- + +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(); +} + +// --------------------------------------------------------------------------- +// Navigation +// --------------------------------------------------------------------------- + +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))); + } +} + +// --------------------------------------------------------------------------- +// Update +// --------------------------------------------------------------------------- + +pub fn update(self: *Tree, msg: Msg) ?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; +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +fn cursorWidth(self: *const Tree) u16 { + if (self.cursor_character.len == 0) return 0; + return blush.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) 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 = blush.measure.lineCount(w.buffered()); + return text_h +| self.styles.help_style.padding.top; +} + +// ---- offsets ---- + +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 blush.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; +} + +// ---- full render ---- + +/// 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 = blush.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, blush.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 -| blush.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: 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 = blush.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; +} + +// --------------------------------------------------------------------------- +// View +// --------------------------------------------------------------------------- + +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); +} + +// --------------------------------------------------------------------------- +// Help of tree used by helpHeight above. +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// tests +// --------------------------------------------------------------------------- + +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; +} diff --git a/leaves/root.zig b/leaves/root.zig index 2a98d01..0ded513 100644 --- a/leaves/root.zig +++ b/leaves/root.zig @@ -15,6 +15,7 @@ pub const Table = @import("Table.zig"); pub const Textarea = @import("Textarea.zig"); pub const List = @import("List.zig"); pub const FilePicker = @import("FilePicker.zig"); +pub const Tree = @import("Tree.zig"); test { std.testing.refAllDecls(@This()); diff --git a/zli/Cmd.zig b/zli/Cmd.zig index e60f8fb..049d24a 100644 --- a/zli/Cmd.zig +++ b/zli/Cmd.zig @@ -270,65 +270,3 @@ fn parseList(comptime T: type, arena: Allocator, raw: []const u8) !T { } return out; } - -/// Base type used for function parameters. Declares a named CLI parameter with a type, -/// required/optional status, and description. -pub fn Param( - comptime name: []const u8, - comptime T: type, - comptime param: Type(T), - comptime description: []const u8, -) type { - return struct { - pub const param_name = name; - pub const BaseType = T; - pub const param_type: Type(T) = param; - pub const param_description = description; - - value: T, - }; -} - -/// Optional boolean switch used as a function arg. Value is true if present. -pub fn Switch(comptime name: []const u8, comptime description: []const u8) type { - return Param(name, bool, .{ .swtch = false }, description); -} - -/// List of type T. iterator returns null when out of values. -pub fn List(comptime name: []const u8, comptime T: type, comptime description: []const u8, comptime optional: bool) type { - return struct { - const Self = @This(); - - // default value for this type is an empty list - pub const param_name = name; - pub const BaseType = []T; - pub const param_type: Type(bool) = .{ .lst = optional }; - pub const param_description = description; - - value: []T, - - pub fn iterator(self: *Self) Iterator { - _ = self; - return .{}; - } - - const Iterator = struct {}; - }; -} - -fn Type(comptime T: type) type { - return union(enum) { - req, - swtch: T, - lst: T, // if true this arg is optional - default: T, - - const Self = @This(); - - pub const required: Self = .{ .req = {} }; - /// if value is not pulled from args, this one will be used - pub fn optional(default: T) Self { - return .{ .default = default }; - } - }; -} diff --git a/zli/root.zig b/zli/root.zig index 297feef..600d898 100644 --- a/zli/root.zig +++ b/zli/root.zig @@ -9,8 +9,6 @@ const Cmd = @import("Cmd.zig"); const help = @import("help.zig"); const ArgMap = StringArrayHashMap([]const u8); -pub const Param = Cmd.Param; - /// Parse process arguments and execute the targeted function pub fn run( init: Init, @@ -165,7 +163,7 @@ pub fn run( if (command == null) { printGroupHelp(init.io, description, group, cmd_list) catch {}; - std.process.exit(1); + std.process.exit(0); } const cmd_name = tree.items[tree.items.len - 1]; @@ -318,3 +316,65 @@ fn findValue(map: *const ArgMap, params: []const Cmd.Command.Param, canonical: [ } return ""; } + +/// Base type used for function parameters. Declares a named CLI parameter with a type, +/// required/optional status, and description. +pub fn Param( + comptime name: []const u8, + comptime T: type, + comptime param: Type(T), + comptime description: []const u8, +) type { + return struct { + pub const param_name = name; + pub const BaseType = T; + pub const param_type: Type(T) = param; + pub const param_description = description; + + value: T, + }; +} + +/// Optional boolean switch used as a function arg. Value is true if present. +pub fn Switch(comptime name: []const u8, comptime description: []const u8) type { + return Param(name, bool, .{ .swtch = false }, description); +} + +/// List of type T. iterator returns null when out of values. +pub fn List(comptime name: []const u8, comptime T: type, comptime description: []const u8, comptime optional: bool) type { + return struct { + const Self = @This(); + + // default value for this type is an empty list + pub const param_name = name; + pub const BaseType = []T; + pub const param_type: Type(bool) = .{ .lst = optional }; + pub const param_description = description; + + value: []T, + + pub fn iterator(self: *Self) Iterator { + _ = self; + return .{}; + } + + const Iterator = struct {}; + }; +} + +fn Type(comptime T: type) type { + return union(enum) { + req, + swtch: T, + lst: T, // if true this arg is optional + default: T, + + const Self = @This(); + + pub const required: Self = .{ .req = {} }; + /// if value is not pulled from args, this one will be used + pub fn optional(default: T) Self { + return .{ .default = default }; + } + }; +} -- 2.51.2