feat: Añadir archivos nuevos de widgets y focus

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 <noreply@anthropic.com>
This commit is contained in:
reugenio 2025-12-12 22:55:41 +01:00
parent aa3d0edcff
commit 1ee10b8e17
5 changed files with 3957 additions and 0 deletions

416
src/core/focus_group.zig Normal file
View file

@ -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);
}

272
src/widgets/focus.zig Normal file
View file

@ -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());
}

806
src/widgets/progress.zig Normal file
View file

@ -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();
}

1592
src/widgets/table.zig Normal file

File diff suppressed because it is too large Load diff

871
src/widgets/textarea.zig Normal file
View file

@ -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));
}