New Widgets: - TextArea: Multi-line text editor with cursor navigation, line numbers, selection, and scrolling support - Tree: Hierarchical tree view with expand/collapse, keyboard navigation, and selection - Badge: Status labels with variants (primary, success, warning, danger, info, outline), dismissible option - TagGroup: Multiple badges in a row with wrapping From previous session (v0.7.0): - Progress: Bar (solid, striped, gradient, segmented), Circle, Spinner (circular, dots, bars, ring) - Tooltip: Hover tooltips with smart positioning - Toast: Non-blocking notifications with auto-dismiss Widget count: 23 widgets Test count: 163 tests passing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
467 lines
14 KiB
Zig
467 lines
14 KiB
Zig
//! 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));
|
|
}
|