//! Tree Widget - Hierarchical tree view //! //! A collapsible tree structure for displaying hierarchical data. //! Supports keyboard navigation, selection, and expansion/collapse. const std = @import("std"); const Allocator = std.mem.Allocator; const Context = @import("../core/context.zig").Context; const Command = @import("../core/command.zig"); const Layout = @import("../core/layout.zig"); const Style = @import("../core/style.zig"); const Input = @import("../core/input.zig"); /// Tree node ID (for tracking state) pub const NodeId = u64; /// Tree node definition pub const TreeNode = struct { /// Unique ID for this node id: NodeId, /// Label text label: []const u8, /// Optional icon (single character) icon: ?u8 = null, /// Whether this node has children has_children: bool = false, /// Depth level (0 = root) depth: u8 = 0, /// User data pointer user_data: ?*anyopaque = null, }; /// Tree state (caller-managed) pub const TreeState = struct { /// Allocator for dynamic data allocator: Allocator, /// Set of expanded node IDs expanded: std.AutoHashMap(NodeId, void), /// Currently selected node ID selected: ?NodeId = null, /// Scroll offset (rows) scroll_y: usize = 0, /// Whether tree has focus focused: bool = false, const Self = @This(); /// Initialize tree state pub fn init(allocator: Allocator) Self { return .{ .allocator = allocator, .expanded = std.AutoHashMap(NodeId, void).init(allocator), }; } /// Deinitialize and free resources pub fn deinit(self: *Self) void { self.expanded.deinit(); } /// Check if a node is expanded pub fn isExpanded(self: Self, id: NodeId) bool { return self.expanded.contains(id); } /// Expand a node pub fn expand(self: *Self, id: NodeId) void { self.expanded.put(id, {}) catch {}; } /// Collapse a node pub fn collapse(self: *Self, id: NodeId) void { _ = self.expanded.remove(id); } /// Toggle expand/collapse pub fn toggle(self: *Self, id: NodeId) void { if (self.isExpanded(id)) { self.collapse(id); } else { self.expand(id); } } /// Expand all nodes from a list pub fn expandAll(self: *Self, nodes: []const TreeNode) void { for (nodes) |node| { if (node.has_children) { self.expand(node.id); } } } /// Collapse all nodes pub fn collapseAll(self: *Self) void { self.expanded.clearRetainingCapacity(); } }; /// Tree configuration pub const TreeConfig = struct { /// Indent width per level (pixels) indent: u32 = 16, /// Row height row_height: u32 = 20, /// Show icons show_icons: bool = true, /// Show expand/collapse indicators show_indicators: bool = true, /// Enable keyboard navigation keyboard_nav: bool = true, /// Allow multiple selection (future) multi_select: bool = false, }; /// Tree colors pub const TreeColors = struct { background: Style.Color = Style.Color.rgba(30, 30, 30, 255), text: Style.Color = Style.Color.rgba(220, 220, 220, 255), text_selected: Style.Color = Style.Color.rgba(255, 255, 255, 255), icon: Style.Color = Style.Color.rgba(180, 180, 180, 255), indicator: Style.Color = Style.Color.rgba(150, 150, 150, 255), selected_bg: Style.Color = Style.Color.rgba(50, 100, 150, 255), hovered_bg: Style.Color = Style.Color.rgba(60, 60, 60, 255), border: Style.Color = Style.Color.rgba(80, 80, 80, 255), guide_line: Style.Color = Style.Color.rgba(60, 60, 60, 255), pub fn fromTheme(theme: Style.Theme) TreeColors { return .{ .background = theme.background, .text = theme.foreground, .text_selected = theme.foreground, .icon = theme.secondary, .indicator = theme.secondary, .selected_bg = theme.selection_bg, .hovered_bg = theme.background.lighten(10), .border = theme.border, .guide_line = theme.border.darken(20), }; } }; /// Result of tree widget pub const TreeResult = struct { /// A node was selected selected: ?NodeId = null, /// A node was double-clicked (or Enter pressed) activated: ?NodeId = null, /// A node was expanded expanded: ?NodeId = null, /// A node was collapsed collapsed: ?NodeId = null, /// Widget was clicked clicked: bool = false, }; /// Callback type for getting child nodes pub const GetChildrenFn = *const fn (parent_id: ?NodeId, user_data: ?*anyopaque) []const TreeNode; /// Draw a tree and return interaction result pub fn tree( ctx: *Context, state: *TreeState, nodes: []const TreeNode, ) TreeResult { return treeEx(ctx, state, nodes, .{}, .{}); } /// Draw a tree with custom configuration pub fn treeEx( ctx: *Context, state: *TreeState, nodes: []const TreeNode, config: TreeConfig, colors: TreeColors, ) TreeResult { const bounds = ctx.layout.nextRect(); return treeRect(ctx, bounds, state, nodes, config, colors); } /// Draw a tree in a specific rectangle pub fn treeRect( ctx: *Context, bounds: Layout.Rect, state: *TreeState, nodes: []const TreeNode, config: TreeConfig, colors: TreeColors, ) TreeResult { var result = TreeResult{}; if (bounds.isEmpty()) return result; // Check mouse interaction const mouse = ctx.input.mousePos(); const hovered = bounds.contains(mouse.x, mouse.y); const clicked = hovered and ctx.input.mousePressed(.left); if (clicked) { state.focused = true; result.clicked = true; } // Draw background ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, colors.background)); // Draw border ctx.pushCommand(Command.rectOutline(bounds.x, bounds.y, bounds.w, bounds.h, colors.border)); // Calculate visible rows const inner = bounds.shrink(1); if (inner.isEmpty()) return result; const visible_rows = inner.h / config.row_height; const row_height = config.row_height; // Filter visible nodes (expanded parents only) var visible_count: usize = 0; var row_index: usize = 0; for (nodes) |node| { // Check if all ancestors are expanded if (!isNodeVisible(nodes, node, state)) continue; // Check if in scroll range if (row_index >= state.scroll_y and visible_count < visible_rows) { const y = inner.y + @as(i32, @intCast(visible_count * row_height)); // Check if this row is hovered const row_hovered = hovered and mouse.y >= y and mouse.y < y + @as(i32, @intCast(row_height)); // Handle click on this row if (row_hovered and clicked) { // Check if click is on expand indicator const indicator_x = inner.x + @as(i32, @intCast(node.depth * config.indent)); const indicator_w: i32 = if (config.show_indicators) 12 else 0; if (node.has_children and config.show_indicators and mouse.x >= indicator_x and mouse.x < indicator_x + indicator_w) { // Toggle expand/collapse state.toggle(node.id); if (state.isExpanded(node.id)) { result.expanded = node.id; } else { result.collapsed = node.id; } } else { // Select the node state.selected = node.id; result.selected = node.id; } } // Draw row const is_selected = state.selected != null and state.selected.? == node.id; drawTreeRow(ctx, inner, y, node, state, config, colors, is_selected, row_hovered); visible_count += 1; } row_index += 1; } return result; } /// Check if a node is visible (all ancestors expanded) fn isNodeVisible(nodes: []const TreeNode, node: TreeNode, state: *TreeState) bool { if (node.depth == 0) return true; // Find parent at depth-1 that contains this node // This is a simplification - in a real tree you'd track parent IDs var target_depth = node.depth - 1; var i: usize = 0; for (nodes, 0..) |n, idx| { if (n.id == node.id) { i = idx; break; } } // Walk backwards to find ancestors while (target_depth > 0) : (target_depth -= 1) { var found = false; var j = i; while (j > 0) { j -= 1; if (nodes[j].depth == target_depth) { if (!state.isExpanded(nodes[j].id)) return false; found = true; i = j; break; } } if (!found and target_depth > 0) return false; } // Check immediate parent (depth-1) if (node.depth > 0) { var j: usize = 0; for (nodes, 0..) |n, idx| { if (n.id == node.id) { j = idx; break; } } while (j > 0) { j -= 1; if (nodes[j].depth == node.depth - 1) { return state.isExpanded(nodes[j].id); } } } return true; } /// Draw a single tree row fn drawTreeRow( ctx: *Context, area: Layout.Rect, y: i32, node: TreeNode, state: *const TreeState, config: TreeConfig, colors: TreeColors, is_selected: bool, is_hovered: bool, ) void { const row_height = config.row_height; var x = area.x + @as(i32, @intCast(node.depth * config.indent)); // Draw selection/hover background if (is_selected) { ctx.pushCommand(Command.rect(area.x, y, area.w, row_height, colors.selected_bg)); } else if (is_hovered) { ctx.pushCommand(Command.rect(area.x, y, area.w, row_height, colors.hovered_bg)); } // Draw expand/collapse indicator if (config.show_indicators and node.has_children) { const expanded = state.isExpanded(node.id); const indicator = if (expanded) "-" else "+"; const text_y = y + @as(i32, @intCast((row_height -| 8) / 2)); ctx.pushCommand(Command.text(x, text_y, indicator, colors.indicator)); } x += if (config.show_indicators) 12 else 0; // Draw icon if (config.show_icons) { if (node.icon) |icon| { const icon_str = &[_]u8{icon}; const text_y = y + @as(i32, @intCast((row_height -| 8) / 2)); ctx.pushCommand(Command.text(x, text_y, icon_str, colors.icon)); } x += 12; } // Draw label const text_color = if (is_selected) colors.text_selected else colors.text; const text_y = y + @as(i32, @intCast((row_height -| 8) / 2)); ctx.pushCommand(Command.text(x, text_y, node.label, text_color)); } // ============================================================================= // Tests // ============================================================================= test "TreeState expand/collapse" { var state = TreeState.init(std.testing.allocator); defer state.deinit(); // Initially not expanded try std.testing.expect(!state.isExpanded(1)); // Expand state.expand(1); try std.testing.expect(state.isExpanded(1)); // Collapse state.collapse(1); try std.testing.expect(!state.isExpanded(1)); // Toggle state.toggle(2); try std.testing.expect(state.isExpanded(2)); state.toggle(2); try std.testing.expect(!state.isExpanded(2)); } test "TreeState selection" { var state = TreeState.init(std.testing.allocator); defer state.deinit(); try std.testing.expectEqual(@as(?NodeId, null), state.selected); state.selected = 42; try std.testing.expectEqual(@as(?NodeId, 42), state.selected); } test "TreeState expandAll/collapseAll" { var state = TreeState.init(std.testing.allocator); defer state.deinit(); const nodes = [_]TreeNode{ .{ .id = 1, .label = "Root", .has_children = true }, .{ .id = 2, .label = "Child 1", .has_children = true, .depth = 1 }, .{ .id = 3, .label = "Child 2", .has_children = false, .depth = 1 }, }; state.expandAll(&nodes); try std.testing.expect(state.isExpanded(1)); try std.testing.expect(state.isExpanded(2)); try std.testing.expect(!state.isExpanded(3)); // no children state.collapseAll(); try std.testing.expect(!state.isExpanded(1)); try std.testing.expect(!state.isExpanded(2)); } test "tree generates commands" { var ctx = try Context.init(std.testing.allocator, 800, 600); defer ctx.deinit(); var state = TreeState.init(std.testing.allocator); defer state.deinit(); const nodes = [_]TreeNode{ .{ .id = 1, .label = "Root" }, }; ctx.beginFrame(); ctx.layout.row_height = 200; _ = tree(&ctx, &state, &nodes); // Should generate: rect (bg) + rect_outline (border) + text (label) try std.testing.expect(ctx.commands.items.len >= 2); ctx.endFrame(); } test "isNodeVisible" { var state = TreeState.init(std.testing.allocator); defer state.deinit(); const nodes = [_]TreeNode{ .{ .id = 1, .label = "Root", .has_children = true, .depth = 0 }, .{ .id = 2, .label = "Child", .depth = 1 }, }; // Root is always visible try std.testing.expect(isNodeVisible(&nodes, nodes[0], &state)); // Child not visible when parent not expanded try std.testing.expect(!isNodeVisible(&nodes, nodes[1], &state)); // Child visible when parent expanded state.expand(1); try std.testing.expect(isNodeVisible(&nodes, nodes[1], &state)); }