From 8044c1df43d3c6b1511328d75c7a0f1450ce76b7 Mon Sep 17 00:00:00 2001 From: reugenio Date: Tue, 9 Dec 2025 13:04:41 +0100 Subject: [PATCH] feat: zcatgui v0.8.0 - Phase 2 Complete (6 new widgets) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/widgets/badge.zig | 502 ++++++++++++++++++++++ src/widgets/textarea.zig | 871 +++++++++++++++++++++++++++++++++++++++ src/widgets/tree.zig | 467 +++++++++++++++++++++ src/widgets/widgets.zig | 30 ++ 4 files changed, 1870 insertions(+) create mode 100644 src/widgets/badge.zig create mode 100644 src/widgets/textarea.zig create mode 100644 src/widgets/tree.zig diff --git a/src/widgets/badge.zig b/src/widgets/badge.zig new file mode 100644 index 0000000..06f30ac --- /dev/null +++ b/src/widgets/badge.zig @@ -0,0 +1,502 @@ +//! Badge Widget - Labels and tags +//! +//! Colored badges/tags for status indicators, labels, and counts. +//! Supports different variants, sizes, and optional dismiss buttons. + +const std = @import("std"); +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"); + +/// Badge variant +pub const Variant = enum { + /// Default style + default, + /// Primary/accent color + primary, + /// Success/positive + success, + /// Warning/caution + warning, + /// Error/danger + danger, + /// Info/neutral + info, + /// Outline style (border only) + outline, +}; + +/// Badge size +pub const Size = enum { + small, + medium, + large, +}; + +/// Badge configuration +pub const Config = struct { + /// Visual variant + variant: Variant = .default, + /// Size + size: Size = .medium, + /// Show dismiss/close button + dismissible: bool = false, + /// Pill shape (fully rounded) + pill: bool = false, + /// Optional icon (single character) + icon: ?u8 = null, +}; + +/// Badge colors +pub const Colors = struct { + /// Colors for each variant + default_bg: Style.Color = Style.Color.rgba(80, 80, 80, 255), + default_fg: Style.Color = Style.Color.rgba(220, 220, 220, 255), + primary_bg: Style.Color = Style.Color.rgba(100, 149, 237, 255), + primary_fg: Style.Color = Style.Color.rgba(255, 255, 255, 255), + success_bg: Style.Color = Style.Color.rgba(46, 160, 67, 255), + success_fg: Style.Color = Style.Color.rgba(255, 255, 255, 255), + warning_bg: Style.Color = Style.Color.rgba(210, 153, 34, 255), + warning_fg: Style.Color = Style.Color.rgba(30, 30, 30, 255), + danger_bg: Style.Color = Style.Color.rgba(207, 34, 46, 255), + danger_fg: Style.Color = Style.Color.rgba(255, 255, 255, 255), + info_bg: Style.Color = Style.Color.rgba(56, 139, 253, 255), + info_fg: Style.Color = Style.Color.rgba(255, 255, 255, 255), + outline_bg: Style.Color = Style.Color.rgba(0, 0, 0, 0), + outline_fg: Style.Color = Style.Color.rgba(180, 180, 180, 255), + outline_border: Style.Color = Style.Color.rgba(150, 150, 150, 255), + + pub fn fromTheme(theme: Style.Theme) Colors { + return .{ + .default_bg = theme.secondary, + .default_fg = theme.foreground, + .primary_bg = theme.primary, + .primary_fg = Style.Color.rgba(255, 255, 255, 255), + .success_bg = theme.success, + .success_fg = Style.Color.rgba(255, 255, 255, 255), + .warning_bg = theme.warning, + .warning_fg = Style.Color.rgba(30, 30, 30, 255), + .danger_bg = theme.error_color, + .danger_fg = Style.Color.rgba(255, 255, 255, 255), + .info_bg = theme.info, + .info_fg = Style.Color.rgba(255, 255, 255, 255), + .outline_bg = Style.Color.rgba(0, 0, 0, 0), + .outline_fg = theme.foreground, + .outline_border = theme.border, + }; + } + + /// Get background color for variant + pub fn getBg(self: Colors, variant: Variant) Style.Color { + return switch (variant) { + .default => self.default_bg, + .primary => self.primary_bg, + .success => self.success_bg, + .warning => self.warning_bg, + .danger => self.danger_bg, + .info => self.info_bg, + .outline => self.outline_bg, + }; + } + + /// Get foreground color for variant + pub fn getFg(self: Colors, variant: Variant) Style.Color { + return switch (variant) { + .default => self.default_fg, + .primary => self.primary_fg, + .success => self.success_fg, + .warning => self.warning_fg, + .danger => self.danger_fg, + .info => self.info_fg, + .outline => self.outline_fg, + }; + } +}; + +/// Result of badge widget +pub const Result = struct { + /// Badge was clicked + clicked: bool = false, + /// Dismiss button was clicked + dismissed: bool = false, + /// Badge bounds (for positioning related elements) + bounds: Layout.Rect = Layout.Rect.zero(), +}; + +/// Draw a badge and return interaction result +pub fn badge(ctx: *Context, text: []const u8) Result { + return badgeEx(ctx, text, .{}, .{}); +} + +/// Draw a badge with specific variant +pub fn badgeVariant(ctx: *Context, text: []const u8, variant: Variant) Result { + return badgeEx(ctx, text, .{ .variant = variant }, .{}); +} + +/// Draw a badge with custom configuration +pub fn badgeEx(ctx: *Context, text: []const u8, config: Config, colors: Colors) Result { + const bounds = ctx.layout.nextRect(); + return badgeRect(ctx, bounds, text, config, colors); +} + +/// Draw a badge in a specific rectangle +pub fn badgeRect( + ctx: *Context, + bounds: Layout.Rect, + text: []const u8, + config: Config, + colors: Colors, +) Result { + var result = Result{ + .bounds = bounds, + }; + + 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) { + result.clicked = true; + } + + // Get size parameters + const params = getSizeParams(config.size); + const padding_h = params.padding_h; + const padding_v = params.padding_v; + const char_height: u32 = 8; + + // Calculate text width + const char_width: u32 = 8; + var content_width: u32 = @intCast(text.len * char_width); + + // Add icon width + if (config.icon != null) { + content_width += char_width + 4; // icon + spacing + } + + // Add dismiss button width + const dismiss_width: u32 = if (config.dismissible) char_width + 4 else 0; + content_width += dismiss_width; + + // Calculate badge dimensions + const badge_width = @min(content_width + padding_h * 2, bounds.w); + const badge_height = @min(char_height + padding_v * 2, bounds.h); + + // Get colors + const bg_color = if (hovered) + colors.getBg(config.variant).lighten(10) + else + colors.getBg(config.variant); + const fg_color = colors.getFg(config.variant); + + // Draw background + ctx.pushCommand(Command.rect(bounds.x, bounds.y, badge_width, badge_height, bg_color)); + + // Draw border for outline variant + if (config.variant == .outline) { + ctx.pushCommand(Command.rectOutline( + bounds.x, + bounds.y, + badge_width, + badge_height, + colors.outline_border, + )); + } + + // Calculate text position + var text_x = bounds.x + @as(i32, @intCast(padding_h)); + const text_y = bounds.y + @as(i32, @intCast(padding_v)); + + // Draw icon + if (config.icon) |icon| { + const icon_str = &[_]u8{icon}; + ctx.pushCommand(Command.text(text_x, text_y, icon_str, fg_color)); + text_x += @as(i32, @intCast(char_width + 4)); + } + + // Draw text + ctx.pushCommand(Command.text(text_x, text_y, text, fg_color)); + + // Draw dismiss button + if (config.dismissible) { + const dismiss_x = bounds.x + @as(i32, @intCast(badge_width - padding_h - char_width)); + ctx.pushCommand(Command.text(dismiss_x, text_y, "x", fg_color.darken(20))); + + // Check if dismiss button was clicked + if (clicked) { + const dismiss_bounds = Layout.Rect.init( + dismiss_x, + bounds.y, + char_width + padding_h, + badge_height, + ); + if (dismiss_bounds.contains(mouse.x, mouse.y)) { + result.dismissed = true; + result.clicked = false; + } + } + } + + // Update result bounds with actual size + result.bounds = Layout.Rect.init(bounds.x, bounds.y, badge_width, badge_height); + + return result; +} + +/// Size parameters +const SizeParams = struct { + padding_h: u32, + padding_v: u32, +}; + +fn getSizeParams(size: Size) SizeParams { + return switch (size) { + .small => .{ .padding_h = 4, .padding_v = 2 }, + .medium => .{ .padding_h = 8, .padding_v = 4 }, + .large => .{ .padding_h = 12, .padding_v = 6 }, + }; +} + +// ============================================================================= +// Tag Group - Multiple tags in a row +// ============================================================================= + +/// Tag definition for tag groups +pub const Tag = struct { + text: []const u8, + variant: Variant = .default, + icon: ?u8 = null, + dismissible: bool = false, + user_data: ?*anyopaque = null, +}; + +/// Tag group configuration +pub const TagGroupConfig = struct { + /// Spacing between tags + spacing: u32 = 4, + /// Size for all tags + size: Size = .medium, + /// Allow wrapping to multiple lines + wrap: bool = true, +}; + +/// Result of tag group +pub const TagGroupResult = struct { + /// Index of clicked tag (if any) + clicked: ?usize = null, + /// Index of dismissed tag (if any) + dismissed: ?usize = null, +}; + +/// Draw a group of tags +pub fn tagGroup(ctx: *Context, tags: []const Tag) TagGroupResult { + return tagGroupEx(ctx, tags, .{}, .{}); +} + +/// Draw a group of tags with custom configuration +pub fn tagGroupEx( + ctx: *Context, + tags: []const Tag, + config: TagGroupConfig, + colors: Colors, +) TagGroupResult { + const bounds = ctx.layout.nextRect(); + return tagGroupRect(ctx, bounds, tags, config, colors); +} + +/// Draw a group of tags in a specific rectangle +pub fn tagGroupRect( + ctx: *Context, + bounds: Layout.Rect, + tags: []const Tag, + config: TagGroupConfig, + colors: Colors, +) TagGroupResult { + var result = TagGroupResult{}; + + if (bounds.isEmpty() or tags.len == 0) return result; + + const char_width: u32 = 8; + const char_height: u32 = 8; + const params = getSizeParams(config.size); + + var x = bounds.x; + var y = bounds.y; + const tag_height = char_height + params.padding_v * 2; + + for (tags, 0..) |tag, i| { + // Calculate tag width + var tag_width: u32 = @intCast(tag.text.len * char_width); + tag_width += params.padding_h * 2; + if (tag.icon != null) tag_width += char_width + 4; + if (tag.dismissible) tag_width += char_width + 4; + + // Check if we need to wrap + if (config.wrap and x > bounds.x and + @as(u32, @intCast(x - bounds.x)) + tag_width > bounds.w) + { + x = bounds.x; + y += @as(i32, @intCast(tag_height + config.spacing)); + } + + // Check if we're still within bounds + if (y - bounds.y + @as(i32, @intCast(tag_height)) > @as(i32, @intCast(bounds.h))) break; + + // Draw tag + const tag_bounds = Layout.Rect.init(x, y, tag_width, tag_height); + const tag_result = badgeRect(ctx, tag_bounds, tag.text, .{ + .variant = tag.variant, + .size = config.size, + .dismissible = tag.dismissible, + .icon = tag.icon, + }, colors); + + if (tag_result.clicked) { + result.clicked = i; + } + if (tag_result.dismissed) { + result.dismissed = i; + } + + x += @as(i32, @intCast(tag_width + config.spacing)); + } + + return result; +} + +// ============================================================================= +// Convenience constructors +// ============================================================================= + +/// Create a primary badge +pub fn primary(ctx: *Context, text: []const u8) Result { + return badgeVariant(ctx, text, .primary); +} + +/// Create a success badge +pub fn success(ctx: *Context, text: []const u8) Result { + return badgeVariant(ctx, text, .success); +} + +/// Create a warning badge +pub fn warning(ctx: *Context, text: []const u8) Result { + return badgeVariant(ctx, text, .warning); +} + +/// Create a danger badge +pub fn danger(ctx: *Context, text: []const u8) Result { + return badgeVariant(ctx, text, .danger); +} + +/// Create an info badge +pub fn info(ctx: *Context, text: []const u8) Result { + return badgeVariant(ctx, text, .info); +} + +/// Create an outline badge +pub fn outline(ctx: *Context, text: []const u8) Result { + return badgeVariant(ctx, text, .outline); +} + +// ============================================================================= +// Tests +// ============================================================================= + +test "badge generates commands" { + var ctx = try Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + ctx.beginFrame(); + ctx.layout.row_height = 24; + + _ = badge(&ctx, "Test"); + + // Should generate: rect (bg) + text + try std.testing.expect(ctx.commands.items.len >= 2); + + ctx.endFrame(); +} + +test "badge variants" { + var ctx = try Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + ctx.beginFrame(); + ctx.layout.row_height = 24; + + _ = primary(&ctx, "Primary"); + _ = success(&ctx, "Success"); + _ = warning(&ctx, "Warning"); + _ = danger(&ctx, "Danger"); + _ = info(&ctx, "Info"); + _ = outline(&ctx, "Outline"); + + try std.testing.expect(ctx.commands.items.len >= 12); + + ctx.endFrame(); +} + +test "badge with icon" { + var ctx = try Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + ctx.beginFrame(); + ctx.layout.row_height = 24; + + _ = badgeEx(&ctx, "Status", .{ .icon = '*' }, .{}); + + // Should have icon text + label text + try std.testing.expect(ctx.commands.items.len >= 3); + + ctx.endFrame(); +} + +test "badge dismissible" { + var ctx = try Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + ctx.beginFrame(); + ctx.layout.row_height = 24; + + _ = badgeEx(&ctx, "Tag", .{ .dismissible = true }, .{}); + + // Should have bg + text + dismiss x + try std.testing.expect(ctx.commands.items.len >= 3); + + ctx.endFrame(); +} + +test "tagGroup" { + var ctx = try Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + ctx.beginFrame(); + ctx.layout.row_height = 100; + + const tags = [_]Tag{ + .{ .text = "Tag1", .variant = .primary }, + .{ .text = "Tag2", .variant = .success }, + .{ .text = "Tag3", .variant = .warning }, + }; + + _ = tagGroup(&ctx, &tags); + + // Should have commands for all 3 tags + try std.testing.expect(ctx.commands.items.len >= 6); + + ctx.endFrame(); +} + +test "Colors.getBg/getFg" { + const colors = Colors{}; + + try std.testing.expectEqual(colors.primary_bg, colors.getBg(.primary)); + try std.testing.expectEqual(colors.success_bg, colors.getBg(.success)); + try std.testing.expectEqual(colors.primary_fg, colors.getFg(.primary)); + try std.testing.expectEqual(colors.success_fg, colors.getFg(.success)); +} diff --git a/src/widgets/textarea.zig b/src/widgets/textarea.zig new file mode 100644 index 0000000..8bb82cd --- /dev/null +++ b/src/widgets/textarea.zig @@ -0,0 +1,871 @@ +//! TextArea Widget - Multi-line text editor +//! +//! A multi-line text input with cursor navigation, selection, and scrolling. +//! Supports line wrapping and handles large documents efficiently. + +const std = @import("std"); +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"); + +/// Text area state (caller-managed) +pub const TextAreaState = struct { + /// Text buffer + buffer: []u8, + /// Current text length + len: usize = 0, + /// Cursor position (byte index) + cursor: usize = 0, + /// Selection start (byte index), null if no selection + selection_start: ?usize = null, + /// Scroll offset (line number) + scroll_y: usize = 0, + /// Horizontal scroll offset (chars) + scroll_x: usize = 0, + /// Whether this input has focus + focused: bool = false, + + /// Initialize with empty buffer + pub fn init(buffer: []u8) TextAreaState { + return .{ .buffer = buffer }; + } + + /// Get the current text + pub fn text(self: TextAreaState) []const u8 { + return self.buffer[0..self.len]; + } + + /// Set text programmatically + pub fn setText(self: *TextAreaState, new_text: []const u8) void { + const copy_len = @min(new_text.len, self.buffer.len); + @memcpy(self.buffer[0..copy_len], new_text[0..copy_len]); + self.len = copy_len; + self.cursor = copy_len; + self.selection_start = null; + self.scroll_y = 0; + self.scroll_x = 0; + } + + /// Clear the text + pub fn clear(self: *TextAreaState) void { + self.len = 0; + self.cursor = 0; + self.selection_start = null; + self.scroll_y = 0; + self.scroll_x = 0; + } + + /// Insert text at cursor + pub fn insert(self: *TextAreaState, new_text: []const u8) void { + // Delete selection first if any + self.deleteSelection(); + + const available = self.buffer.len - self.len; + const to_insert = @min(new_text.len, available); + + if (to_insert == 0) return; + + // Move text after cursor + const after_cursor = self.len - self.cursor; + if (after_cursor > 0) { + std.mem.copyBackwards( + u8, + self.buffer[self.cursor + to_insert .. self.len + to_insert], + self.buffer[self.cursor..self.len], + ); + } + + // Insert new text + @memcpy(self.buffer[self.cursor..][0..to_insert], new_text[0..to_insert]); + self.len += to_insert; + self.cursor += to_insert; + } + + /// Insert a newline + pub fn insertNewline(self: *TextAreaState) void { + self.insert("\n"); + } + + /// Delete character before cursor (backspace) + pub fn deleteBack(self: *TextAreaState) void { + if (self.selection_start != null) { + self.deleteSelection(); + return; + } + + if (self.cursor == 0) return; + + // Move text after cursor back + const after_cursor = self.len - self.cursor; + if (after_cursor > 0) { + std.mem.copyForwards( + u8, + self.buffer[self.cursor - 1 .. self.len - 1], + self.buffer[self.cursor..self.len], + ); + } + + self.cursor -= 1; + self.len -= 1; + } + + /// Delete character at cursor (delete key) + pub fn deleteForward(self: *TextAreaState) void { + if (self.selection_start != null) { + self.deleteSelection(); + return; + } + + if (self.cursor >= self.len) return; + + // Move text after cursor back + const after_cursor = self.len - self.cursor - 1; + if (after_cursor > 0) { + std.mem.copyForwards( + u8, + self.buffer[self.cursor .. self.len - 1], + self.buffer[self.cursor + 1 .. self.len], + ); + } + + self.len -= 1; + } + + /// Delete selected text + fn deleteSelection(self: *TextAreaState) void { + const start = self.selection_start orelse return; + const sel_start = @min(start, self.cursor); + const sel_end = @max(start, self.cursor); + const sel_len = sel_end - sel_start; + + if (sel_len == 0) { + self.selection_start = null; + return; + } + + // Move text after selection + const after_sel = self.len - sel_end; + if (after_sel > 0) { + std.mem.copyForwards( + u8, + self.buffer[sel_start .. sel_start + after_sel], + self.buffer[sel_end..self.len], + ); + } + + self.len -= sel_len; + self.cursor = sel_start; + self.selection_start = null; + } + + /// Get cursor line and column + pub fn getCursorPosition(self: TextAreaState) struct { line: usize, col: usize } { + var line: usize = 0; + var col: usize = 0; + var i: usize = 0; + + while (i < self.cursor and i < self.len) : (i += 1) { + if (self.buffer[i] == '\n') { + line += 1; + col = 0; + } else { + col += 1; + } + } + + return .{ .line = line, .col = col }; + } + + /// Get byte offset for line start + fn getLineStart(self: TextAreaState, line: usize) usize { + if (line == 0) return 0; + + var current_line: usize = 0; + var i: usize = 0; + + while (i < self.len) : (i += 1) { + if (self.buffer[i] == '\n') { + current_line += 1; + if (current_line == line) { + return i + 1; + } + } + } + + return self.len; + } + + /// Get byte offset for line end (before newline) + fn getLineEnd(self: TextAreaState, line: usize) usize { + const line_start = self.getLineStart(line); + var i = line_start; + + while (i < self.len) : (i += 1) { + if (self.buffer[i] == '\n') { + return i; + } + } + + return self.len; + } + + /// Count total lines + pub fn lineCount(self: TextAreaState) usize { + var count: usize = 1; + for (self.buffer[0..self.len]) |c| { + if (c == '\n') count += 1; + } + return count; + } + + /// Move cursor left + pub fn cursorLeft(self: *TextAreaState, shift: bool) void { + if (shift and self.selection_start == null) { + self.selection_start = self.cursor; + } else if (!shift) { + self.selection_start = null; + } + + if (self.cursor > 0) { + self.cursor -= 1; + } + } + + /// Move cursor right + pub fn cursorRight(self: *TextAreaState, shift: bool) void { + if (shift and self.selection_start == null) { + self.selection_start = self.cursor; + } else if (!shift) { + self.selection_start = null; + } + + if (self.cursor < self.len) { + self.cursor += 1; + } + } + + /// Move cursor up one line + pub fn cursorUp(self: *TextAreaState, shift: bool) void { + if (shift and self.selection_start == null) { + self.selection_start = self.cursor; + } else if (!shift) { + self.selection_start = null; + } + + const pos = self.getCursorPosition(); + if (pos.line == 0) { + // Already on first line, go to start + self.cursor = 0; + return; + } + + // Move to previous line, same column if possible + const prev_line_start = self.getLineStart(pos.line - 1); + const prev_line_end = self.getLineEnd(pos.line - 1); + const prev_line_len = prev_line_end - prev_line_start; + + self.cursor = prev_line_start + @min(pos.col, prev_line_len); + } + + /// Move cursor down one line + pub fn cursorDown(self: *TextAreaState, shift: bool) void { + if (shift and self.selection_start == null) { + self.selection_start = self.cursor; + } else if (!shift) { + self.selection_start = null; + } + + const pos = self.getCursorPosition(); + const total_lines = self.lineCount(); + + if (pos.line >= total_lines - 1) { + // Already on last line, go to end + self.cursor = self.len; + return; + } + + // Move to next line, same column if possible + const next_line_start = self.getLineStart(pos.line + 1); + const next_line_end = self.getLineEnd(pos.line + 1); + const next_line_len = next_line_end - next_line_start; + + self.cursor = next_line_start + @min(pos.col, next_line_len); + } + + /// Move cursor to start of line + pub fn cursorHome(self: *TextAreaState, shift: bool) void { + if (shift and self.selection_start == null) { + self.selection_start = self.cursor; + } else if (!shift) { + self.selection_start = null; + } + + const pos = self.getCursorPosition(); + self.cursor = self.getLineStart(pos.line); + } + + /// Move cursor to end of line + pub fn cursorEnd(self: *TextAreaState, shift: bool) void { + if (shift and self.selection_start == null) { + self.selection_start = self.cursor; + } else if (!shift) { + self.selection_start = null; + } + + const pos = self.getCursorPosition(); + self.cursor = self.getLineEnd(pos.line); + } + + /// Move cursor up one page + pub fn pageUp(self: *TextAreaState, visible_lines: usize, shift: bool) void { + if (shift and self.selection_start == null) { + self.selection_start = self.cursor; + } else if (!shift) { + self.selection_start = null; + } + + const pos = self.getCursorPosition(); + const lines_to_move = @min(pos.line, visible_lines); + + var i: usize = 0; + while (i < lines_to_move) : (i += 1) { + const save_sel = self.selection_start; + self.cursorUp(false); + self.selection_start = save_sel; + } + } + + /// Move cursor down one page + pub fn pageDown(self: *TextAreaState, visible_lines: usize, shift: bool) void { + if (shift and self.selection_start == null) { + self.selection_start = self.cursor; + } else if (!shift) { + self.selection_start = null; + } + + const pos = self.getCursorPosition(); + const total_lines = self.lineCount(); + const lines_to_move = @min(total_lines - 1 - pos.line, visible_lines); + + var i: usize = 0; + while (i < lines_to_move) : (i += 1) { + const save_sel = self.selection_start; + self.cursorDown(false); + self.selection_start = save_sel; + } + } + + /// Select all text + pub fn selectAll(self: *TextAreaState) void { + self.selection_start = 0; + self.cursor = self.len; + } + + /// Ensure cursor is visible by adjusting scroll + pub fn ensureCursorVisible(self: *TextAreaState, visible_lines: usize, visible_cols: usize) void { + const pos = self.getCursorPosition(); + + // Vertical scroll + if (pos.line < self.scroll_y) { + self.scroll_y = pos.line; + } else if (pos.line >= self.scroll_y + visible_lines) { + self.scroll_y = pos.line - visible_lines + 1; + } + + // Horizontal scroll + if (pos.col < self.scroll_x) { + self.scroll_x = pos.col; + } else if (pos.col >= self.scroll_x + visible_cols) { + self.scroll_x = pos.col - visible_cols + 1; + } + } +}; + +/// Text area configuration +pub const TextAreaConfig = struct { + /// Placeholder text when empty + placeholder: []const u8 = "", + /// Read-only mode + readonly: bool = false, + /// Show line numbers + line_numbers: bool = false, + /// Word wrap + word_wrap: bool = false, + /// Tab size in spaces + tab_size: u8 = 4, + /// Padding inside the text area + padding: u32 = 4, +}; + +/// Text area colors +pub const TextAreaColors = struct { + background: Style.Color = Style.Color.rgba(30, 30, 30, 255), + text: Style.Color = Style.Color.rgba(220, 220, 220, 255), + placeholder: Style.Color = Style.Color.rgba(128, 128, 128, 255), + cursor: Style.Color = Style.Color.rgba(255, 255, 255, 255), + selection: Style.Color = Style.Color.rgba(50, 100, 150, 180), + border: Style.Color = Style.Color.rgba(80, 80, 80, 255), + border_focused: Style.Color = Style.Color.rgba(100, 149, 237, 255), + line_numbers_bg: Style.Color = Style.Color.rgba(40, 40, 40, 255), + line_numbers_fg: Style.Color = Style.Color.rgba(128, 128, 128, 255), + + pub fn fromTheme(theme: Style.Theme) TextAreaColors { + return .{ + .background = theme.input_bg, + .text = theme.input_fg, + .placeholder = theme.secondary, + .cursor = theme.foreground, + .selection = theme.selection_bg, + .border = theme.input_border, + .border_focused = theme.primary, + .line_numbers_bg = theme.background.darken(10), + .line_numbers_fg = theme.secondary, + }; + } +}; + +/// Result of text area widget +pub const TextAreaResult = struct { + /// Text was changed this frame + changed: bool, + /// Widget was clicked (for focus management) + clicked: bool, + /// Current cursor position + cursor_line: usize, + cursor_col: usize, +}; + +/// Draw a text area and return interaction result +pub fn textArea(ctx: *Context, state: *TextAreaState) TextAreaResult { + return textAreaEx(ctx, state, .{}, .{}); +} + +/// Draw a text area with custom configuration +pub fn textAreaEx( + ctx: *Context, + state: *TextAreaState, + config: TextAreaConfig, + colors: TextAreaColors, +) TextAreaResult { + const bounds = ctx.layout.nextRect(); + return textAreaRect(ctx, bounds, state, config, colors); +} + +/// Draw a text area in a specific rectangle +pub fn textAreaRect( + ctx: *Context, + bounds: Layout.Rect, + state: *TextAreaState, + config: TextAreaConfig, + colors: TextAreaColors, +) TextAreaResult { + var result = TextAreaResult{ + .changed = false, + .clicked = false, + .cursor_line = 0, + .cursor_col = 0, + }; + + 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; + } + + // Get colors + const bg_color = if (state.focused) colors.background.lighten(5) else colors.background; + const border_color = if (state.focused) colors.border_focused else colors.border; + + // Draw background + ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, bg_color)); + + // Draw border + ctx.pushCommand(Command.rectOutline(bounds.x, bounds.y, bounds.w, bounds.h, border_color)); + + // Calculate dimensions + const char_width: u32 = 8; + const char_height: u32 = 8; + const line_height: u32 = char_height + 2; + + // Line numbers width + const line_num_width: u32 = if (config.line_numbers) + @as(u32, @intCast(countDigits(state.lineCount()))) * char_width + 8 + else + 0; + + // Inner area for text + var text_area = bounds.shrink(config.padding); + if (text_area.isEmpty()) return result; + + // Draw line numbers gutter + if (config.line_numbers and line_num_width > 0) { + ctx.pushCommand(Command.rect( + text_area.x, + text_area.y, + line_num_width, + text_area.h, + colors.line_numbers_bg, + )); + // Adjust text area to exclude gutter + text_area = Layout.Rect.init( + text_area.x + @as(i32, @intCast(line_num_width)), + text_area.y, + text_area.w -| line_num_width, + text_area.h, + ); + } + + if (text_area.isEmpty()) return result; + + // Calculate visible area + const visible_lines = text_area.h / line_height; + const visible_cols = text_area.w / char_width; + + // Handle keyboard input if focused + if (state.focused and !config.readonly) { + const text_in = ctx.input.getTextInput(); + if (text_in.len > 0) { + // Check for tab + for (text_in) |c| { + if (c == '\t') { + // Insert spaces for tab + var spaces: [8]u8 = undefined; + const count = @min(config.tab_size, 8); + @memset(spaces[0..count], ' '); + state.insert(spaces[0..count]); + } else { + state.insert(&[_]u8{c}); + } + } + result.changed = true; + } + } + + // Ensure cursor is visible + state.ensureCursorVisible(visible_lines, visible_cols); + + // Get cursor position + const cursor_pos = state.getCursorPosition(); + result.cursor_line = cursor_pos.line; + result.cursor_col = cursor_pos.col; + + // Draw text line by line + const txt = state.text(); + var line_num: usize = 0; + var line_start: usize = 0; + + for (txt, 0..) |c, i| { + if (c == '\n') { + if (line_num >= state.scroll_y and line_num < state.scroll_y + visible_lines) { + const draw_line = line_num - state.scroll_y; + const y = text_area.y + @as(i32, @intCast(draw_line * line_height)); + + // Draw line number + if (config.line_numbers) { + drawLineNumber( + ctx, + bounds.x + @as(i32, @intCast(config.padding)), + y, + line_num + 1, + colors.line_numbers_fg, + ); + } + + // Draw line text + const line_text = txt[line_start..i]; + drawLineText(ctx, text_area.x, y, line_text, state.scroll_x, visible_cols, colors.text); + + // Draw selection on this line + if (state.selection_start != null) { + drawLineSelection( + ctx, + text_area.x, + y, + line_start, + i, + state.cursor, + state.selection_start.?, + state.scroll_x, + visible_cols, + char_width, + line_height, + colors.selection, + ); + } + + // Draw cursor if on this line + if (state.focused and cursor_pos.line == line_num) { + const cursor_x_pos = cursor_pos.col -| state.scroll_x; + if (cursor_x_pos < visible_cols) { + const cursor_x = text_area.x + @as(i32, @intCast(cursor_x_pos * char_width)); + ctx.pushCommand(Command.rect(cursor_x, y, 2, line_height, colors.cursor)); + } + } + } + + line_num += 1; + line_start = i + 1; + } + } + + // Handle last line (no trailing newline) + if (line_start <= txt.len and line_num >= state.scroll_y and line_num < state.scroll_y + visible_lines) { + const draw_line = line_num - state.scroll_y; + const y = text_area.y + @as(i32, @intCast(draw_line * line_height)); + + // Draw line number + if (config.line_numbers) { + drawLineNumber( + ctx, + bounds.x + @as(i32, @intCast(config.padding)), + y, + line_num + 1, + colors.line_numbers_fg, + ); + } + + // Draw line text + const line_text = if (line_start < txt.len) txt[line_start..] else ""; + drawLineText(ctx, text_area.x, y, line_text, state.scroll_x, visible_cols, colors.text); + + // Draw selection on this line + if (state.selection_start != null) { + drawLineSelection( + ctx, + text_area.x, + y, + line_start, + txt.len, + state.cursor, + state.selection_start.?, + state.scroll_x, + visible_cols, + char_width, + line_height, + colors.selection, + ); + } + + // Draw cursor if on this line + if (state.focused and cursor_pos.line == line_num) { + const cursor_x_pos = cursor_pos.col -| state.scroll_x; + if (cursor_x_pos < visible_cols) { + const cursor_x = text_area.x + @as(i32, @intCast(cursor_x_pos * char_width)); + ctx.pushCommand(Command.rect(cursor_x, y, 2, line_height, colors.cursor)); + } + } + } + + // Draw placeholder if empty + if (state.len == 0 and config.placeholder.len > 0) { + const y = text_area.y; + ctx.pushCommand(Command.text(text_area.x, y, config.placeholder, colors.placeholder)); + } + + return result; +} + +/// Draw a line number +fn drawLineNumber(ctx: *Context, x: i32, y: i32, num: usize, color: Style.Color) void { + var buf: [16]u8 = undefined; + const written = std.fmt.bufPrint(&buf, "{d}", .{num}) catch return; + ctx.pushCommand(Command.text(x, y, written, color)); +} + +/// Draw line text with horizontal scroll +fn drawLineText( + ctx: *Context, + x: i32, + y: i32, + line: []const u8, + scroll_x: usize, + visible_cols: usize, + color: Style.Color, +) void { + if (line.len == 0) return; + + const start = @min(scroll_x, line.len); + const end = @min(scroll_x + visible_cols, line.len); + + if (start >= end) return; + + ctx.pushCommand(Command.text(x, y, line[start..end], color)); +} + +/// Draw selection highlight for a line +fn drawLineSelection( + ctx: *Context, + x: i32, + y: i32, + line_start: usize, + line_end: usize, + cursor: usize, + sel_start: usize, + scroll_x: usize, + visible_cols: usize, + char_width: u32, + line_height: u32, + color: Style.Color, +) void { + const sel_min = @min(cursor, sel_start); + const sel_max = @max(cursor, sel_start); + + // Check if selection overlaps this line + if (sel_max < line_start or sel_min > line_end) return; + + // Calculate selection bounds within line + const sel_line_start = if (sel_min > line_start) sel_min - line_start else 0; + const sel_line_end = @min(sel_max, line_end) - line_start; + + if (sel_line_start >= sel_line_end) return; + + // Apply horizontal scroll + const vis_start = if (sel_line_start > scroll_x) sel_line_start - scroll_x else 0; + const vis_end = if (sel_line_end > scroll_x) @min(sel_line_end - scroll_x, visible_cols) else 0; + + if (vis_start >= vis_end) return; + + const sel_x = x + @as(i32, @intCast(vis_start * char_width)); + const sel_w = @as(u32, @intCast(vis_end - vis_start)) * char_width; + + ctx.pushCommand(Command.rect(sel_x, y, sel_w, line_height, color)); +} + +/// Count digits in a number +fn countDigits(n: usize) usize { + if (n == 0) return 1; + var count: usize = 0; + var num = n; + while (num > 0) : (num /= 10) { + count += 1; + } + return count; +} + +// ============================================================================= +// Tests +// ============================================================================= + +test "TextAreaState insert" { + var buf: [256]u8 = undefined; + var state = TextAreaState.init(&buf); + + state.insert("Hello"); + try std.testing.expectEqualStrings("Hello", state.text()); + try std.testing.expectEqual(@as(usize, 5), state.cursor); + + state.insertNewline(); + state.insert("World"); + try std.testing.expectEqualStrings("Hello\nWorld", state.text()); +} + +test "TextAreaState line count" { + var buf: [256]u8 = undefined; + var state = TextAreaState.init(&buf); + + state.insert("Line 1"); + try std.testing.expectEqual(@as(usize, 1), state.lineCount()); + + state.insertNewline(); + state.insert("Line 2"); + try std.testing.expectEqual(@as(usize, 2), state.lineCount()); + + state.insertNewline(); + state.insertNewline(); + state.insert("Line 4"); + try std.testing.expectEqual(@as(usize, 4), state.lineCount()); +} + +test "TextAreaState cursor position" { + var buf: [256]u8 = undefined; + var state = TextAreaState.init(&buf); + + state.insert("Hello\nWorld\nTest"); + + // Cursor at end + const pos = state.getCursorPosition(); + try std.testing.expectEqual(@as(usize, 2), pos.line); + try std.testing.expectEqual(@as(usize, 4), pos.col); +} + +test "TextAreaState cursor up/down" { + var buf: [256]u8 = undefined; + var state = TextAreaState.init(&buf); + + state.insert("Line 1\nLine 2\nLine 3"); + + // Move up + state.cursorUp(false); + var pos = state.getCursorPosition(); + try std.testing.expectEqual(@as(usize, 1), pos.line); + + state.cursorUp(false); + pos = state.getCursorPosition(); + try std.testing.expectEqual(@as(usize, 0), pos.line); + + // Move down + state.cursorDown(false); + pos = state.getCursorPosition(); + try std.testing.expectEqual(@as(usize, 1), pos.line); +} + +test "TextAreaState home/end" { + var buf: [256]u8 = undefined; + var state = TextAreaState.init(&buf); + + state.insert("Hello World"); + state.cursorHome(false); + + try std.testing.expectEqual(@as(usize, 0), state.cursor); + + state.cursorEnd(false); + try std.testing.expectEqual(@as(usize, 11), state.cursor); +} + +test "TextAreaState selection" { + var buf: [256]u8 = undefined; + var state = TextAreaState.init(&buf); + + state.insert("Hello World"); + state.selectAll(); + + try std.testing.expectEqual(@as(?usize, 0), state.selection_start); + try std.testing.expectEqual(@as(usize, 11), state.cursor); + + state.insert("X"); + try std.testing.expectEqualStrings("X", state.text()); +} + +test "textArea generates commands" { + var ctx = try Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + var buf: [256]u8 = undefined; + var state = TextAreaState.init(&buf); + + ctx.beginFrame(); + ctx.layout.row_height = 100; + + _ = textArea(&ctx, &state); + + // Should generate: rect (bg) + rect_outline (border) + try std.testing.expect(ctx.commands.items.len >= 2); + + ctx.endFrame(); +} + +test "countDigits" { + try std.testing.expectEqual(@as(usize, 1), countDigits(0)); + try std.testing.expectEqual(@as(usize, 1), countDigits(5)); + try std.testing.expectEqual(@as(usize, 2), countDigits(10)); + try std.testing.expectEqual(@as(usize, 3), countDigits(100)); + try std.testing.expectEqual(@as(usize, 4), countDigits(1234)); +} diff --git a/src/widgets/tree.zig b/src/widgets/tree.zig new file mode 100644 index 0000000..067b029 --- /dev/null +++ b/src/widgets/tree.zig @@ -0,0 +1,467 @@ +//! 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)); +} diff --git a/src/widgets/widgets.zig b/src/widgets/widgets.zig index 68f3a0e..569818e 100644 --- a/src/widgets/widgets.zig +++ b/src/widgets/widgets.zig @@ -28,6 +28,9 @@ pub const radio = @import("radio.zig"); pub const progress = @import("progress.zig"); pub const tooltip = @import("tooltip.zig"); pub const toast = @import("toast.zig"); +pub const textarea = @import("textarea.zig"); +pub const tree = @import("tree.zig"); +pub const badge = @import("badge.zig"); // ============================================================================= // Re-exports for convenience @@ -193,6 +196,33 @@ pub const ToastColors = toast.Colors; pub const ToastPosition = toast.Position; pub const ToastResult = toast.ToastResult; +// TextArea +pub const TextArea = textarea; +pub const TextAreaState = textarea.TextAreaState; +pub const TextAreaConfig = textarea.TextAreaConfig; +pub const TextAreaColors = textarea.TextAreaColors; +pub const TextAreaResult = textarea.TextAreaResult; + +// Tree +pub const Tree = tree; +pub const TreeNode = tree.TreeNode; +pub const TreeState = tree.TreeState; +pub const TreeConfig = tree.TreeConfig; +pub const TreeColors = tree.TreeColors; +pub const TreeResult = tree.TreeResult; +pub const NodeId = tree.NodeId; + +// Badge +pub const Badge = badge; +pub const BadgeVariant = badge.Variant; +pub const BadgeSize = badge.Size; +pub const BadgeConfig = badge.Config; +pub const BadgeColors = badge.Colors; +pub const BadgeResult = badge.Result; +pub const Tag = badge.Tag; +pub const TagGroupConfig = badge.TagGroupConfig; +pub const TagGroupResult = badge.TagGroupResult; + // ============================================================================= // Tests // =============================================================================