From 50a6d3ca603ebdf8db142bdb8bc961781f8c5121 Mon Sep 17 00:00:00 2001 From: reugenio Date: Mon, 29 Dec 2025 11:33:20 +0100 Subject: [PATCH] chore: Eliminar archivos residuales textarea.zig y progress.zig Estos archivos quedaron fuera de sus carpetas modulares. Ya se importan desde textarea/textarea.zig y progress/progress.zig. --- src/widgets/progress.zig | 777 ---------------------------------- src/widgets/textarea.zig | 871 --------------------------------------- 2 files changed, 1648 deletions(-) delete mode 100644 src/widgets/progress.zig delete mode 100644 src/widgets/textarea.zig diff --git a/src/widgets/progress.zig b/src/widgets/progress.zig deleted file mode 100644 index e07ac17..0000000 --- a/src/widgets/progress.zig +++ /dev/null @@ -1,777 +0,0 @@ -//! 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; - - // Use proper gradient command for smooth color transition - const start_color = base_color.lighten(25); - const end_color = base_color.darken(15); - - if (vertical) { - ctx.pushCommand(Command.gradientV(bounds.x, bounds.y, bounds.w, bounds.h, start_color, end_color)); - } else { - ctx.pushCommand(Command.gradientH(bounds.x, bounds.y, bounds.w, bounds.h, start_color, end_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/textarea.zig b/src/widgets/textarea.zig deleted file mode 100644 index 8bb82cd..0000000 --- a/src/widgets/textarea.zig +++ /dev/null @@ -1,871 +0,0 @@ -//! 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)); -}