From 1ee10b8e172c697e8ea23d24945cb0f0bf0b7a11 Mon Sep 17 00:00:00 2001 From: reugenio Date: Fri, 12 Dec 2025 22:55:41 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20A=C3=B1adir=20archivos=20nuevos=20de=20?= =?UTF-8?q?widgets=20y=20focus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Archivos que estaban localmente pero no en el repo: - src/core/focus_group.zig (416 líneas) - src/widgets/focus.zig (272 líneas) - src/widgets/progress.zig (806 líneas) - src/widgets/table.zig (1592 líneas) - src/widgets/textarea.zig (871 líneas) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/core/focus_group.zig | 416 ++++++++++ src/widgets/focus.zig | 272 +++++++ src/widgets/progress.zig | 806 +++++++++++++++++++ src/widgets/table.zig | 1592 ++++++++++++++++++++++++++++++++++++++ src/widgets/textarea.zig | 871 +++++++++++++++++++++ 5 files changed, 3957 insertions(+) create mode 100644 src/core/focus_group.zig create mode 100644 src/widgets/focus.zig create mode 100644 src/widgets/progress.zig create mode 100644 src/widgets/table.zig create mode 100644 src/widgets/textarea.zig diff --git a/src/core/focus_group.zig b/src/core/focus_group.zig new file mode 100644 index 0000000..87115fe --- /dev/null +++ b/src/core/focus_group.zig @@ -0,0 +1,416 @@ +//! Focus Groups +//! +//! Manages focus navigation within groups of widgets. +//! Supports tab order, wrapping, and nested groups. + +const std = @import("std"); +const Input = @import("input.zig"); + +/// Maximum widgets per group +const MAX_GROUP_SIZE = 64; + +/// Maximum number of groups +const MAX_GROUPS = 32; + +/// Focus direction +pub const Direction = enum { + next, + previous, + up, + down, + left, + right, +}; + +/// A focus group containing related widgets +pub const FocusGroup = struct { + /// Group identifier + id: u64, + /// Widget IDs in this group (in tab order) + widgets: [MAX_GROUP_SIZE]u64 = undefined, + /// Number of widgets + count: usize = 0, + /// Currently focused widget index + focused_index: ?usize = null, + /// Wrap focus at boundaries + wrap: bool = true, + /// Is this group active + active: bool = true, + /// Parent group (for nested focus) + parent_group: ?u64 = null, + + const Self = @This(); + + /// Create a new focus group + pub fn init(id: u64) Self { + return .{ .id = id }; + } + + /// Add a widget to the group + pub fn add(self: *Self, widget_id: u64) void { + if (self.count >= MAX_GROUP_SIZE) return; + self.widgets[self.count] = widget_id; + self.count += 1; + } + + /// Remove a widget from the group + pub fn remove(self: *Self, widget_id: u64) void { + var i: usize = 0; + while (i < self.count) { + if (self.widgets[i] == widget_id) { + // Shift remaining widgets + var j = i; + while (j < self.count - 1) : (j += 1) { + self.widgets[j] = self.widgets[j + 1]; + } + self.count -= 1; + + // Adjust focused index + if (self.focused_index) |idx| { + if (idx == i) { + self.focused_index = if (self.count > 0) @min(idx, self.count - 1) else null; + } else if (idx > i) { + self.focused_index = idx - 1; + } + } + return; + } + i += 1; + } + } + + /// Get the currently focused widget ID + pub fn getFocused(self: Self) ?u64 { + if (self.focused_index) |idx| { + if (idx < self.count) { + return self.widgets[idx]; + } + } + return null; + } + + /// Set focus to a specific widget + pub fn setFocus(self: *Self, widget_id: u64) bool { + for (self.widgets[0..self.count], 0..) |id, i| { + if (id == widget_id) { + self.focused_index = i; + return true; + } + } + return false; + } + + /// Focus the next widget + pub fn focusNext(self: *Self) ?u64 { + if (self.count == 0) return null; + + if (self.focused_index) |idx| { + if (idx + 1 < self.count) { + self.focused_index = idx + 1; + } else if (self.wrap) { + self.focused_index = 0; + } + } else { + self.focused_index = 0; + } + + return self.getFocused(); + } + + /// Focus the previous widget + pub fn focusPrevious(self: *Self) ?u64 { + if (self.count == 0) return null; + + if (self.focused_index) |idx| { + if (idx > 0) { + self.focused_index = idx - 1; + } else if (self.wrap) { + self.focused_index = self.count - 1; + } + } else { + self.focused_index = self.count - 1; + } + + return self.getFocused(); + } + + /// Focus first widget + pub fn focusFirst(self: *Self) ?u64 { + if (self.count == 0) return null; + self.focused_index = 0; + return self.getFocused(); + } + + /// Focus last widget + pub fn focusLast(self: *Self) ?u64 { + if (self.count == 0) return null; + self.focused_index = self.count - 1; + return self.getFocused(); + } + + /// Clear focus + pub fn clearFocus(self: *Self) void { + self.focused_index = null; + } + + /// Check if a widget has focus + pub fn hasFocus(self: Self, widget_id: u64) bool { + if (self.getFocused()) |focused| { + return focused == widget_id; + } + return false; + } + + /// Get index of a widget + pub fn indexOf(self: Self, widget_id: u64) ?usize { + for (self.widgets[0..self.count], 0..) |id, i| { + if (id == widget_id) { + return i; + } + } + return null; + } +}; + +/// Focus Group Manager - manages multiple focus groups +pub const FocusGroupManager = struct { + groups: [MAX_GROUPS]FocusGroup = undefined, + group_count: usize = 0, + active_group: ?u64 = null, + + const Self = @This(); + + /// Initialize the manager + pub fn init() Self { + return .{}; + } + + /// Create a new group + pub fn createGroup(self: *Self, id: u64) *FocusGroup { + if (self.group_count >= MAX_GROUPS) { + // Return first group as fallback + return &self.groups[0]; + } + + self.groups[self.group_count] = FocusGroup.init(id); + const group = &self.groups[self.group_count]; + self.group_count += 1; + + // Set as active if first group + if (self.active_group == null) { + self.active_group = id; + } + + return group; + } + + /// Get a group by ID + pub fn getGroup(self: *Self, id: u64) ?*FocusGroup { + for (self.groups[0..self.group_count]) |*group| { + if (group.id == id) { + return group; + } + } + return null; + } + + /// Remove a group + pub fn removeGroup(self: *Self, id: u64) void { + var i: usize = 0; + while (i < self.group_count) { + if (self.groups[i].id == id) { + var j = i; + while (j < self.group_count - 1) : (j += 1) { + self.groups[j] = self.groups[j + 1]; + } + self.group_count -= 1; + + if (self.active_group == id) { + self.active_group = if (self.group_count > 0) self.groups[0].id else null; + } + return; + } + i += 1; + } + } + + /// Set the active group + pub fn setActiveGroup(self: *Self, id: u64) void { + if (self.getGroup(id) != null) { + self.active_group = id; + } + } + + /// Get the active group + pub fn getActiveGroup(self: *Self) ?*FocusGroup { + if (self.active_group) |id| { + return self.getGroup(id); + } + return null; + } + + /// Handle focus navigation input + pub fn handleInput(self: *Self, input: *const Input.InputState) ?u64 { + const group = self.getActiveGroup() orelse return null; + + if (input.keyPressed(.tab)) { + if (input.keyDown(.left_shift) or input.keyDown(.right_shift)) { + return group.focusPrevious(); + } else { + return group.focusNext(); + } + } + + return null; + } + + /// Get currently focused widget across all groups + pub fn getGlobalFocus(self: Self) ?u64 { + if (self.active_group) |active_id| { + for (self.groups[0..self.group_count]) |group| { + if (group.id == active_id) { + return group.getFocused(); + } + } + } + return null; + } + + /// Focus next group + pub fn focusNextGroup(self: *Self) void { + if (self.group_count <= 1) return; + + if (self.active_group) |active_id| { + for (self.groups[0..self.group_count], 0..) |group, i| { + if (group.id == active_id) { + const next_idx = (i + 1) % self.group_count; + self.active_group = self.groups[next_idx].id; + return; + } + } + } + } + + /// Focus previous group + pub fn focusPreviousGroup(self: *Self) void { + if (self.group_count <= 1) return; + + if (self.active_group) |active_id| { + for (self.groups[0..self.group_count], 0..) |group, i| { + if (group.id == active_id) { + const prev_idx = if (i == 0) self.group_count - 1 else i - 1; + self.active_group = self.groups[prev_idx].id; + return; + } + } + } + } +}; + +// ============================================================================= +// Tests +// ============================================================================= + +test "FocusGroup init" { + const group = FocusGroup.init(1); + try std.testing.expectEqual(@as(u64, 1), group.id); + try std.testing.expectEqual(@as(usize, 0), group.count); +} + +test "FocusGroup add and remove" { + var group = FocusGroup.init(1); + + group.add(100); + group.add(200); + group.add(300); + + try std.testing.expectEqual(@as(usize, 3), group.count); + + group.remove(200); + try std.testing.expectEqual(@as(usize, 2), group.count); + try std.testing.expectEqual(@as(u64, 100), group.widgets[0]); + try std.testing.expectEqual(@as(u64, 300), group.widgets[1]); +} + +test "FocusGroup navigation" { + var group = FocusGroup.init(1); + group.add(100); + group.add(200); + group.add(300); + + // Focus first + try std.testing.expectEqual(@as(?u64, 100), group.focusFirst()); + try std.testing.expectEqual(@as(?usize, 0), group.focused_index); + + // Focus next + try std.testing.expectEqual(@as(?u64, 200), group.focusNext()); + try std.testing.expectEqual(@as(?u64, 300), group.focusNext()); + + // Wrap around + try std.testing.expectEqual(@as(?u64, 100), group.focusNext()); + + // Focus previous + try std.testing.expectEqual(@as(?u64, 300), group.focusPrevious()); +} + +test "FocusGroup setFocus" { + var group = FocusGroup.init(1); + group.add(100); + group.add(200); + group.add(300); + + try std.testing.expect(group.setFocus(200)); + try std.testing.expectEqual(@as(?u64, 200), group.getFocused()); + + try std.testing.expect(!group.setFocus(999)); // Non-existent +} + +test "FocusGroup hasFocus" { + var group = FocusGroup.init(1); + group.add(100); + group.add(200); + _ = group.focusFirst(); + + try std.testing.expect(group.hasFocus(100)); + try std.testing.expect(!group.hasFocus(200)); +} + +test "FocusGroupManager create groups" { + var manager = FocusGroupManager.init(); + + const group1 = manager.createGroup(1); + const group2 = manager.createGroup(2); + + try std.testing.expectEqual(@as(usize, 2), manager.group_count); + try std.testing.expectEqual(@as(u64, 1), group1.id); + try std.testing.expectEqual(@as(u64, 2), group2.id); + + // First group should be active + try std.testing.expectEqual(@as(?u64, 1), manager.active_group); +} + +test "FocusGroupManager get and remove" { + var manager = FocusGroupManager.init(); + _ = manager.createGroup(1); + _ = manager.createGroup(2); + + const group = manager.getGroup(2); + try std.testing.expect(group != null); + try std.testing.expectEqual(@as(u64, 2), group.?.id); + + manager.removeGroup(1); + try std.testing.expectEqual(@as(usize, 1), manager.group_count); + try std.testing.expect(manager.getGroup(1) == null); +} + +test "FocusGroupManager active group" { + var manager = FocusGroupManager.init(); + _ = manager.createGroup(1); + _ = manager.createGroup(2); + + manager.setActiveGroup(2); + try std.testing.expectEqual(@as(?u64, 2), manager.active_group); + + manager.focusNextGroup(); + try std.testing.expectEqual(@as(?u64, 1), manager.active_group); +} diff --git a/src/widgets/focus.zig b/src/widgets/focus.zig new file mode 100644 index 0000000..127ae62 --- /dev/null +++ b/src/widgets/focus.zig @@ -0,0 +1,272 @@ +//! Focus Management - Track and navigate widget focus +//! +//! Manages which widget has keyboard focus and provides +//! Tab/Shift+Tab navigation between focusable widgets. + +const std = @import("std"); +const Input = @import("../core/input.zig"); + +/// Maximum number of focusable widgets per frame +pub const MAX_FOCUSABLES = 64; + +/// Focus manager state +pub const FocusManager = struct { + /// Currently focused widget ID + focused_id: ?u32 = null, + + /// List of focusable widget IDs this frame (in order) + focusables: [MAX_FOCUSABLES]u32 = undefined, + focusable_count: usize = 0, + + /// Widget ID to focus next frame (from keyboard nav) + pending_focus: ?u32 = null, + + /// Whether Tab was pressed this frame + tab_pressed: bool = false, + shift_tab_pressed: bool = false, + + const Self = @This(); + + /// Reset for new frame + pub fn beginFrame(self: *Self) void { + self.focusable_count = 0; + self.tab_pressed = false; + self.shift_tab_pressed = false; + + // Apply pending focus + if (self.pending_focus) |id| { + self.focused_id = id; + self.pending_focus = null; + } + } + + /// Process keyboard input for focus navigation + pub fn processInput(self: *Self, input: *const Input.InputState, key_events: []const Input.KeyEvent) void { + _ = input; + for (key_events) |event| { + if (event.key == .tab and event.pressed) { + if (event.modifiers.shift) { + self.shift_tab_pressed = true; + } else { + self.tab_pressed = true; + } + } + } + } + + /// Register a widget as focusable + pub fn registerFocusable(self: *Self, id: u32) void { + if (self.focusable_count >= MAX_FOCUSABLES) return; + self.focusables[self.focusable_count] = id; + self.focusable_count += 1; + } + + /// Check if a widget has focus + pub fn hasFocus(self: Self, id: u32) bool { + return self.focused_id == id; + } + + /// Request focus for a widget + pub fn requestFocus(self: *Self, id: u32) void { + self.focused_id = id; + } + + /// Clear focus + pub fn clearFocus(self: *Self) void { + self.focused_id = null; + } + + /// End of frame: process Tab navigation + pub fn endFrame(self: *Self) void { + if (self.focusable_count == 0) return; + + if (self.tab_pressed) { + self.focusNext(); + } else if (self.shift_tab_pressed) { + self.focusPrev(); + } + } + + /// Focus next widget in order + fn focusNext(self: *Self) void { + if (self.focusable_count == 0) return; + + if (self.focused_id) |current| { + // Find current index + for (self.focusables[0..self.focusable_count], 0..) |id, i| { + if (id == current) { + // Focus next (wrap around) + const next_idx = (i + 1) % self.focusable_count; + self.pending_focus = self.focusables[next_idx]; + return; + } + } + } + + // No current focus, focus first + self.pending_focus = self.focusables[0]; + } + + /// Focus previous widget in order + fn focusPrev(self: *Self) void { + if (self.focusable_count == 0) return; + + if (self.focused_id) |current| { + // Find current index + for (self.focusables[0..self.focusable_count], 0..) |id, i| { + if (id == current) { + // Focus previous (wrap around) + const prev_idx = if (i == 0) self.focusable_count - 1 else i - 1; + self.pending_focus = self.focusables[prev_idx]; + return; + } + } + } + + // No current focus, focus last + self.pending_focus = self.focusables[self.focusable_count - 1]; + } + + /// Focus specific index + pub fn focusIndex(self: *Self, idx: usize) void { + if (idx < self.focusable_count) { + self.pending_focus = self.focusables[idx]; + } + } + + /// Get the index of the focused widget + pub fn focusedIndex(self: Self) ?usize { + if (self.focused_id) |current| { + for (self.focusables[0..self.focusable_count], 0..) |id, i| { + if (id == current) { + return i; + } + } + } + return null; + } +}; + +/// Focus ring - circular focus navigation helper +pub const FocusRing = struct { + ids: [MAX_FOCUSABLES]u32 = undefined, + count: usize = 0, + current: usize = 0, + + const Self = @This(); + + /// Add a widget ID to the ring + pub fn add(self: *Self, id: u32) void { + if (self.count >= MAX_FOCUSABLES) return; + self.ids[self.count] = id; + self.count += 1; + } + + /// Get current focused ID + pub fn currentId(self: Self) ?u32 { + if (self.count == 0) return null; + return self.ids[self.current]; + } + + /// Move to next + pub fn next(self: *Self) void { + if (self.count == 0) return; + self.current = (self.current + 1) % self.count; + } + + /// Move to previous + pub fn prev(self: *Self) void { + if (self.count == 0) return; + self.current = if (self.current == 0) self.count - 1 else self.current - 1; + } + + /// Check if widget has focus + pub fn isFocused(self: Self, id: u32) bool { + if (self.count == 0) return false; + return self.ids[self.current] == id; + } + + /// Focus specific widget by ID + pub fn focusId(self: *Self, id: u32) bool { + for (self.ids[0..self.count], 0..) |widget_id, i| { + if (widget_id == id) { + self.current = i; + return true; + } + } + return false; + } + + /// Reset the ring + pub fn reset(self: *Self) void { + self.count = 0; + self.current = 0; + } +}; + +// ============================================================================= +// Tests +// ============================================================================= + +test "FocusManager navigation" { + var fm = FocusManager{}; + + fm.beginFrame(); + fm.registerFocusable(100); + fm.registerFocusable(200); + fm.registerFocusable(300); + + // No focus initially + try std.testing.expectEqual(@as(?u32, null), fm.focused_id); + + // Tab to first + fm.tab_pressed = true; + fm.endFrame(); + fm.beginFrame(); + + try std.testing.expectEqual(@as(?u32, 100), fm.focused_id); + + // Register again for new frame + fm.registerFocusable(100); + fm.registerFocusable(200); + fm.registerFocusable(300); + + // Tab to second + fm.tab_pressed = true; + fm.endFrame(); + fm.beginFrame(); + + try std.testing.expectEqual(@as(?u32, 200), fm.focused_id); +} + +test "FocusRing" { + var ring = FocusRing{}; + + ring.add(10); + ring.add(20); + ring.add(30); + + try std.testing.expectEqual(@as(?u32, 10), ring.currentId()); + try std.testing.expect(ring.isFocused(10)); + + ring.next(); + try std.testing.expectEqual(@as(?u32, 20), ring.currentId()); + + ring.prev(); + try std.testing.expectEqual(@as(?u32, 10), ring.currentId()); + + ring.prev(); // Wrap to end + try std.testing.expectEqual(@as(?u32, 30), ring.currentId()); +} + +test "FocusRing focusId" { + var ring = FocusRing{}; + + ring.add(100); + ring.add(200); + ring.add(300); + + const found = ring.focusId(200); + try std.testing.expect(found); + try std.testing.expectEqual(@as(?u32, 200), ring.currentId()); +} diff --git a/src/widgets/progress.zig b/src/widgets/progress.zig new file mode 100644 index 0000000..4e3691a --- /dev/null +++ b/src/widgets/progress.zig @@ -0,0 +1,806 @@ +//! Progress Widget +//! +//! Visual feedback widgets for progress and loading states. +//! +//! ## Widgets +//! - **ProgressBar**: Horizontal/vertical progress bar +//! - **ProgressCircle**: Circular progress indicator +//! - **Spinner**: Animated loading indicator +//! +//! ## Usage +//! ```zig +//! // Simple progress bar +//! progress.bar(ctx, 0.75); +//! +//! // Progress with config +//! progress.barEx(ctx, 0.5, .{ +//! .show_percentage = true, +//! .style = .striped, +//! }); +//! +//! // Indeterminate spinner +//! progress.spinner(ctx, .{ .style = .circular }); +//! ``` + +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 Rect = Layout.Rect; +const Color = Style.Color; + +// ============================================================================= +// Progress Bar +// ============================================================================= + +/// Progress bar style +pub const BarStyle = enum { + /// Solid fill + solid, + /// Striped pattern (animated) + striped, + /// Gradient fill + gradient, + /// Segmented blocks + segmented, +}; + +/// Progress bar configuration +pub const BarConfig = struct { + /// Visual style + style: BarStyle = .solid, + /// Show percentage text + show_percentage: bool = true, + /// Custom label (overrides percentage) + label: ?[]const u8 = null, + /// Orientation + vertical: bool = false, + /// Height (for horizontal) or Width (for vertical) + thickness: u16 = 20, + /// Corner radius + corner_radius: u8 = 4, + /// Animation enabled (for striped style) + animated: bool = true, + /// Track color (background) + track_color: ?Color = null, + /// Fill color + fill_color: ?Color = null, + /// Text color + text_color: ?Color = null, + /// Number of segments (for segmented style) + segments: u8 = 10, +}; + +/// Progress bar result +pub const BarResult = struct { + /// The bounds used + bounds: Rect, + /// Current progress value (clamped 0-1) + progress: f32, +}; + +/// Simple progress bar with default styling +pub fn bar(ctx: *Context, value: f32) BarResult { + return barEx(ctx, value, .{}); +} + +/// Progress bar with configuration +pub fn barEx(ctx: *Context, value: f32, config: BarConfig) BarResult { + // Get theme colors + const theme = Style.currentTheme(); + const track_color = config.track_color orelse theme.border; + const fill_color = config.fill_color orelse theme.primary; + const text_color = config.text_color orelse theme.text_primary; + + // Clamp progress value + const progress = std.math.clamp(value, 0.0, 1.0); + + // Calculate bounds based on layout + const layout_rect = ctx.layout.area; + const bounds = if (config.vertical) + Rect.init(layout_rect.x, layout_rect.y, config.thickness, layout_rect.h) + else + Rect.init(layout_rect.x, layout_rect.y, layout_rect.w, config.thickness); + + // Draw track (background) + ctx.pushCommand(.{ + .rect = .{ + .x = bounds.x, + .y = bounds.y, + .w = bounds.w, + .h = bounds.h, + .color = track_color, + }, + }); + + // Calculate fill dimensions + const fill_bounds = if (config.vertical) blk: { + const fill_height: u32 = @intFromFloat(@as(f32, @floatFromInt(bounds.h)) * progress); + const fill_y = bounds.y + @as(i32, @intCast(bounds.h - fill_height)); + break :blk Rect.init(bounds.x, fill_y, bounds.w, fill_height); + } else blk: { + const fill_width: u32 = @intFromFloat(@as(f32, @floatFromInt(bounds.w)) * progress); + break :blk Rect.init(bounds.x, bounds.y, fill_width, bounds.h); + }; + + // Draw fill based on style + switch (config.style) { + .solid => { + if (fill_bounds.w > 0 and fill_bounds.h > 0) { + ctx.pushCommand(.{ + .rect = .{ + .x = fill_bounds.x, + .y = fill_bounds.y, + .w = fill_bounds.w, + .h = fill_bounds.h, + .color = fill_color, + }, + }); + } + }, + .striped => { + drawStripedFill(ctx, fill_bounds, fill_color, config.animated); + }, + .gradient => { + drawGradientFill(ctx, fill_bounds, fill_color, config.vertical); + }, + .segmented => { + drawSegmentedFill(ctx, bounds, fill_color, progress, config.segments, config.vertical); + }, + } + + // Draw label or percentage + if (config.show_percentage or config.label != null) { + var label_buf: [32]u8 = undefined; + const label_text = if (config.label) |lbl| + lbl + else blk: { + const percent: u8 = @intFromFloat(progress * 100); + const written = std.fmt.bufPrint(&label_buf, "{d}%", .{percent}) catch ""; + break :blk written; + }; + + // Center text in bar + const text_width: u32 = @intCast(label_text.len * 8); // Assuming 8px font + const text_x = bounds.x + @as(i32, @intCast((bounds.w -| text_width) / 2)); + const text_y = bounds.y + @as(i32, @intCast((bounds.h -| 8) / 2)); + + ctx.pushCommand(.{ + .text = .{ + .x = text_x, + .y = text_y, + .text = label_text, + .color = text_color, + }, + }); + } + + ctx.countWidget(); + + return .{ + .bounds = bounds, + .progress = progress, + }; +} + +/// Draw at specific bounds +pub fn barRect(ctx: *Context, bounds: Rect, value: f32, config: BarConfig) BarResult { + // Override layout temporarily + const saved_layout = ctx.layout; + ctx.layout = Layout.LayoutState.init(bounds.w, bounds.h); + ctx.layout.container = bounds; + + const result = barEx(ctx, value, config); + + ctx.layout = saved_layout; + return result; +} + +// ============================================================================= +// Progress Circle +// ============================================================================= + +/// Circle progress configuration +pub const CircleConfig = struct { + /// Diameter in pixels + diameter: u16 = 48, + /// Stroke width + stroke_width: u8 = 4, + /// Show percentage in center + show_percentage: bool = true, + /// Custom label + label: ?[]const u8 = null, + /// Start angle (0 = top, clockwise) + start_angle: f32 = 0, + /// Track color + track_color: ?Color = null, + /// Fill color + fill_color: ?Color = null, + /// Text color + text_color: ?Color = null, +}; + +/// Circle progress result +pub const CircleResult = struct { + bounds: Rect, + progress: f32, +}; + +/// Simple circle progress +pub fn circle(ctx: *Context, value: f32) CircleResult { + return circleEx(ctx, value, .{}); +} + +/// Circle progress with configuration +pub fn circleEx(ctx: *Context, value: f32, config: CircleConfig) CircleResult { + const theme = Style.currentTheme(); + const track_color = config.track_color orelse theme.border; + const fill_color = config.fill_color orelse theme.primary; + const text_color = config.text_color orelse theme.text_primary; + + const progress = std.math.clamp(value, 0.0, 1.0); + + // Get bounds from layout + const layout_rect = ctx.layout.area; + const bounds = Rect.init( + layout_rect.x, + layout_rect.y, + config.diameter, + config.diameter, + ); + + const center_x = bounds.x + @as(i32, @intCast(bounds.w / 2)); + const center_y = bounds.y + @as(i32, @intCast(bounds.h / 2)); + const radius: i32 = @intCast(config.diameter / 2 - config.stroke_width); + + // Draw track circle (as approximation with arcs or just outline) + drawCircleOutline(ctx, center_x, center_y, radius, config.stroke_width, track_color); + + // Draw progress arc + if (progress > 0) { + drawProgressArc(ctx, center_x, center_y, radius, config.stroke_width, fill_color, progress, config.start_angle); + } + + // Draw label + if (config.show_percentage or config.label != null) { + var label_buf: [32]u8 = undefined; + const label_text = if (config.label) |lbl| + lbl + else blk: { + const percent: u8 = @intFromFloat(progress * 100); + const written = std.fmt.bufPrint(&label_buf, "{d}%", .{percent}) catch ""; + break :blk written; + }; + + const text_width: u32 = @intCast(label_text.len * 8); + const text_x = center_x - @as(i32, @intCast(text_width / 2)); + const text_y = center_y - 4; + + ctx.pushCommand(.{ + .text = .{ + .x = text_x, + .y = text_y, + .text = label_text, + .color = text_color, + }, + }); + } + + ctx.countWidget(); + + return .{ + .bounds = bounds, + .progress = progress, + }; +} + +// ============================================================================= +// Spinner +// ============================================================================= + +/// Spinner style +pub const SpinnerStyle = enum { + /// Rotating arc + circular, + /// Pulsing dots + dots, + /// Bouncing bars (equalizer) + bars, + /// Ring with gap + ring, +}; + +/// Spinner configuration +pub const SpinnerConfig = struct { + /// Visual style + style: SpinnerStyle = .circular, + /// Size in pixels + size: u16 = 24, + /// Animation speed multiplier + speed: f32 = 1.0, + /// Optional label below spinner + label: ?[]const u8 = null, + /// Primary color + color: ?Color = null, + /// Number of elements (dots or bars) + elements: u8 = 8, +}; + +/// Spinner state (for animation) +pub const SpinnerState = struct { + /// Animation progress (0-1, loops) + animation: f32 = 0, + /// Last update timestamp + last_update: i64 = 0, + + pub fn update(self: *SpinnerState, speed: f32) void { + const now = std.time.milliTimestamp(); + if (self.last_update == 0) { + self.last_update = now; + return; + } + + const delta_ms = now - self.last_update; + self.last_update = now; + + // Advance animation + const delta_f: f32 = @floatFromInt(delta_ms); + self.animation += (delta_f / 1000.0) * speed; + if (self.animation >= 1.0) { + self.animation -= 1.0; + } + } +}; + +/// Spinner result +pub const SpinnerResult = struct { + bounds: Rect, +}; + +/// Simple spinner +pub fn spinner(ctx: *Context, state: *SpinnerState) SpinnerResult { + return spinnerEx(ctx, state, .{}); +} + +/// Spinner with configuration +pub fn spinnerEx(ctx: *Context, state: *SpinnerState, config: SpinnerConfig) SpinnerResult { + const theme = Style.currentTheme(); + const color = config.color orelse theme.primary; + + // Update animation + state.update(config.speed); + + // Get bounds + const layout_rect = ctx.layout.area; + const bounds = Rect.init( + layout_rect.x, + layout_rect.y, + config.size, + config.size, + ); + + const center_x = bounds.x + @as(i32, @intCast(bounds.w / 2)); + const center_y = bounds.y + @as(i32, @intCast(bounds.h / 2)); + + switch (config.style) { + .circular => { + drawRotatingArc(ctx, center_x, center_y, config.size / 2 - 2, color, state.animation); + }, + .dots => { + drawPulsingDots(ctx, center_x, center_y, config.size, color, config.elements, state.animation); + }, + .bars => { + drawBouncingBars(ctx, bounds, color, config.elements, state.animation); + }, + .ring => { + drawRingWithGap(ctx, center_x, center_y, config.size / 2 - 2, color, state.animation); + }, + } + + // Draw label if present + if (config.label) |label| { + const text_x = bounds.x; + const text_y = bounds.y + @as(i32, @intCast(bounds.h)) + 4; + + ctx.pushCommand(.{ + .text = .{ + .x = text_x, + .y = text_y, + .text = label, + .color = theme.text_secondary, + }, + }); + } + + ctx.countWidget(); + + return .{ + .bounds = bounds, + }; +} + +// ============================================================================= +// Helper Drawing Functions +// ============================================================================= + +fn drawStripedFill(ctx: *Context, bounds: Rect, fill_color: Color, animated: bool) void { + _ = animated; // TODO: Use frame time for animation offset + + if (bounds.w == 0 or bounds.h == 0) return; + + // Draw base fill + ctx.pushCommand(.{ + .rect = .{ + .x = bounds.x, + .y = bounds.y, + .w = bounds.w, + .h = bounds.h, + .color = fill_color, + }, + }); + + // Draw stripes (darker lines) + const stripe_color = Color.rgba( + fill_color.r -| 30, + fill_color.g -| 30, + fill_color.b -| 30, + fill_color.a, + ); + + const stripe_width: i32 = 6; + const stripe_gap: i32 = 12; + var x = bounds.x; + + while (x < bounds.x + @as(i32, @intCast(bounds.w))) { + const stripe_h = @min(@as(u32, @intCast(@max(0, bounds.x + @as(i32, @intCast(bounds.w)) - x))), @as(u32, @intCast(stripe_width))); + if (stripe_h > 0) { + ctx.pushCommand(.{ + .rect = .{ + .x = x, + .y = bounds.y, + .w = stripe_h, + .h = bounds.h, + .color = stripe_color, + }, + }); + } + x += stripe_gap; + } +} + +fn drawGradientFill(ctx: *Context, bounds: Rect, base_color: Color, vertical: bool) void { + if (bounds.w == 0 or bounds.h == 0) return; + + // Simple gradient approximation with 4 bands + const bands: u32 = 4; + const steps = if (vertical) bounds.h / bands else bounds.w / bands; + + var i: u32 = 0; + while (i < bands) : (i += 1) { + const t: f32 = @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(bands)); + const brightness: u8 = @intFromFloat(t * 40); + + const band_color = Color.rgba( + base_color.r -| brightness, + base_color.g -| brightness, + base_color.b -| brightness, + base_color.a, + ); + + if (vertical) { + ctx.pushCommand(.{ + .rect = .{ + .x = bounds.x, + .y = bounds.y + @as(i32, @intCast(i * steps)), + .w = bounds.w, + .h = steps, + .color = band_color, + }, + }); + } else { + ctx.pushCommand(.{ + .rect = .{ + .x = bounds.x + @as(i32, @intCast(i * steps)), + .y = bounds.y, + .w = steps, + .h = bounds.h, + .color = band_color, + }, + }); + } + } +} + +fn drawSegmentedFill(ctx: *Context, bounds: Rect, fill_color: Color, progress: f32, segments: u8, vertical: bool) void { + const seg_count: u32 = segments; + const gap: u32 = 2; + + if (vertical) { + const seg_height = (bounds.h - (seg_count - 1) * gap) / seg_count; + const filled_segs: u32 = @intFromFloat(@as(f32, @floatFromInt(seg_count)) * progress); + + var i: u32 = 0; + while (i < seg_count) : (i += 1) { + const seg_y = bounds.y + @as(i32, @intCast(bounds.h)) - @as(i32, @intCast((i + 1) * (seg_height + gap))); + const color = if (i < filled_segs) fill_color else Color.rgba(fill_color.r / 4, fill_color.g / 4, fill_color.b / 4, fill_color.a); + + ctx.pushCommand(.{ + .rect = .{ + .x = bounds.x, + .y = seg_y, + .w = bounds.w, + .h = seg_height, + .color = color, + }, + }); + } + } else { + const seg_width = (bounds.w - (seg_count - 1) * gap) / seg_count; + const filled_segs: u32 = @intFromFloat(@as(f32, @floatFromInt(seg_count)) * progress); + + var i: u32 = 0; + while (i < seg_count) : (i += 1) { + const seg_x = bounds.x + @as(i32, @intCast(i * (seg_width + gap))); + const color = if (i < filled_segs) fill_color else Color.rgba(fill_color.r / 4, fill_color.g / 4, fill_color.b / 4, fill_color.a); + + ctx.pushCommand(.{ + .rect = .{ + .x = seg_x, + .y = bounds.y, + .w = seg_width, + .h = bounds.h, + .color = color, + }, + }); + } + } +} + +fn drawCircleOutline(ctx: *Context, cx: i32, cy: i32, radius: i32, stroke: u8, color: Color) void { + // Approximate circle with octagon for simplicity in software rendering + const r = radius; + const s: i32 = @intCast(stroke); + + // Draw 8 segments around the circle + const offsets = [_][2]i32{ + .{ 0, -r }, // top + .{ r, 0 }, // right + .{ 0, r }, // bottom + .{ -r, 0 }, // left + }; + + for (offsets) |off| { + ctx.pushCommand(.{ + .rect = .{ + .x = cx + off[0] - @divTrunc(s, 2), + .y = cy + off[1] - @divTrunc(s, 2), + .w = @intCast(s), + .h = @intCast(s), + .color = color, + }, + }); + } +} + +fn drawProgressArc(ctx: *Context, cx: i32, cy: i32, radius: i32, stroke: u8, color: Color, progress: f32, start_angle: f32) void { + _ = start_angle; + + // Simplified arc drawing - draw filled segments + const segments: u32 = 16; + const filled: u32 = @intFromFloat(@as(f32, @floatFromInt(segments)) * progress); + + var i: u32 = 0; + while (i < filled) : (i += 1) { + const angle: f32 = (@as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(segments))) * 2.0 * std.math.pi - std.math.pi / 2.0; + const px: i32 = cx + @as(i32, @intFromFloat(@cos(angle) * @as(f32, @floatFromInt(radius)))); + const py: i32 = cy + @as(i32, @intFromFloat(@sin(angle) * @as(f32, @floatFromInt(radius)))); + + ctx.pushCommand(.{ + .rect = .{ + .x = px - @divTrunc(@as(i32, stroke), 2), + .y = py - @divTrunc(@as(i32, stroke), 2), + .w = stroke, + .h = stroke, + .color = color, + }, + }); + } +} + +fn drawRotatingArc(ctx: *Context, cx: i32, cy: i32, radius: i32, color: Color, animation: f32) void { + const segments: u32 = 8; + const arc_length: u32 = 5; // Number of segments in the arc + + const start_seg: u32 = @intFromFloat(animation * @as(f32, @floatFromInt(segments))); + + var i: u32 = 0; + while (i < arc_length) : (i += 1) { + const seg = (start_seg + i) % segments; + const angle: f32 = (@as(f32, @floatFromInt(seg)) / @as(f32, @floatFromInt(segments))) * 2.0 * std.math.pi - std.math.pi / 2.0; + const px: i32 = cx + @as(i32, @intFromFloat(@cos(angle) * @as(f32, @floatFromInt(radius)))); + const py: i32 = cy + @as(i32, @intFromFloat(@sin(angle) * @as(f32, @floatFromInt(radius)))); + + // Fade based on position in arc + const alpha: u8 = @intFromFloat((@as(f32, @floatFromInt(arc_length - i)) / @as(f32, @floatFromInt(arc_length))) * 255); + const faded = Color.rgba(color.r, color.g, color.b, alpha); + + ctx.pushCommand(.{ + .rect = .{ + .x = px - 2, + .y = py - 2, + .w = 4, + .h = 4, + .color = faded, + }, + }); + } +} + +fn drawPulsingDots(ctx: *Context, cx: i32, cy: i32, size: u16, color: Color, count: u8, animation: f32) void { + const radius: f32 = @as(f32, @floatFromInt(size)) / 3.0; + + var i: u8 = 0; + while (i < count) : (i += 1) { + const angle: f32 = (@as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(count))) * 2.0 * std.math.pi - std.math.pi / 2.0; + const px: i32 = cx + @as(i32, @intFromFloat(@cos(angle) * radius)); + const py: i32 = cy + @as(i32, @intFromFloat(@sin(angle) * radius)); + + // Pulse based on animation and position + const phase = animation + @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(count)); + const scale: f32 = 0.5 + 0.5 * @sin(phase * 2.0 * std.math.pi); + const dot_size: u32 = @intFromFloat(2.0 + scale * 3.0); + + ctx.pushCommand(.{ + .rect = .{ + .x = px - @as(i32, @intCast(dot_size / 2)), + .y = py - @as(i32, @intCast(dot_size / 2)), + .w = dot_size, + .h = dot_size, + .color = color, + }, + }); + } +} + +fn drawBouncingBars(ctx: *Context, bounds: Rect, color: Color, count: u8, animation: f32) void { + const bar_width = bounds.w / @as(u32, count); + const max_height = bounds.h; + + var i: u8 = 0; + while (i < count) : (i += 1) { + // Each bar bounces with phase offset + const phase = animation + @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(count)); + const bounce: f32 = @abs(@sin(phase * 2.0 * std.math.pi)); + const bar_height: u32 = @intFromFloat(@as(f32, @floatFromInt(max_height)) * (0.3 + 0.7 * bounce)); + + const bar_x = bounds.x + @as(i32, @intCast(@as(u32, i) * bar_width)); + const bar_y = bounds.y + @as(i32, @intCast(max_height - bar_height)); + + ctx.pushCommand(.{ + .rect = .{ + .x = bar_x + 1, + .y = bar_y, + .w = bar_width -| 2, + .h = bar_height, + .color = color, + }, + }); + } +} + +fn drawRingWithGap(ctx: *Context, cx: i32, cy: i32, radius: i32, color: Color, animation: f32) void { + const segments: u32 = 12; + const gap_size: u32 = 3; // Number of segments for the gap + const gap_start: u32 = @intFromFloat(animation * @as(f32, @floatFromInt(segments))); + + var i: u32 = 0; + while (i < segments) : (i += 1) { + // Skip gap segments + const distance_from_gap = @min((i + segments - gap_start) % segments, (gap_start + segments - i) % segments); + if (distance_from_gap < gap_size / 2) continue; + + const angle: f32 = (@as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(segments))) * 2.0 * std.math.pi - std.math.pi / 2.0; + const px: i32 = cx + @as(i32, @intFromFloat(@cos(angle) * @as(f32, @floatFromInt(radius)))); + const py: i32 = cy + @as(i32, @intFromFloat(@sin(angle) * @as(f32, @floatFromInt(radius)))); + + ctx.pushCommand(.{ + .rect = .{ + .x = px - 2, + .y = py - 2, + .w = 4, + .h = 4, + .color = color, + }, + }); + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +test "ProgressBar basic" { + var ctx = try Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + ctx.beginFrame(); + + const result = bar(&ctx, 0.5); + try std.testing.expectEqual(@as(f32, 0.5), result.progress); + try std.testing.expect(ctx.commands.items.len > 0); + + ctx.endFrame(); +} + +test "ProgressBar clamping" { + var ctx = try Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + ctx.beginFrame(); + + // Test value clamping + const result1 = bar(&ctx, -0.5); + try std.testing.expectEqual(@as(f32, 0.0), result1.progress); + + const result2 = bar(&ctx, 1.5); + try std.testing.expectEqual(@as(f32, 1.0), result2.progress); + + ctx.endFrame(); +} + +test "ProgressBar styles" { + var ctx = try Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + ctx.beginFrame(); + + // Test different styles + _ = barEx(&ctx, 0.75, .{ .style = .solid }); + _ = barEx(&ctx, 0.75, .{ .style = .striped }); + _ = barEx(&ctx, 0.75, .{ .style = .gradient }); + _ = barEx(&ctx, 0.75, .{ .style = .segmented, .segments = 5 }); + + try std.testing.expect(ctx.commands.items.len > 0); + + ctx.endFrame(); +} + +test "ProgressCircle basic" { + var ctx = try Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + ctx.beginFrame(); + + const result = circle(&ctx, 0.75); + try std.testing.expectEqual(@as(f32, 0.75), result.progress); + + ctx.endFrame(); +} + +test "Spinner state" { + var state = SpinnerState{}; + + // Initial state + try std.testing.expectEqual(@as(f32, 0), state.animation); + + // Update advances animation + state.update(1.0); + state.last_update -= 100; // Simulate 100ms passed + state.update(1.0); + + try std.testing.expect(state.animation >= 0); +} + +test "Spinner basic" { + var ctx = try Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + ctx.beginFrame(); + + var state = SpinnerState{}; + const result = spinner(&ctx, &state); + try std.testing.expect(result.bounds.w > 0); + + ctx.endFrame(); +} diff --git a/src/widgets/table.zig b/src/widgets/table.zig new file mode 100644 index 0000000..e6e2d58 --- /dev/null +++ b/src/widgets/table.zig @@ -0,0 +1,1592 @@ +//! Table Widget - Editable data table +//! +//! A full-featured table widget with: +//! - Keyboard navigation (arrows, Tab, Enter, Escape) +//! - In-place cell editing +//! - Row state indicators (new, modified, deleted) +//! - Column headers with optional sorting +//! - Virtualized rendering (only visible rows) +//! - Scrolling support + +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"); +const text_input = @import("text_input.zig"); + +// ============================================================================= +// Types +// ============================================================================= + +/// Row state for dirty tracking +pub const RowState = enum { + /// Unchanged from original + clean, + /// Newly added row + new, + /// Modified row + modified, + /// Marked for deletion + deleted, +}; + +/// Column type for formatting/validation +pub const ColumnType = enum { + text, + number, + money, + date, + select, +}; + +/// Sort direction +pub const SortDirection = enum { + none, + ascending, + descending, + + /// Toggle to next direction + pub fn toggle(self: SortDirection) SortDirection { + return switch (self) { + .none => .ascending, + .ascending => .descending, + .descending => .none, + }; + } +}; + +/// Column definition +pub const Column = struct { + /// Column header text + name: []const u8, + /// Column width in pixels + width: u32, + /// Column type for formatting + column_type: ColumnType = .text, + /// Whether cells in this column are editable + editable: bool = true, + /// Minimum width when resizing + min_width: u32 = 40, + /// Whether this column is sortable + sortable: bool = true, +}; + +/// Table configuration +pub const TableConfig = struct { + /// Height of header row + header_height: u32 = 28, + /// Height of each data row + row_height: u32 = 24, + /// Show row state indicators + show_state_indicators: bool = true, + /// Width of state indicator column + state_indicator_width: u32 = 24, + /// Allow keyboard navigation + keyboard_nav: bool = true, + /// Allow cell editing + allow_edit: bool = true, + /// Show column headers + show_headers: bool = true, + /// Alternating row colors + alternating_rows: bool = true, + /// Allow column sorting + allow_sorting: bool = true, + /// Allow row operations (Ctrl+N, Delete, etc.) + allow_row_operations: bool = true, + /// Allow multi-row selection + allow_multi_select: bool = true, +}; + +/// Table colors +pub const TableColors = struct { + header_bg: Style.Color = Style.Color.rgb(50, 50, 50), + header_fg: Style.Color = Style.Color.rgb(220, 220, 220), + header_hover: Style.Color = Style.Color.rgb(60, 60, 65), + header_sorted: Style.Color = Style.Color.rgb(55, 55, 60), + sort_indicator: Style.Color = Style.Color.primary, + row_even: Style.Color = Style.Color.rgb(35, 35, 35), + row_odd: Style.Color = Style.Color.rgb(40, 40, 40), + row_hover: Style.Color = Style.Color.rgb(50, 50, 60), + row_selected: Style.Color = Style.Color.rgb(66, 135, 245), + cell_editing: Style.Color = Style.Color.rgb(60, 60, 80), + cell_text: Style.Color = Style.Color.rgb(220, 220, 220), + cell_text_selected: Style.Color = Style.Color.rgb(255, 255, 255), + border: Style.Color = Style.Color.rgb(60, 60, 60), + state_new: Style.Color = Style.Color.rgb(76, 175, 80), + state_modified: Style.Color = Style.Color.rgb(255, 152, 0), + state_deleted: Style.Color = Style.Color.rgb(244, 67, 54), + /// Validation error cell background + validation_error_bg: Style.Color = Style.Color.rgb(80, 40, 40), + /// Validation error border + validation_error_border: Style.Color = Style.Color.rgb(200, 60, 60), +}; + +/// Result of table interaction +pub const TableResult = struct { + /// Cell was selected + selection_changed: bool = false, + /// Cell value was edited + cell_edited: bool = false, + /// Row was added (Ctrl+N pressed) + row_added: bool = false, + /// Insert row at this index (-1 = append) + insert_at: i32 = -1, + /// Row was deleted (Delete pressed) + row_deleted: bool = false, + /// Rows to delete (indices) + delete_rows: [64]usize = undefined, + /// Number of rows to delete + delete_count: usize = 0, + /// Editing started + edit_started: bool = false, + /// Editing ended + edit_ended: bool = false, + /// Sort changed + sort_changed: bool = false, + /// Column that was sorted (-1 if none) + sort_column: i32 = -1, + /// New sort direction + sort_direction: SortDirection = .none, + /// Select all was triggered (Ctrl+A) + select_all: bool = false, + /// Validation failed + validation_failed: bool = false, + /// Validation error message + validation_message: []const u8 = "", +}; + +// ============================================================================= +// Table State +// ============================================================================= + +/// Maximum columns supported +pub const MAX_COLUMNS = 32; +/// Maximum edit buffer size +pub const MAX_EDIT_BUFFER = 256; + +/// Table state (caller-managed) +pub const TableState = struct { + /// Number of rows + row_count: usize = 0, + + /// Selected row (-1 for none) + selected_row: i32 = -1, + /// Selected column (-1 for none) + selected_col: i32 = -1, + + /// Whether a cell is being edited + editing: bool = false, + /// Edit buffer + edit_buffer: [MAX_EDIT_BUFFER]u8 = undefined, + /// Edit state (for TextInput) + edit_state: text_input.TextInputState = undefined, + + /// Scroll offset (first visible row) + scroll_row: usize = 0, + /// Horizontal scroll offset + scroll_x: i32 = 0, + + /// Whether table has focus + focused: bool = false, + + /// Row states for dirty tracking + row_states: [1024]RowState = [_]RowState{.clean} ** 1024, + + /// Currently sorted column (-1 for none) + sort_column: i32 = -1, + /// Sort direction + sort_direction: SortDirection = .none, + /// Hovered header column (-1 for none) + hovered_header: i32 = -1, + + /// Multi-row selection (bit array for first 1024 rows) + selected_rows: [128]u8 = [_]u8{0} ** 128, // 1024 bits + /// Selection anchor for shift-click + selection_anchor: i32 = -1, + + /// Cells with validation errors (row * MAX_COLUMNS + col) + validation_errors: [256]u32 = [_]u32{0xFFFFFFFF} ** 256, + /// Number of cells with validation errors + validation_error_count: usize = 0, + /// Last validation error message + last_validation_message: [128]u8 = [_]u8{0} ** 128, + /// Length of last validation message + last_validation_message_len: usize = 0, + + const Self = @This(); + + /// Initialize table state + pub fn init() Self { + var state = Self{}; + state.edit_state = text_input.TextInputState.init(&state.edit_buffer); + return state; + } + + /// Set row count + pub fn setRowCount(self: *Self, count: usize) void { + self.row_count = count; + // Reset states for new rows + for (0..@min(count, self.row_states.len)) |i| { + if (self.row_states[i] == .clean) { + // Keep existing state + } + } + } + + /// Get selected cell + pub fn selectedCell(self: Self) ?struct { row: usize, col: usize } { + if (self.selected_row < 0 or self.selected_col < 0) return null; + return .{ + .row = @intCast(self.selected_row), + .col = @intCast(self.selected_col), + }; + } + + /// Select a cell + pub fn selectCell(self: *Self, row: usize, col: usize) void { + self.selected_row = @intCast(row); + self.selected_col = @intCast(col); + } + + /// Clear selection + pub fn clearSelection(self: *Self) void { + self.selected_row = -1; + self.selected_col = -1; + self.editing = false; + } + + /// Start editing current cell + pub fn startEditing(self: *Self, initial_text: []const u8) void { + self.editing = true; + self.edit_state.setText(initial_text); + self.edit_state.focused = true; + } + + /// Stop editing + pub fn stopEditing(self: *Self) void { + self.editing = false; + self.edit_state.focused = false; + } + + /// Get edit text + pub fn getEditText(self: *Self) []const u8 { + return self.edit_state.text(); + } + + /// Mark row as modified + pub fn markModified(self: *Self, row: usize) void { + if (row < self.row_states.len) { + if (self.row_states[row] == .clean) { + self.row_states[row] = .modified; + } + } + } + + /// Mark row as new + pub fn markNew(self: *Self, row: usize) void { + if (row < self.row_states.len) { + self.row_states[row] = .new; + } + } + + /// Mark row as deleted + pub fn markDeleted(self: *Self, row: usize) void { + if (row < self.row_states.len) { + self.row_states[row] = .deleted; + } + } + + /// Get row state + pub fn getRowState(self: Self, row: usize) RowState { + if (row < self.row_states.len) { + return self.row_states[row]; + } + return .clean; + } + + /// Ensure selected row is visible + pub fn ensureVisible(self: *Self, visible_rows: usize) void { + if (self.selected_row < 0) return; + const row: usize = @intCast(self.selected_row); + + if (row < self.scroll_row) { + self.scroll_row = row; + } else if (row >= self.scroll_row + visible_rows) { + self.scroll_row = row - visible_rows + 1; + } + } + + // ========================================================================= + // Navigation + // ========================================================================= + + /// Move selection up + pub fn moveUp(self: *Self) void { + if (self.selected_row > 0) { + self.selected_row -= 1; + } + } + + /// Move selection down + pub fn moveDown(self: *Self) void { + if (self.selected_row < @as(i32, @intCast(self.row_count)) - 1) { + self.selected_row += 1; + } + } + + /// Move selection left + pub fn moveLeft(self: *Self) void { + if (self.selected_col > 0) { + self.selected_col -= 1; + } + } + + /// Move selection right + pub fn moveRight(self: *Self, col_count: usize) void { + if (self.selected_col < @as(i32, @intCast(col_count)) - 1) { + self.selected_col += 1; + } + } + + /// Move to first row + pub fn moveToFirst(self: *Self) void { + if (self.row_count > 0) { + self.selected_row = 0; + } + } + + /// Move to last row + pub fn moveToLast(self: *Self) void { + if (self.row_count > 0) { + self.selected_row = @intCast(self.row_count - 1); + } + } + + /// Page up + pub fn pageUp(self: *Self, visible_rows: usize) void { + if (self.selected_row > 0) { + const jump = @as(i32, @intCast(visible_rows)); + self.selected_row = @max(0, self.selected_row - jump); + } + } + + /// Page down + pub fn pageDown(self: *Self, visible_rows: usize) void { + const max_row = @as(i32, @intCast(self.row_count)) - 1; + if (self.selected_row < max_row) { + const jump = @as(i32, @intCast(visible_rows)); + self.selected_row = @min(max_row, self.selected_row + jump); + } + } + + // ========================================================================= + // Sorting + // ========================================================================= + + /// Set sort column and direction + pub fn setSort(self: *Self, column: i32, direction: SortDirection) void { + self.sort_column = column; + self.sort_direction = direction; + } + + /// Clear sort + pub fn clearSort(self: *Self) void { + self.sort_column = -1; + self.sort_direction = .none; + } + + /// Toggle sort on a column + pub fn toggleSort(self: *Self, column: usize) SortDirection { + const col_i32 = @as(i32, @intCast(column)); + if (self.sort_column == col_i32) { + // Same column - toggle direction + self.sort_direction = self.sort_direction.toggle(); + if (self.sort_direction == .none) { + self.sort_column = -1; + } + } else { + // Different column - start ascending + self.sort_column = col_i32; + self.sort_direction = .ascending; + } + return self.sort_direction; + } + + /// Get current sort info + pub fn getSortInfo(self: Self) ?struct { column: usize, direction: SortDirection } { + if (self.sort_column < 0 or self.sort_direction == .none) return null; + return .{ + .column = @intCast(self.sort_column), + .direction = self.sort_direction, + }; + } + + // ========================================================================= + // Multi-Row Selection + // ========================================================================= + + /// Check if a row is selected + pub fn isRowSelected(self: Self, row: usize) bool { + if (row >= 1024) return false; + const byte_idx = row / 8; + const bit_idx: u3 = @intCast(row % 8); + return (self.selected_rows[byte_idx] & (@as(u8, 1) << bit_idx)) != 0; + } + + /// Select a single row (clears other selections) + pub fn selectSingleRow(self: *Self, row: usize) void { + self.clearRowSelection(); + self.addRowToSelection(row); + self.selected_row = @intCast(row); + self.selection_anchor = @intCast(row); + } + + /// Add a row to selection + pub fn addRowToSelection(self: *Self, row: usize) void { + if (row >= 1024) return; + const byte_idx = row / 8; + const bit_idx: u3 = @intCast(row % 8); + self.selected_rows[byte_idx] |= (@as(u8, 1) << bit_idx); + } + + /// Remove a row from selection + pub fn removeRowFromSelection(self: *Self, row: usize) void { + if (row >= 1024) return; + const byte_idx = row / 8; + const bit_idx: u3 = @intCast(row % 8); + self.selected_rows[byte_idx] &= ~(@as(u8, 1) << bit_idx); + } + + /// Toggle row selection + pub fn toggleRowSelection(self: *Self, row: usize) void { + if (self.isRowSelected(row)) { + self.removeRowFromSelection(row); + } else { + self.addRowToSelection(row); + } + } + + /// Clear all row selections + pub fn clearRowSelection(self: *Self) void { + @memset(&self.selected_rows, 0); + } + + /// Select all rows + pub fn selectAllRows(self: *Self) void { + if (self.row_count == 0) return; + // Set bits for all rows + const full_bytes = self.row_count / 8; + const remaining_bits: u3 = @intCast(self.row_count % 8); + + for (0..full_bytes) |i| { + self.selected_rows[i] = 0xFF; + } + if (remaining_bits > 0 and full_bytes < self.selected_rows.len) { + self.selected_rows[full_bytes] = (@as(u8, 1) << remaining_bits) - 1; + } + } + + /// Select range of rows (for Shift+click) + pub fn selectRowRange(self: *Self, from: usize, to: usize) void { + const start = @min(from, to); + const end = @max(from, to); + for (start..end + 1) |row| { + self.addRowToSelection(row); + } + } + + /// Get count of selected rows + pub fn getSelectedRowCount(self: Self) usize { + var count: usize = 0; + for (0..@min(self.row_count, 1024)) |row| { + if (self.isRowSelected(row)) { + count += 1; + } + } + return count; + } + + /// Get list of selected row indices + pub fn getSelectedRows(self: Self, buffer: []usize) usize { + var count: usize = 0; + for (0..@min(self.row_count, 1024)) |row| { + if (self.isRowSelected(row) and count < buffer.len) { + buffer[count] = row; + count += 1; + } + } + return count; + } + + // ========================================================================= + // Validation + // ========================================================================= + + /// Check if a cell has a validation error + pub fn hasCellError(self: Self, row: usize, col: usize) bool { + const cell_id = @as(u32, @intCast(row)) * MAX_COLUMNS + @as(u32, @intCast(col)); + for (0..self.validation_error_count) |i| { + if (self.validation_errors[i] == cell_id) { + return true; + } + } + return false; + } + + /// Add a validation error for a cell + pub fn addCellError(self: *Self, row: usize, col: usize, message: []const u8) void { + // Store message first (even if cell already has error) + const copy_len = @min(message.len, self.last_validation_message.len); + for (0..copy_len) |i| { + self.last_validation_message[i] = message[i]; + } + self.last_validation_message_len = copy_len; + + if (self.hasCellError(row, col)) return; + if (self.validation_error_count >= self.validation_errors.len) return; + + const cell_id = @as(u32, @intCast(row)) * MAX_COLUMNS + @as(u32, @intCast(col)); + self.validation_errors[self.validation_error_count] = cell_id; + self.validation_error_count += 1; + } + + /// Clear validation error for a cell + pub fn clearCellError(self: *Self, row: usize, col: usize) void { + const cell_id = @as(u32, @intCast(row)) * MAX_COLUMNS + @as(u32, @intCast(col)); + for (0..self.validation_error_count) |i| { + if (self.validation_errors[i] == cell_id) { + // Move last error to this slot + if (self.validation_error_count > 1) { + self.validation_errors[i] = self.validation_errors[self.validation_error_count - 1]; + } + self.validation_error_count -= 1; + return; + } + } + } + + /// Clear all validation errors + pub fn clearAllErrors(self: *Self) void { + self.validation_error_count = 0; + self.last_validation_message_len = 0; + } + + /// Check if any cell has validation errors + pub fn hasAnyErrors(self: Self) bool { + return self.validation_error_count > 0; + } + + /// Get last validation message + pub fn getLastValidationMessage(self: Self) []const u8 { + return self.last_validation_message[0..self.last_validation_message_len]; + } +}; + +// ============================================================================= +// Table Widget +// ============================================================================= + +/// Cell data provider callback +pub const CellDataFn = *const fn (row: usize, col: usize) []const u8; + +/// Cell edit callback (called when edit is committed) +pub const CellEditFn = *const fn (row: usize, col: usize, new_value: []const u8) void; + +/// Validation result +pub const ValidationResult = struct { + /// Whether the value is valid + valid: bool = true, + /// Error message (if invalid) + message: []const u8 = "", +}; + +/// Cell validation callback +pub const CellValidateFn = *const fn (row: usize, col: usize, value: []const u8) ValidationResult; + +/// Draw a table +pub fn table( + ctx: *Context, + state: *TableState, + columns: []const Column, + get_cell: CellDataFn, +) TableResult { + return tableEx(ctx, state, columns, get_cell, null, .{}, .{}); +} + +/// Draw a table with full options +pub fn tableEx( + ctx: *Context, + state: *TableState, + columns: []const Column, + get_cell: CellDataFn, + on_edit: ?CellEditFn, + config: TableConfig, + colors: TableColors, +) TableResult { + const bounds = ctx.layout.nextRect(); + return tableRectFull(ctx, bounds, state, columns, get_cell, on_edit, null, config, colors); +} + +/// Draw a table with validation +pub fn tableWithValidation( + ctx: *Context, + state: *TableState, + columns: []const Column, + get_cell: CellDataFn, + on_edit: ?CellEditFn, + validate: ?CellValidateFn, + config: TableConfig, + colors: TableColors, +) TableResult { + const bounds = ctx.layout.nextRect(); + return tableRectFull(ctx, bounds, state, columns, get_cell, on_edit, validate, config, colors); +} + +/// Draw a table in a specific rectangle +pub fn tableRect( + ctx: *Context, + bounds: Layout.Rect, + state: *TableState, + columns: []const Column, + get_cell: CellDataFn, + on_edit: ?CellEditFn, + config: TableConfig, + colors: TableColors, +) TableResult { + return tableRectFull(ctx, bounds, state, columns, get_cell, on_edit, null, config, colors); +} + +/// Draw a table in a specific rectangle with full options +pub fn tableRectFull( + ctx: *Context, + bounds: Layout.Rect, + state: *TableState, + columns: []const Column, + get_cell: CellDataFn, + on_edit: ?CellEditFn, + validate: ?CellValidateFn, + config: TableConfig, + colors: TableColors, +) TableResult { + var result = TableResult{}; + + if (bounds.isEmpty() or columns.len == 0) return result; + + const mouse = ctx.input.mousePos(); + const table_hovered = bounds.contains(mouse.x, mouse.y); + + // Click for focus + if (table_hovered and ctx.input.mousePressed(.left)) { + state.focused = true; + } + + // Calculate dimensions + const header_h = if (config.show_headers) config.header_height else 0; + const state_col_w = if (config.show_state_indicators) config.state_indicator_width else 0; + + // Calculate total column width + var total_col_width: u32 = state_col_w; + for (columns) |col| { + total_col_width += col.width; + } + + // Data area + const data_area = Layout.Rect.init( + bounds.x, + bounds.y + @as(i32, @intCast(header_h)), + bounds.w, + bounds.h -| header_h, + ); + + // Visible rows + const visible_rows = data_area.h / config.row_height; + + // Clamp scroll + if (state.row_count <= visible_rows) { + state.scroll_row = 0; + } else if (state.scroll_row > state.row_count - visible_rows) { + state.scroll_row = state.row_count - visible_rows; + } + + // Handle scroll wheel + if (table_hovered) { + if (ctx.input.scroll_y < 0 and state.scroll_row > 0) { + state.scroll_row -= 1; + } else if (ctx.input.scroll_y > 0 and state.scroll_row < state.row_count -| visible_rows) { + state.scroll_row += 1; + } + } + + // Draw background + ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, colors.row_even)); + + // Draw border + const border_color = if (state.focused) Style.Color.primary else colors.border; + ctx.pushCommand(Command.rectOutline(bounds.x, bounds.y, bounds.w, bounds.h, border_color)); + + // Clip to table bounds + ctx.pushCommand(Command.clip(bounds.x, bounds.y, bounds.w, bounds.h)); + + // Draw header + if (config.show_headers) { + const header_result = drawHeader(ctx, bounds, state, columns, state_col_w, config, colors); + if (header_result.sort_changed) { + result.sort_changed = true; + result.sort_column = header_result.sort_column; + result.sort_direction = header_result.sort_direction; + } + } + + // Draw rows + const end_row = @min(state.scroll_row + visible_rows + 1, state.row_count); + var row_y = data_area.y; + + for (state.scroll_row..end_row) |row| { + if (row_y >= data_area.bottom()) break; + + const row_bounds = Layout.Rect.init( + data_area.x, + row_y, + data_area.w, + config.row_height, + ); + + const row_result = drawRow( + ctx, + row_bounds, + state, + row, + columns, + get_cell, + on_edit, + validate, + state_col_w, + config, + colors, + ); + + if (row_result.selection_changed) result.selection_changed = true; + if (row_result.cell_edited) result.cell_edited = true; + if (row_result.edit_started) result.edit_started = true; + if (row_result.edit_ended) result.edit_ended = true; + if (row_result.validation_failed) { + result.validation_failed = true; + result.validation_message = row_result.validation_message; + } + + row_y += @as(i32, @intCast(config.row_height)); + } + + // Draw scrollbar if needed + if (state.row_count > visible_rows) { + drawScrollbar(ctx, bounds, state, visible_rows, config, colors); + } + + // End clip + ctx.pushCommand(Command.clipEnd()); + + // Handle keyboard if focused and not editing + if (state.focused and config.keyboard_nav and !state.editing) { + handleKeyboard(ctx, state, columns.len, visible_rows, get_cell, on_edit, validate, config, &result); + } + + // Ensure selection is visible after navigation + state.ensureVisible(visible_rows); + + return result; +} + +// ============================================================================= +// Drawing Helpers +// ============================================================================= + +fn drawHeader( + ctx: *Context, + bounds: Layout.Rect, + state: *TableState, + columns: []const Column, + state_col_w: u32, + config: TableConfig, + colors: TableColors, +) TableResult { + var result = TableResult{}; + + const header_bounds = Layout.Rect.init( + bounds.x, + bounds.y, + bounds.w, + config.header_height, + ); + + const mouse = ctx.input.mousePos(); + const mouse_pressed = ctx.input.mousePressed(.left); + + // Header background + ctx.pushCommand(Command.rect( + header_bounds.x, + header_bounds.y, + header_bounds.w, + header_bounds.h, + colors.header_bg, + )); + + // Header border + ctx.pushCommand(Command.line( + header_bounds.x, + header_bounds.bottom() - 1, + header_bounds.right(), + header_bounds.bottom() - 1, + colors.border, + )); + + // Reset hovered header + state.hovered_header = -1; + + // State indicator column header (empty) + var col_x = bounds.x + @as(i32, @intCast(state_col_w)); + + // Draw column headers + const char_height: u32 = 8; + const text_y = header_bounds.y + @as(i32, @intCast((config.header_height -| char_height) / 2)); + + for (columns, 0..) |col, col_idx| { + const col_header_bounds = Layout.Rect.init( + col_x, + header_bounds.y, + col.width, + config.header_height, + ); + + const is_hovered = col_header_bounds.contains(mouse.x, mouse.y); + const is_sorted = state.sort_column == @as(i32, @intCast(col_idx)); + + if (is_hovered and col.sortable) { + state.hovered_header = @intCast(col_idx); + } + + // Column background (for hover/sorted state) + const col_bg = if (is_sorted) + colors.header_sorted + else if (is_hovered and col.sortable and config.allow_sorting) + colors.header_hover + else + colors.header_bg; + + if (col_bg.r != colors.header_bg.r or col_bg.g != colors.header_bg.g or col_bg.b != colors.header_bg.b) { + ctx.pushCommand(Command.rect( + col_header_bounds.x, + col_header_bounds.y, + col_header_bounds.w, + col_header_bounds.h, + col_bg, + )); + } + + // Column text + const text_x = col_x + 4; // Padding + ctx.pushCommand(Command.text(text_x, text_y, col.name, colors.header_fg)); + + // Sort indicator + if (is_sorted and state.sort_direction != .none) { + const indicator_x = col_x + @as(i32, @intCast(col.width)) - 16; + const indicator_y = header_bounds.y + @as(i32, @intCast((config.header_height - 8) / 2)); + + // Draw arrow (triangle approximation with text) + const arrow: []const u8 = switch (state.sort_direction) { + .ascending => "^", + .descending => "v", + .none => "", + }; + if (arrow.len > 0) { + ctx.pushCommand(Command.text(indicator_x, indicator_y, arrow, colors.sort_indicator)); + } + } + + // Handle click for sorting + if (mouse_pressed and is_hovered and col.sortable and config.allow_sorting) { + const new_direction = state.toggleSort(col_idx); + result.sort_changed = true; + result.sort_column = @intCast(col_idx); + result.sort_direction = new_direction; + } + + // Column separator + col_x += @as(i32, @intCast(col.width)); + ctx.pushCommand(Command.line( + col_x, + header_bounds.y, + col_x, + header_bounds.bottom(), + colors.border, + )); + } + + return result; +} + +fn drawRow( + ctx: *Context, + row_bounds: Layout.Rect, + state: *TableState, + row: usize, + columns: []const Column, + get_cell: CellDataFn, + on_edit: ?CellEditFn, + validate: ?CellValidateFn, + state_col_w: u32, + config: TableConfig, + colors: TableColors, +) TableResult { + var result = TableResult{}; + + const mouse = ctx.input.mousePos(); + const is_selected = state.selected_row == @as(i32, @intCast(row)); + const row_hovered = row_bounds.contains(mouse.x, mouse.y); + + // Row background + const row_bg = if (is_selected) + colors.row_selected + else if (row_hovered) + colors.row_hover + else if (config.alternating_rows and row % 2 == 1) + colors.row_odd + else + colors.row_even; + + ctx.pushCommand(Command.rect(row_bounds.x, row_bounds.y, row_bounds.w, row_bounds.h, row_bg)); + + // State indicator + if (config.show_state_indicators) { + const indicator_bounds = Layout.Rect.init( + row_bounds.x, + row_bounds.y, + state_col_w, + config.row_height, + ); + drawStateIndicator(ctx, indicator_bounds, state.getRowState(row), colors); + } + + // Draw cells + var col_x = row_bounds.x + @as(i32, @intCast(state_col_w)); + const char_height: u32 = 8; + const text_y = row_bounds.y + @as(i32, @intCast((config.row_height -| char_height) / 2)); + + for (columns, 0..) |col, col_idx| { + const cell_bounds = Layout.Rect.init( + col_x, + row_bounds.y, + col.width, + config.row_height, + ); + + const is_cell_selected = is_selected and state.selected_col == @as(i32, @intCast(col_idx)); + const cell_hovered = cell_bounds.contains(mouse.x, mouse.y); + const has_error = state.hasCellError(row, col_idx); + + // Cell validation error background + if (has_error) { + ctx.pushCommand(Command.rect( + cell_bounds.x + 1, + cell_bounds.y + 1, + cell_bounds.w - 2, + cell_bounds.h - 2, + colors.validation_error_bg, + )); + ctx.pushCommand(Command.rectOutline( + cell_bounds.x, + cell_bounds.y, + cell_bounds.w, + cell_bounds.h, + colors.validation_error_border, + )); + } + + // Cell selection highlight (drawn over error background if both) + if (is_cell_selected and !state.editing) { + ctx.pushCommand(Command.rectOutline( + cell_bounds.x + 1, + cell_bounds.y + 1, + cell_bounds.w - 2, + cell_bounds.h - 2, + Style.Color.primary, + )); + } + + // Handle cell click + if (cell_hovered and ctx.input.mousePressed(.left)) { + const was_selected = is_cell_selected; + state.selectCell(row, col_idx); + result.selection_changed = true; + + // Double-click to edit (or click on already selected) + if (was_selected and config.allow_edit and col.editable) { + const cell_text = get_cell(row, col_idx); + state.startEditing(cell_text); + result.edit_started = true; + } + } + + // Draw cell content + if (state.editing and is_cell_selected) { + // Draw edit field + ctx.pushCommand(Command.rect( + cell_bounds.x + 1, + cell_bounds.y + 1, + cell_bounds.w - 2, + cell_bounds.h - 2, + colors.cell_editing, + )); + + // Real-time validation during editing + if (validate) |validate_fn| { + const edit_text = state.getEditText(); + const validation = validate_fn(row, col_idx, edit_text); + if (!validation.valid) { + // Draw error indicator while editing + ctx.pushCommand(Command.rectOutline( + cell_bounds.x, + cell_bounds.y, + cell_bounds.w, + cell_bounds.h, + colors.validation_error_border, + )); + } + } + + // Handle text input + const text_in = ctx.input.getTextInput(); + if (text_in.len > 0) { + state.edit_state.insert(text_in); + } + + // Draw edit text + const edit_text = state.getEditText(); + ctx.pushCommand(Command.text(col_x + 4, text_y, edit_text, colors.cell_text)); + + // Draw cursor + const cursor_x = col_x + 4 + @as(i32, @intCast(state.edit_state.cursor * 8)); + ctx.pushCommand(Command.rect( + cursor_x, + cell_bounds.y + 2, + 2, + cell_bounds.h - 4, + colors.cell_text, + )); + } else { + // Normal cell display + const cell_text = get_cell(row, col_idx); + const text_color = if (is_selected) colors.cell_text_selected else colors.cell_text; + ctx.pushCommand(Command.text(col_x + 4, text_y, cell_text, text_color)); + } + + // Column separator + col_x += @as(i32, @intCast(col.width)); + ctx.pushCommand(Command.line( + col_x, + row_bounds.y, + col_x, + row_bounds.bottom(), + colors.border, + )); + } + + // Row bottom border + ctx.pushCommand(Command.line( + row_bounds.x, + row_bounds.bottom() - 1, + row_bounds.right(), + row_bounds.bottom() - 1, + colors.border, + )); + + // Handle edit commit on Enter or when moving away + if (state.editing and is_selected) { + // This will be handled by keyboard handler + _ = on_edit; + } + + return result; +} + +fn drawStateIndicator( + ctx: *Context, + bounds: Layout.Rect, + row_state: RowState, + colors: TableColors, +) void { + const indicator_size: u32 = 8; + const x = bounds.x + @as(i32, @intCast((bounds.w -| indicator_size) / 2)); + const y = bounds.y + @as(i32, @intCast((bounds.h -| indicator_size) / 2)); + + const color: ?Style.Color = switch (row_state) { + .clean => null, + .new => colors.state_new, + .modified => colors.state_modified, + .deleted => colors.state_deleted, + }; + + if (color) |c| { + ctx.pushCommand(Command.rect(x, y, indicator_size, indicator_size, c)); + } +} + +fn drawScrollbar( + ctx: *Context, + bounds: Layout.Rect, + state: *TableState, + visible_rows: usize, + config: TableConfig, + colors: TableColors, +) void { + _ = config; + + const scrollbar_w: u32 = 12; + const header_h: u32 = 28; // Assume header + + const track_x = bounds.right() - @as(i32, @intCast(scrollbar_w)); + const track_y = bounds.y + @as(i32, @intCast(header_h)); + const track_h = bounds.h -| header_h; + + // Track + ctx.pushCommand(Command.rect( + track_x, + track_y, + scrollbar_w, + track_h, + colors.row_odd, + )); + + // Thumb + if (state.row_count > 0) { + const visible_rows_u32: u32 = @intCast(visible_rows); + const row_count_u32: u32 = @intCast(state.row_count); + const thumb_h: u32 = @max((visible_rows_u32 * track_h) / row_count_u32, 20); + const scroll_range = state.row_count - visible_rows; + const scroll_row_u32: u32 = @intCast(state.scroll_row); + const scroll_range_u32: u32 = @intCast(scroll_range); + const thumb_offset: u32 = if (scroll_range > 0) + (scroll_row_u32 * (track_h - thumb_h)) / scroll_range_u32 + else + 0; + + ctx.pushCommand(Command.rect( + track_x + 2, + track_y + @as(i32, @intCast(thumb_offset)), + scrollbar_w - 4, + thumb_h, + colors.header_bg, + )); + } +} + +fn handleKeyboard( + ctx: *Context, + state: *TableState, + col_count: usize, + visible_rows: usize, + get_cell: CellDataFn, + on_edit: ?CellEditFn, + validate: ?CellValidateFn, + config: TableConfig, + result: *TableResult, +) void { + // Check for navigation keys + if (ctx.input.navKeyPressed()) |key| { + switch (key) { + .up => { + if (state.selected_row > 0) { + state.selected_row -= 1; + result.selection_changed = true; + state.ensureVisible(visible_rows); + } + }, + .down => { + if (state.selected_row < @as(i32, @intCast(state.row_count)) - 1) { + state.selected_row += 1; + result.selection_changed = true; + state.ensureVisible(visible_rows); + } + }, + .left => { + if (state.selected_col > 0) { + state.selected_col -= 1; + result.selection_changed = true; + } + }, + .right => { + if (state.selected_col < @as(i32, @intCast(col_count)) - 1) { + state.selected_col += 1; + result.selection_changed = true; + } + }, + .home => { + if (ctx.input.modifiers.ctrl) { + // Ctrl+Home: go to first row + state.selected_row = 0; + state.scroll_row = 0; + } else { + // Home: go to first column + state.selected_col = 0; + } + result.selection_changed = true; + }, + .end => { + if (ctx.input.modifiers.ctrl) { + // Ctrl+End: go to last row + state.selected_row = @as(i32, @intCast(state.row_count)) - 1; + state.ensureVisible(visible_rows); + } else { + // End: go to last column + state.selected_col = @as(i32, @intCast(col_count)) - 1; + } + result.selection_changed = true; + }, + .page_up => { + const jump = @as(i32, @intCast(visible_rows)); + state.selected_row = @max(0, state.selected_row - jump); + state.ensureVisible(visible_rows); + result.selection_changed = true; + }, + .page_down => { + const jump = @as(i32, @intCast(visible_rows)); + const max_row = @as(i32, @intCast(state.row_count)) - 1; + state.selected_row = @min(max_row, state.selected_row + jump); + state.ensureVisible(visible_rows); + result.selection_changed = true; + }, + .tab => { + // Tab: next cell, Shift+Tab: previous cell + if (ctx.input.modifiers.shift) { + if (state.selected_col > 0) { + state.selected_col -= 1; + } else if (state.selected_row > 0) { + state.selected_row -= 1; + state.selected_col = @as(i32, @intCast(col_count)) - 1; + } + } else { + if (state.selected_col < @as(i32, @intCast(col_count)) - 1) { + state.selected_col += 1; + } else if (state.selected_row < @as(i32, @intCast(state.row_count)) - 1) { + state.selected_row += 1; + state.selected_col = 0; + } + } + state.ensureVisible(visible_rows); + result.selection_changed = true; + }, + .enter => { + // Enter: start editing if not editing + if (!state.editing and config.allow_edit) { + if (state.selectedCell()) |cell| { + const current_text = get_cell(cell.row, cell.col); + state.startEditing(current_text); + result.edit_started = true; + } + } + }, + .escape => { + // Escape: cancel editing + if (state.editing) { + state.stopEditing(); + result.edit_ended = true; + } + }, + else => {}, + } + } + + // F2 also starts editing + if (ctx.input.keyPressed(.f2) and !state.editing and config.allow_edit) { + if (state.selectedCell()) |cell| { + const current_text = get_cell(cell.row, cell.col); + state.startEditing(current_text); + result.edit_started = true; + } + } + + // Handle edit commit for Enter during editing + if (state.editing and ctx.input.keyPressed(.enter)) { + if (state.selectedCell()) |cell| { + const edit_text = state.getEditText(); + + // Validate before commit if validator provided + var should_commit = true; + if (validate) |validate_fn| { + const validation = validate_fn(cell.row, cell.col, edit_text); + if (!validation.valid) { + // Don't commit, mark error + state.addCellError(cell.row, cell.col, validation.message); + result.validation_failed = true; + result.validation_message = validation.message; + should_commit = false; + } else { + // Clear any previous error on this cell + state.clearCellError(cell.row, cell.col); + } + } + + if (should_commit) { + if (on_edit) |edit_fn| { + edit_fn(cell.row, cell.col, edit_text); + } + state.stopEditing(); + result.cell_edited = true; + result.edit_ended = true; + } + } + } + + // Row operations (only when not editing) + if (!state.editing and config.allow_row_operations) { + // Ctrl+N: Insert new row + if (ctx.input.keyPressed(.n) and ctx.input.modifiers.ctrl) { + result.row_added = true; + // Insert after current row, or append if no selection + if (state.selected_row >= 0) { + result.insert_at = state.selected_row + 1; + } else { + result.insert_at = -1; // Append + } + } + + // Delete: Delete selected row(s) + if (ctx.input.keyPressed(.delete)) { + const count = state.getSelectedRows(&result.delete_rows); + if (count > 0) { + result.row_deleted = true; + result.delete_count = count; + } else if (state.selected_row >= 0) { + // Single row delete (from selected_row) + result.row_deleted = true; + result.delete_rows[0] = @intCast(state.selected_row); + result.delete_count = 1; + } + } + + // Ctrl+A: Select all rows + if (ctx.input.keyPressed(.a) and ctx.input.modifiers.ctrl and config.allow_multi_select) { + state.selectAllRows(); + result.select_all = true; + result.selection_changed = true; + } + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +fn testGetCell(row: usize, col: usize) []const u8 { + _ = row; + _ = col; + return "test"; +} + +test "TableState init" { + var state = TableState.init(); + try std.testing.expect(state.selectedCell() == null); + + state.selectCell(2, 3); + const sel = state.selectedCell().?; + try std.testing.expectEqual(@as(usize, 2), sel.row); + try std.testing.expectEqual(@as(usize, 3), sel.col); +} + +test "TableState navigation" { + var state = TableState.init(); + state.setRowCount(10); + state.selectCell(5, 2); + + state.moveUp(); + try std.testing.expectEqual(@as(i32, 4), state.selected_row); + + state.moveDown(); + try std.testing.expectEqual(@as(i32, 5), state.selected_row); + + state.moveToFirst(); + try std.testing.expectEqual(@as(i32, 0), state.selected_row); + + state.moveToLast(); + try std.testing.expectEqual(@as(i32, 9), state.selected_row); +} + +test "TableState row states" { + var state = TableState.init(); + state.setRowCount(5); + + try std.testing.expectEqual(RowState.clean, state.getRowState(0)); + + state.markNew(0); + try std.testing.expectEqual(RowState.new, state.getRowState(0)); + + state.markModified(1); + try std.testing.expectEqual(RowState.modified, state.getRowState(1)); + + state.markDeleted(2); + try std.testing.expectEqual(RowState.deleted, state.getRowState(2)); +} + +test "TableState editing" { + var state = TableState.init(); + + try std.testing.expect(!state.editing); + + state.startEditing("initial"); + try std.testing.expect(state.editing); + try std.testing.expectEqualStrings("initial", state.getEditText()); + + state.stopEditing(); + try std.testing.expect(!state.editing); +} + +test "table generates commands" { + var ctx = try Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + var state = TableState.init(); + state.setRowCount(5); + + const columns = [_]Column{ + .{ .name = "Name", .width = 150 }, + .{ .name = "Value", .width = 100 }, + }; + + ctx.beginFrame(); + ctx.layout.row_height = 200; + + _ = table(&ctx, &state, &columns, testGetCell); + + // Should generate many commands (background, headers, rows, etc.) + try std.testing.expect(ctx.commands.items.len > 10); + + ctx.endFrame(); +} + +test "TableState sorting" { + var state = TableState.init(); + + // Initially no sort + try std.testing.expect(state.getSortInfo() == null); + + // Toggle sort on column 0 -> ascending + const dir1 = state.toggleSort(0); + try std.testing.expectEqual(SortDirection.ascending, dir1); + try std.testing.expectEqual(@as(i32, 0), state.sort_column); + try std.testing.expectEqual(SortDirection.ascending, state.sort_direction); + + // Toggle again -> descending + const dir2 = state.toggleSort(0); + try std.testing.expectEqual(SortDirection.descending, dir2); + + // Toggle again -> none (clear) + const dir3 = state.toggleSort(0); + try std.testing.expectEqual(SortDirection.none, dir3); + try std.testing.expectEqual(@as(i32, -1), state.sort_column); + + // Sort different column + _ = state.toggleSort(2); + try std.testing.expectEqual(@as(i32, 2), state.sort_column); + try std.testing.expectEqual(SortDirection.ascending, state.sort_direction); + + // Get sort info + const info = state.getSortInfo().?; + try std.testing.expectEqual(@as(usize, 2), info.column); + try std.testing.expectEqual(SortDirection.ascending, info.direction); + + // Clear sort + state.clearSort(); + try std.testing.expect(state.getSortInfo() == null); +} + +test "SortDirection toggle" { + try std.testing.expectEqual(SortDirection.ascending, SortDirection.none.toggle()); + try std.testing.expectEqual(SortDirection.descending, SortDirection.ascending.toggle()); + try std.testing.expectEqual(SortDirection.none, SortDirection.descending.toggle()); +} + +test "TableState multi-row selection" { + var state = TableState.init(); + state.setRowCount(10); + + // Initially no selection + try std.testing.expect(!state.isRowSelected(0)); + try std.testing.expectEqual(@as(usize, 0), state.getSelectedRowCount()); + + // Select single row + state.selectSingleRow(3); + try std.testing.expect(state.isRowSelected(3)); + try std.testing.expectEqual(@as(usize, 1), state.getSelectedRowCount()); + + // Add more rows to selection + state.addRowToSelection(5); + state.addRowToSelection(7); + try std.testing.expect(state.isRowSelected(3)); + try std.testing.expect(state.isRowSelected(5)); + try std.testing.expect(state.isRowSelected(7)); + try std.testing.expectEqual(@as(usize, 3), state.getSelectedRowCount()); + + // Toggle selection + state.toggleRowSelection(5); // Remove + try std.testing.expect(!state.isRowSelected(5)); + state.toggleRowSelection(5); // Add back + try std.testing.expect(state.isRowSelected(5)); + + // Remove from selection + state.removeRowFromSelection(7); + try std.testing.expect(!state.isRowSelected(7)); + + // Get selected rows + var buffer: [10]usize = undefined; + const count = state.getSelectedRows(&buffer); + try std.testing.expectEqual(@as(usize, 2), count); + + // Clear selection + state.clearRowSelection(); + try std.testing.expectEqual(@as(usize, 0), state.getSelectedRowCount()); + + // Select all + state.selectAllRows(); + try std.testing.expectEqual(@as(usize, 10), state.getSelectedRowCount()); + + // Select range + state.clearRowSelection(); + state.selectRowRange(2, 5); + try std.testing.expect(!state.isRowSelected(1)); + try std.testing.expect(state.isRowSelected(2)); + try std.testing.expect(state.isRowSelected(3)); + try std.testing.expect(state.isRowSelected(4)); + try std.testing.expect(state.isRowSelected(5)); + try std.testing.expect(!state.isRowSelected(6)); +} + +test "TableState validation" { + var state = TableState.init(); + state.setRowCount(5); + + // Initially no errors + try std.testing.expect(!state.hasAnyErrors()); + try std.testing.expect(!state.hasCellError(0, 0)); + + // Add error + state.addCellError(0, 0, "Required field"); + try std.testing.expect(state.hasAnyErrors()); + try std.testing.expect(state.hasCellError(0, 0)); + try std.testing.expectEqual(@as(usize, 14), state.last_validation_message_len); + + // Add another error + state.addCellError(1, 2, "Invalid number"); + try std.testing.expect(state.hasCellError(1, 2)); + try std.testing.expectEqual(@as(usize, 2), state.validation_error_count); + + // Clear specific error + state.clearCellError(0, 0); + try std.testing.expect(!state.hasCellError(0, 0)); + try std.testing.expect(state.hasCellError(1, 2)); + try std.testing.expectEqual(@as(usize, 1), state.validation_error_count); + + // Clear all errors + state.clearAllErrors(); + try std.testing.expect(!state.hasAnyErrors()); +} 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)); +}