From 59935aeb2b4693839dad9984c9b8867bb0f6f43c Mon Sep 17 00:00:00 2001 From: reugenio Date: Thu, 11 Dec 2025 23:21:06 +0100 Subject: [PATCH] refactor: Split textarea.zig and progress.zig into modular structures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split large widget files for better maintainability (~500-600 lines per file): textarea/ (was 882 lines): - types.zig: TextAreaConfig, TextAreaColors, TextAreaResult - state.zig: TextAreaState with cursor/selection methods - render.zig: drawLineNumber, drawLineText, drawLineSelection - textarea.zig: Main API with re-exports and tests progress/ (was 806 lines): - render.zig: Shared drawing helpers (stripes, gradients, arcs) - bar.zig: ProgressBar widget - circle.zig: ProgressCircle widget - spinner.zig: Spinner widget with animation state - progress.zig: Main API with re-exports and tests 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/widgets/progress.zig | 806 --------------------------- src/widgets/progress/bar.zig | 183 +++++++ src/widgets/progress/circle.zig | 114 ++++ src/widgets/progress/progress.zig | 151 +++++ src/widgets/progress/render.zig | 302 ++++++++++ src/widgets/progress/spinner.zig | 141 +++++ src/widgets/textarea.zig | 882 ------------------------------ src/widgets/textarea/render.zig | 85 +++ src/widgets/textarea/state.zig | 380 +++++++++++++ src/widgets/textarea/textarea.zig | 396 ++++++++++++++ src/widgets/textarea/types.zig | 59 ++ src/widgets/widgets.zig | 4 +- 12 files changed, 1813 insertions(+), 1690 deletions(-) delete mode 100644 src/widgets/progress.zig create mode 100644 src/widgets/progress/bar.zig create mode 100644 src/widgets/progress/circle.zig create mode 100644 src/widgets/progress/progress.zig create mode 100644 src/widgets/progress/render.zig create mode 100644 src/widgets/progress/spinner.zig delete mode 100644 src/widgets/textarea.zig create mode 100644 src/widgets/textarea/render.zig create mode 100644 src/widgets/textarea/state.zig create mode 100644 src/widgets/textarea/textarea.zig create mode 100644 src/widgets/textarea/types.zig diff --git a/src/widgets/progress.zig b/src/widgets/progress.zig deleted file mode 100644 index 4e3691a..0000000 --- a/src/widgets/progress.zig +++ /dev/null @@ -1,806 +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; - - // Simple gradient approximation with 4 bands - const bands: u32 = 4; - const steps = if (vertical) bounds.h / bands else bounds.w / bands; - - var i: u32 = 0; - while (i < bands) : (i += 1) { - const t: f32 = @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(bands)); - const brightness: u8 = @intFromFloat(t * 40); - - const band_color = Color.rgba( - base_color.r -| brightness, - base_color.g -| brightness, - base_color.b -| brightness, - base_color.a, - ); - - if (vertical) { - ctx.pushCommand(.{ - .rect = .{ - .x = bounds.x, - .y = bounds.y + @as(i32, @intCast(i * steps)), - .w = bounds.w, - .h = steps, - .color = band_color, - }, - }); - } else { - ctx.pushCommand(.{ - .rect = .{ - .x = bounds.x + @as(i32, @intCast(i * steps)), - .y = bounds.y, - .w = steps, - .h = bounds.h, - .color = band_color, - }, - }); - } - } -} - -fn drawSegmentedFill(ctx: *Context, bounds: Rect, fill_color: Color, progress: f32, segments: u8, vertical: bool) void { - const seg_count: u32 = segments; - const gap: u32 = 2; - - if (vertical) { - const seg_height = (bounds.h - (seg_count - 1) * gap) / seg_count; - const filled_segs: u32 = @intFromFloat(@as(f32, @floatFromInt(seg_count)) * progress); - - var i: u32 = 0; - while (i < seg_count) : (i += 1) { - const seg_y = bounds.y + @as(i32, @intCast(bounds.h)) - @as(i32, @intCast((i + 1) * (seg_height + gap))); - const color = if (i < filled_segs) fill_color else Color.rgba(fill_color.r / 4, fill_color.g / 4, fill_color.b / 4, fill_color.a); - - ctx.pushCommand(.{ - .rect = .{ - .x = bounds.x, - .y = seg_y, - .w = bounds.w, - .h = seg_height, - .color = color, - }, - }); - } - } else { - const seg_width = (bounds.w - (seg_count - 1) * gap) / seg_count; - const filled_segs: u32 = @intFromFloat(@as(f32, @floatFromInt(seg_count)) * progress); - - var i: u32 = 0; - while (i < seg_count) : (i += 1) { - const seg_x = bounds.x + @as(i32, @intCast(i * (seg_width + gap))); - const color = if (i < filled_segs) fill_color else Color.rgba(fill_color.r / 4, fill_color.g / 4, fill_color.b / 4, fill_color.a); - - ctx.pushCommand(.{ - .rect = .{ - .x = seg_x, - .y = bounds.y, - .w = seg_width, - .h = bounds.h, - .color = color, - }, - }); - } - } -} - -fn drawCircleOutline(ctx: *Context, cx: i32, cy: i32, radius: i32, stroke: u8, color: Color) void { - // Approximate circle with octagon for simplicity in software rendering - const r = radius; - const s: i32 = @intCast(stroke); - - // Draw 8 segments around the circle - const offsets = [_][2]i32{ - .{ 0, -r }, // top - .{ r, 0 }, // right - .{ 0, r }, // bottom - .{ -r, 0 }, // left - }; - - for (offsets) |off| { - ctx.pushCommand(.{ - .rect = .{ - .x = cx + off[0] - @divTrunc(s, 2), - .y = cy + off[1] - @divTrunc(s, 2), - .w = @intCast(s), - .h = @intCast(s), - .color = color, - }, - }); - } -} - -fn drawProgressArc(ctx: *Context, cx: i32, cy: i32, radius: i32, stroke: u8, color: Color, progress: f32, start_angle: f32) void { - _ = start_angle; - - // Simplified arc drawing - draw filled segments - const segments: u32 = 16; - const filled: u32 = @intFromFloat(@as(f32, @floatFromInt(segments)) * progress); - - var i: u32 = 0; - while (i < filled) : (i += 1) { - const angle: f32 = (@as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(segments))) * 2.0 * std.math.pi - std.math.pi / 2.0; - const px: i32 = cx + @as(i32, @intFromFloat(@cos(angle) * @as(f32, @floatFromInt(radius)))); - const py: i32 = cy + @as(i32, @intFromFloat(@sin(angle) * @as(f32, @floatFromInt(radius)))); - - ctx.pushCommand(.{ - .rect = .{ - .x = px - @divTrunc(@as(i32, stroke), 2), - .y = py - @divTrunc(@as(i32, stroke), 2), - .w = stroke, - .h = stroke, - .color = color, - }, - }); - } -} - -fn drawRotatingArc(ctx: *Context, cx: i32, cy: i32, radius: i32, color: Color, animation: f32) void { - const segments: u32 = 8; - const arc_length: u32 = 5; // Number of segments in the arc - - const start_seg: u32 = @intFromFloat(animation * @as(f32, @floatFromInt(segments))); - - var i: u32 = 0; - while (i < arc_length) : (i += 1) { - const seg = (start_seg + i) % segments; - const angle: f32 = (@as(f32, @floatFromInt(seg)) / @as(f32, @floatFromInt(segments))) * 2.0 * std.math.pi - std.math.pi / 2.0; - const px: i32 = cx + @as(i32, @intFromFloat(@cos(angle) * @as(f32, @floatFromInt(radius)))); - const py: i32 = cy + @as(i32, @intFromFloat(@sin(angle) * @as(f32, @floatFromInt(radius)))); - - // Fade based on position in arc - const alpha: u8 = @intFromFloat((@as(f32, @floatFromInt(arc_length - i)) / @as(f32, @floatFromInt(arc_length))) * 255); - const faded = Color.rgba(color.r, color.g, color.b, alpha); - - ctx.pushCommand(.{ - .rect = .{ - .x = px - 2, - .y = py - 2, - .w = 4, - .h = 4, - .color = faded, - }, - }); - } -} - -fn drawPulsingDots(ctx: *Context, cx: i32, cy: i32, size: u16, color: Color, count: u8, animation: f32) void { - const radius: f32 = @as(f32, @floatFromInt(size)) / 3.0; - - var i: u8 = 0; - while (i < count) : (i += 1) { - const angle: f32 = (@as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(count))) * 2.0 * std.math.pi - std.math.pi / 2.0; - const px: i32 = cx + @as(i32, @intFromFloat(@cos(angle) * radius)); - const py: i32 = cy + @as(i32, @intFromFloat(@sin(angle) * radius)); - - // Pulse based on animation and position - const phase = animation + @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(count)); - const scale: f32 = 0.5 + 0.5 * @sin(phase * 2.0 * std.math.pi); - const dot_size: u32 = @intFromFloat(2.0 + scale * 3.0); - - ctx.pushCommand(.{ - .rect = .{ - .x = px - @as(i32, @intCast(dot_size / 2)), - .y = py - @as(i32, @intCast(dot_size / 2)), - .w = dot_size, - .h = dot_size, - .color = color, - }, - }); - } -} - -fn drawBouncingBars(ctx: *Context, bounds: Rect, color: Color, count: u8, animation: f32) void { - const bar_width = bounds.w / @as(u32, count); - const max_height = bounds.h; - - var i: u8 = 0; - while (i < count) : (i += 1) { - // Each bar bounces with phase offset - const phase = animation + @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(count)); - const bounce: f32 = @abs(@sin(phase * 2.0 * std.math.pi)); - const bar_height: u32 = @intFromFloat(@as(f32, @floatFromInt(max_height)) * (0.3 + 0.7 * bounce)); - - const bar_x = bounds.x + @as(i32, @intCast(@as(u32, i) * bar_width)); - const bar_y = bounds.y + @as(i32, @intCast(max_height - bar_height)); - - ctx.pushCommand(.{ - .rect = .{ - .x = bar_x + 1, - .y = bar_y, - .w = bar_width -| 2, - .h = bar_height, - .color = color, - }, - }); - } -} - -fn drawRingWithGap(ctx: *Context, cx: i32, cy: i32, radius: i32, color: Color, animation: f32) void { - const segments: u32 = 12; - const gap_size: u32 = 3; // Number of segments for the gap - const gap_start: u32 = @intFromFloat(animation * @as(f32, @floatFromInt(segments))); - - var i: u32 = 0; - while (i < segments) : (i += 1) { - // Skip gap segments - const distance_from_gap = @min((i + segments - gap_start) % segments, (gap_start + segments - i) % segments); - if (distance_from_gap < gap_size / 2) continue; - - const angle: f32 = (@as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(segments))) * 2.0 * std.math.pi - std.math.pi / 2.0; - const px: i32 = cx + @as(i32, @intFromFloat(@cos(angle) * @as(f32, @floatFromInt(radius)))); - const py: i32 = cy + @as(i32, @intFromFloat(@sin(angle) * @as(f32, @floatFromInt(radius)))); - - ctx.pushCommand(.{ - .rect = .{ - .x = px - 2, - .y = py - 2, - .w = 4, - .h = 4, - .color = color, - }, - }); - } -} - -// ============================================================================= -// Tests -// ============================================================================= - -test "ProgressBar basic" { - var ctx = try Context.init(std.testing.allocator, 800, 600); - defer ctx.deinit(); - - ctx.beginFrame(); - - const result = bar(&ctx, 0.5); - try std.testing.expectEqual(@as(f32, 0.5), result.progress); - try std.testing.expect(ctx.commands.items.len > 0); - - ctx.endFrame(); -} - -test "ProgressBar clamping" { - var ctx = try Context.init(std.testing.allocator, 800, 600); - defer ctx.deinit(); - - ctx.beginFrame(); - - // Test value clamping - const result1 = bar(&ctx, -0.5); - try std.testing.expectEqual(@as(f32, 0.0), result1.progress); - - const result2 = bar(&ctx, 1.5); - try std.testing.expectEqual(@as(f32, 1.0), result2.progress); - - ctx.endFrame(); -} - -test "ProgressBar styles" { - var ctx = try Context.init(std.testing.allocator, 800, 600); - defer ctx.deinit(); - - ctx.beginFrame(); - - // Test different styles - _ = barEx(&ctx, 0.75, .{ .style = .solid }); - _ = barEx(&ctx, 0.75, .{ .style = .striped }); - _ = barEx(&ctx, 0.75, .{ .style = .gradient }); - _ = barEx(&ctx, 0.75, .{ .style = .segmented, .segments = 5 }); - - try std.testing.expect(ctx.commands.items.len > 0); - - ctx.endFrame(); -} - -test "ProgressCircle basic" { - var ctx = try Context.init(std.testing.allocator, 800, 600); - defer ctx.deinit(); - - ctx.beginFrame(); - - const result = circle(&ctx, 0.75); - try std.testing.expectEqual(@as(f32, 0.75), result.progress); - - ctx.endFrame(); -} - -test "Spinner state" { - var state = SpinnerState{}; - - // Initial state - try std.testing.expectEqual(@as(f32, 0), state.animation); - - // Update advances animation - state.update(1.0); - state.last_update -= 100; // Simulate 100ms passed - state.update(1.0); - - try std.testing.expect(state.animation >= 0); -} - -test "Spinner basic" { - var ctx = try Context.init(std.testing.allocator, 800, 600); - defer ctx.deinit(); - - ctx.beginFrame(); - - var state = SpinnerState{}; - const result = spinner(&ctx, &state); - try std.testing.expect(result.bounds.w > 0); - - ctx.endFrame(); -} diff --git a/src/widgets/progress/bar.zig b/src/widgets/progress/bar.zig new file mode 100644 index 0000000..c34b748 --- /dev/null +++ b/src/widgets/progress/bar.zig @@ -0,0 +1,183 @@ +//! ProgressBar Widget - Horizontal/vertical progress bar +//! +//! Part of the progress widget module. + +const std = @import("std"); +const Context = @import("../../core/context.zig").Context; +const Layout = @import("../../core/layout.zig"); +const Style = @import("../../core/style.zig"); +const Rect = Layout.Rect; +const Color = Style.Color; + +const render = @import("render.zig"); + +// ============================================================================= +// Types +// ============================================================================= + +/// 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, +}; + +// ============================================================================= +// Public API +// ============================================================================= + +/// 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 => { + render.drawStripedFill(ctx, fill_bounds, fill_color, config.animated); + }, + .gradient => { + render.drawGradientFill(ctx, fill_bounds, fill_color, config.vertical); + }, + .segmented => { + render.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; +} diff --git a/src/widgets/progress/circle.zig b/src/widgets/progress/circle.zig new file mode 100644 index 0000000..2b2049c --- /dev/null +++ b/src/widgets/progress/circle.zig @@ -0,0 +1,114 @@ +//! ProgressCircle Widget - Circular progress indicator +//! +//! Part of the progress widget module. + +const std = @import("std"); +const Context = @import("../../core/context.zig").Context; +const Layout = @import("../../core/layout.zig"); +const Style = @import("../../core/style.zig"); +const Rect = Layout.Rect; +const Color = Style.Color; + +const render = @import("render.zig"); + +// ============================================================================= +// Types +// ============================================================================= + +/// 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, +}; + +// ============================================================================= +// Public API +// ============================================================================= + +/// 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) + render.drawCircleOutline(ctx, center_x, center_y, radius, config.stroke_width, track_color); + + // Draw progress arc + if (progress > 0) { + render.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, + }; +} diff --git a/src/widgets/progress/progress.zig b/src/widgets/progress/progress.zig new file mode 100644 index 0000000..49fdcd4 --- /dev/null +++ b/src/widgets/progress/progress.zig @@ -0,0 +1,151 @@ +//! 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 }); +//! ``` +//! +//! This module re-exports types from the progress/ subdirectory. + +const std = @import("std"); +const Context = @import("../../core/context.zig").Context; + +// ============================================================================= +// Re-exports: Bar +// ============================================================================= + +pub const bar_module = @import("bar.zig"); +pub const BarStyle = bar_module.BarStyle; +pub const BarConfig = bar_module.BarConfig; +pub const BarResult = bar_module.BarResult; +pub const bar = bar_module.bar; +pub const barEx = bar_module.barEx; +pub const barRect = bar_module.barRect; + +// ============================================================================= +// Re-exports: Circle +// ============================================================================= + +pub const circle_module = @import("circle.zig"); +pub const CircleConfig = circle_module.CircleConfig; +pub const CircleResult = circle_module.CircleResult; +pub const circle = circle_module.circle; +pub const circleEx = circle_module.circleEx; + +// ============================================================================= +// Re-exports: Spinner +// ============================================================================= + +pub const spinner_module = @import("spinner.zig"); +pub const SpinnerStyle = spinner_module.SpinnerStyle; +pub const SpinnerConfig = spinner_module.SpinnerConfig; +pub const SpinnerState = spinner_module.SpinnerState; +pub const SpinnerResult = spinner_module.SpinnerResult; +pub const spinner = spinner_module.spinner; +pub const spinnerEx = spinner_module.spinnerEx; + +// ============================================================================= +// 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/progress/render.zig b/src/widgets/progress/render.zig new file mode 100644 index 0000000..22fa9d1 --- /dev/null +++ b/src/widgets/progress/render.zig @@ -0,0 +1,302 @@ +//! Progress Render - Drawing helper functions +//! +//! Shared drawing functions for progress widgets. + +const std = @import("std"); +const Context = @import("../../core/context.zig").Context; +const Layout = @import("../../core/layout.zig"); +const Style = @import("../../core/style.zig"); +const Rect = Layout.Rect; +const Color = Style.Color; + +pub 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; + } +} + +pub 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, + }, + }); + } + } +} + +pub 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, + }, + }); + } + } +} + +pub 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, + }, + }); + } +} + +pub 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, + }, + }); + } +} + +pub 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, + }, + }); + } +} + +pub 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, + }, + }); + } +} + +pub 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, + }, + }); + } +} + +pub 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, + }, + }); + } +} diff --git a/src/widgets/progress/spinner.zig b/src/widgets/progress/spinner.zig new file mode 100644 index 0000000..4104c69 --- /dev/null +++ b/src/widgets/progress/spinner.zig @@ -0,0 +1,141 @@ +//! Spinner Widget - Animated loading indicator +//! +//! Part of the progress widget module. + +const std = @import("std"); +const Context = @import("../../core/context.zig").Context; +const Layout = @import("../../core/layout.zig"); +const Style = @import("../../core/style.zig"); +const Rect = Layout.Rect; +const Color = Style.Color; + +const render = @import("render.zig"); + +// ============================================================================= +// Types +// ============================================================================= + +/// 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, +}; + +// ============================================================================= +// Public API +// ============================================================================= + +/// 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 => { + render.drawRotatingArc(ctx, center_x, center_y, config.size / 2 - 2, color, state.animation); + }, + .dots => { + render.drawPulsingDots(ctx, center_x, center_y, config.size, color, config.elements, state.animation); + }, + .bars => { + render.drawBouncingBars(ctx, bounds, color, config.elements, state.animation); + }, + .ring => { + render.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, + }; +} diff --git a/src/widgets/textarea.zig b/src/widgets/textarea.zig deleted file mode 100644 index eea0f3b..0000000 --- a/src/widgets/textarea.zig +++ /dev/null @@ -1,882 +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; - - // Generate unique ID for this widget based on buffer memory address - const widget_id: u64 = @intFromPtr(state.buffer.ptr); - - // Register as focusable in the active focus group - ctx.registerFocusable(widget_id); - - // 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) { - // Request focus through the focus system - ctx.requestFocus(widget_id); - result.clicked = true; - } - - // Check if this widget has focus - const has_focus = ctx.hasFocus(widget_id); - state.focused = has_focus; - - // Get colors - const bg_color = if (has_focus) colors.background.lighten(5) else colors.background; - const border_color = if (has_focus) colors.border_focused else colors.border; - - // Draw background - ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, bg_color)); - - // Draw border - ctx.pushCommand(Command.rectOutline(bounds.x, bounds.y, bounds.w, bounds.h, border_color)); - - // Calculate dimensions - const char_width: u32 = 8; - const char_height: u32 = 8; - const line_height: u32 = char_height + 2; - - // Line numbers width - const line_num_width: u32 = if (config.line_numbers) - @as(u32, @intCast(countDigits(state.lineCount()))) * char_width + 8 - else - 0; - - // Inner area for text - var text_area = bounds.shrink(config.padding); - if (text_area.isEmpty()) return result; - - // Draw line numbers gutter - if (config.line_numbers and line_num_width > 0) { - ctx.pushCommand(Command.rect( - text_area.x, - text_area.y, - line_num_width, - text_area.h, - colors.line_numbers_bg, - )); - // Adjust text area to exclude gutter - text_area = Layout.Rect.init( - text_area.x + @as(i32, @intCast(line_num_width)), - text_area.y, - text_area.w -| line_num_width, - text_area.h, - ); - } - - if (text_area.isEmpty()) return result; - - // Calculate visible area - const visible_lines = text_area.h / line_height; - const visible_cols = text_area.w / char_width; - - // Handle keyboard input if focused - if (state.focused and !config.readonly) { - const text_in = ctx.input.getTextInput(); - if (text_in.len > 0) { - // Check for tab - for (text_in) |c| { - if (c == '\t') { - // Insert spaces for tab - var spaces: [8]u8 = undefined; - const count = @min(config.tab_size, 8); - @memset(spaces[0..count], ' '); - state.insert(spaces[0..count]); - } else { - state.insert(&[_]u8{c}); - } - } - result.changed = true; - } - } - - // Ensure cursor is visible - state.ensureCursorVisible(visible_lines, visible_cols); - - // Get cursor position - const cursor_pos = state.getCursorPosition(); - result.cursor_line = cursor_pos.line; - result.cursor_col = cursor_pos.col; - - // Draw text line by line - const txt = state.text(); - var line_num: usize = 0; - var line_start: usize = 0; - - for (txt, 0..) |c, i| { - if (c == '\n') { - if (line_num >= state.scroll_y and line_num < state.scroll_y + visible_lines) { - const draw_line = line_num - state.scroll_y; - const y = text_area.y + @as(i32, @intCast(draw_line * line_height)); - - // Draw line number - if (config.line_numbers) { - drawLineNumber( - ctx, - bounds.x + @as(i32, @intCast(config.padding)), - y, - line_num + 1, - colors.line_numbers_fg, - ); - } - - // Draw line text - const line_text = txt[line_start..i]; - drawLineText(ctx, text_area.x, y, line_text, state.scroll_x, visible_cols, colors.text); - - // Draw selection on this line - if (state.selection_start != null) { - drawLineSelection( - ctx, - text_area.x, - y, - line_start, - i, - state.cursor, - state.selection_start.?, - state.scroll_x, - visible_cols, - char_width, - line_height, - colors.selection, - ); - } - - // Draw cursor if on this line - if (state.focused and cursor_pos.line == line_num) { - const cursor_x_pos = cursor_pos.col -| state.scroll_x; - if (cursor_x_pos < visible_cols) { - const cursor_x = text_area.x + @as(i32, @intCast(cursor_x_pos * char_width)); - ctx.pushCommand(Command.rect(cursor_x, y, 2, line_height, colors.cursor)); - } - } - } - - line_num += 1; - line_start = i + 1; - } - } - - // Handle last line (no trailing newline) - if (line_start <= txt.len and line_num >= state.scroll_y and line_num < state.scroll_y + visible_lines) { - const draw_line = line_num - state.scroll_y; - const y = text_area.y + @as(i32, @intCast(draw_line * line_height)); - - // Draw line number - if (config.line_numbers) { - drawLineNumber( - ctx, - bounds.x + @as(i32, @intCast(config.padding)), - y, - line_num + 1, - colors.line_numbers_fg, - ); - } - - // Draw line text - const line_text = if (line_start < txt.len) txt[line_start..] else ""; - drawLineText(ctx, text_area.x, y, line_text, state.scroll_x, visible_cols, colors.text); - - // Draw selection on this line - if (state.selection_start != null) { - drawLineSelection( - ctx, - text_area.x, - y, - line_start, - txt.len, - state.cursor, - state.selection_start.?, - state.scroll_x, - visible_cols, - char_width, - line_height, - colors.selection, - ); - } - - // Draw cursor if on this line - if (state.focused and cursor_pos.line == line_num) { - const cursor_x_pos = cursor_pos.col -| state.scroll_x; - if (cursor_x_pos < visible_cols) { - const cursor_x = text_area.x + @as(i32, @intCast(cursor_x_pos * char_width)); - ctx.pushCommand(Command.rect(cursor_x, y, 2, line_height, colors.cursor)); - } - } - } - - // Draw placeholder if empty - if (state.len == 0 and config.placeholder.len > 0) { - const y = text_area.y; - ctx.pushCommand(Command.text(text_area.x, y, config.placeholder, colors.placeholder)); - } - - return result; -} - -/// Draw a line number -fn drawLineNumber(ctx: *Context, x: i32, y: i32, num: usize, color: Style.Color) void { - var buf: [16]u8 = undefined; - const written = std.fmt.bufPrint(&buf, "{d}", .{num}) catch return; - ctx.pushCommand(Command.text(x, y, written, color)); -} - -/// Draw line text with horizontal scroll -fn drawLineText( - ctx: *Context, - x: i32, - y: i32, - line: []const u8, - scroll_x: usize, - visible_cols: usize, - color: Style.Color, -) void { - if (line.len == 0) return; - - const start = @min(scroll_x, line.len); - const end = @min(scroll_x + visible_cols, line.len); - - if (start >= end) return; - - ctx.pushCommand(Command.text(x, y, line[start..end], color)); -} - -/// Draw selection highlight for a line -fn drawLineSelection( - ctx: *Context, - x: i32, - y: i32, - line_start: usize, - line_end: usize, - cursor: usize, - sel_start: usize, - scroll_x: usize, - visible_cols: usize, - char_width: u32, - line_height: u32, - color: Style.Color, -) void { - const sel_min = @min(cursor, sel_start); - const sel_max = @max(cursor, sel_start); - - // Check if selection overlaps this line - if (sel_max < line_start or sel_min > line_end) return; - - // Calculate selection bounds within line - const sel_line_start = if (sel_min > line_start) sel_min - line_start else 0; - const sel_line_end = @min(sel_max, line_end) - line_start; - - if (sel_line_start >= sel_line_end) return; - - // Apply horizontal scroll - const vis_start = if (sel_line_start > scroll_x) sel_line_start - scroll_x else 0; - const vis_end = if (sel_line_end > scroll_x) @min(sel_line_end - scroll_x, visible_cols) else 0; - - if (vis_start >= vis_end) return; - - const sel_x = x + @as(i32, @intCast(vis_start * char_width)); - const sel_w = @as(u32, @intCast(vis_end - vis_start)) * char_width; - - ctx.pushCommand(Command.rect(sel_x, y, sel_w, line_height, color)); -} - -/// Count digits in a number -fn countDigits(n: usize) usize { - if (n == 0) return 1; - var count: usize = 0; - var num = n; - while (num > 0) : (num /= 10) { - count += 1; - } - return count; -} - -// ============================================================================= -// Tests -// ============================================================================= - -test "TextAreaState insert" { - var buf: [256]u8 = undefined; - var state = TextAreaState.init(&buf); - - state.insert("Hello"); - try std.testing.expectEqualStrings("Hello", state.text()); - try std.testing.expectEqual(@as(usize, 5), state.cursor); - - state.insertNewline(); - state.insert("World"); - try std.testing.expectEqualStrings("Hello\nWorld", state.text()); -} - -test "TextAreaState line count" { - var buf: [256]u8 = undefined; - var state = TextAreaState.init(&buf); - - state.insert("Line 1"); - try std.testing.expectEqual(@as(usize, 1), state.lineCount()); - - state.insertNewline(); - state.insert("Line 2"); - try std.testing.expectEqual(@as(usize, 2), state.lineCount()); - - state.insertNewline(); - state.insertNewline(); - state.insert("Line 4"); - try std.testing.expectEqual(@as(usize, 4), state.lineCount()); -} - -test "TextAreaState cursor position" { - var buf: [256]u8 = undefined; - var state = TextAreaState.init(&buf); - - state.insert("Hello\nWorld\nTest"); - - // Cursor at end - const pos = state.getCursorPosition(); - try std.testing.expectEqual(@as(usize, 2), pos.line); - try std.testing.expectEqual(@as(usize, 4), pos.col); -} - -test "TextAreaState cursor up/down" { - var buf: [256]u8 = undefined; - var state = TextAreaState.init(&buf); - - state.insert("Line 1\nLine 2\nLine 3"); - - // Move up - state.cursorUp(false); - var pos = state.getCursorPosition(); - try std.testing.expectEqual(@as(usize, 1), pos.line); - - state.cursorUp(false); - pos = state.getCursorPosition(); - try std.testing.expectEqual(@as(usize, 0), pos.line); - - // Move down - state.cursorDown(false); - pos = state.getCursorPosition(); - try std.testing.expectEqual(@as(usize, 1), pos.line); -} - -test "TextAreaState home/end" { - var buf: [256]u8 = undefined; - var state = TextAreaState.init(&buf); - - state.insert("Hello World"); - state.cursorHome(false); - - try std.testing.expectEqual(@as(usize, 0), state.cursor); - - state.cursorEnd(false); - try std.testing.expectEqual(@as(usize, 11), state.cursor); -} - -test "TextAreaState selection" { - var buf: [256]u8 = undefined; - var state = TextAreaState.init(&buf); - - state.insert("Hello World"); - state.selectAll(); - - try std.testing.expectEqual(@as(?usize, 0), state.selection_start); - try std.testing.expectEqual(@as(usize, 11), state.cursor); - - state.insert("X"); - try std.testing.expectEqualStrings("X", state.text()); -} - -test "textArea generates commands" { - var ctx = try Context.init(std.testing.allocator, 800, 600); - defer ctx.deinit(); - - var buf: [256]u8 = undefined; - var state = TextAreaState.init(&buf); - - ctx.beginFrame(); - ctx.layout.row_height = 100; - - _ = textArea(&ctx, &state); - - // Should generate: rect (bg) + rect_outline (border) - try std.testing.expect(ctx.commands.items.len >= 2); - - ctx.endFrame(); -} - -test "countDigits" { - try std.testing.expectEqual(@as(usize, 1), countDigits(0)); - try std.testing.expectEqual(@as(usize, 1), countDigits(5)); - try std.testing.expectEqual(@as(usize, 2), countDigits(10)); - try std.testing.expectEqual(@as(usize, 3), countDigits(100)); - try std.testing.expectEqual(@as(usize, 4), countDigits(1234)); -} diff --git a/src/widgets/textarea/render.zig b/src/widgets/textarea/render.zig new file mode 100644 index 0000000..7325660 --- /dev/null +++ b/src/widgets/textarea/render.zig @@ -0,0 +1,85 @@ +//! TextArea Render - Drawing helper functions +//! +//! Part of the textarea widget module. + +const std = @import("std"); +const Context = @import("../../core/context.zig").Context; +const Command = @import("../../core/command.zig"); +const Style = @import("../../core/style.zig"); + +/// Draw a line number +pub 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 +pub 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 +pub 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 (for line number width calculation) +pub 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; +} diff --git a/src/widgets/textarea/state.zig b/src/widgets/textarea/state.zig new file mode 100644 index 0000000..c7aa631 --- /dev/null +++ b/src/widgets/textarea/state.zig @@ -0,0 +1,380 @@ +//! TextArea State - State management for multi-line text editor +//! +//! Part of the textarea widget module. + +const std = @import("std"); + +/// 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, + + const Self = @This(); + + /// Initialize with empty buffer + pub fn init(buffer: []u8) Self { + return .{ .buffer = buffer }; + } + + /// Get the current text + pub fn text(self: Self) []const u8 { + return self.buffer[0..self.len]; + } + + /// Set text programmatically + pub fn setText(self: *Self, 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: *Self) 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: *Self, 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: *Self) void { + self.insert("\n"); + } + + /// Delete character before cursor (backspace) + pub fn deleteBack(self: *Self) 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: *Self) 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: *Self) 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: Self) 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 + pub fn getLineStart(self: Self, 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) + pub fn getLineEnd(self: Self, 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: Self) 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: *Self, 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: *Self, 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: *Self, 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: *Self, 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: *Self, 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: *Self, 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: *Self, 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: *Self, 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: *Self) void { + self.selection_start = 0; + self.cursor = self.len; + } + + /// Ensure cursor is visible by adjusting scroll + pub fn ensureCursorVisible(self: *Self, 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; + } + } +}; diff --git a/src/widgets/textarea/textarea.zig b/src/widgets/textarea/textarea.zig new file mode 100644 index 0000000..308e60b --- /dev/null +++ b/src/widgets/textarea/textarea.zig @@ -0,0 +1,396 @@ +//! 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. +//! +//! This module re-exports types from the textarea/ subdirectory. + +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"); + +// Re-export types +pub const types = @import("types.zig"); +pub const TextAreaConfig = types.TextAreaConfig; +pub const TextAreaColors = types.TextAreaColors; +pub const TextAreaResult = types.TextAreaResult; + +// Re-export state +pub const state = @import("state.zig"); +pub const TextAreaState = state.TextAreaState; + +// Import render helpers +const render = @import("render.zig"); + +// ============================================================================= +// Public API +// ============================================================================= + +/// Draw a text area and return interaction result +pub fn textArea(ctx: *Context, textarea_state: *TextAreaState) TextAreaResult { + return textAreaEx(ctx, textarea_state, .{}, .{}); +} + +/// Draw a text area with custom configuration +pub fn textAreaEx( + ctx: *Context, + textarea_state: *TextAreaState, + config: TextAreaConfig, + colors: TextAreaColors, +) TextAreaResult { + const bounds = ctx.layout.nextRect(); + return textAreaRect(ctx, bounds, textarea_state, config, colors); +} + +/// Draw a text area in a specific rectangle +pub fn textAreaRect( + ctx: *Context, + bounds: Layout.Rect, + textarea_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; + + // Generate unique ID for this widget based on buffer memory address + const widget_id: u64 = @intFromPtr(textarea_state.buffer.ptr); + + // Register as focusable in the active focus group + ctx.registerFocusable(widget_id); + + // 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) { + // Request focus through the focus system + ctx.requestFocus(widget_id); + result.clicked = true; + } + + // Check if this widget has focus + const has_focus = ctx.hasFocus(widget_id); + textarea_state.focused = has_focus; + + // Get colors + const bg_color = if (has_focus) colors.background.lighten(5) else colors.background; + const border_color = if (has_focus) 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(render.countDigits(textarea_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 (textarea_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], ' '); + textarea_state.insert(spaces[0..count]); + } else { + textarea_state.insert(&[_]u8{c}); + } + } + result.changed = true; + } + } + + // Ensure cursor is visible + textarea_state.ensureCursorVisible(visible_lines, visible_cols); + + // Get cursor position + const cursor_pos = textarea_state.getCursorPosition(); + result.cursor_line = cursor_pos.line; + result.cursor_col = cursor_pos.col; + + // Draw text line by line + const txt = textarea_state.text(); + var line_num: usize = 0; + var line_start: usize = 0; + + for (txt, 0..) |c, i| { + if (c == '\n') { + if (line_num >= textarea_state.scroll_y and line_num < textarea_state.scroll_y + visible_lines) { + const draw_line = line_num - textarea_state.scroll_y; + const y = text_area.y + @as(i32, @intCast(draw_line * line_height)); + + // Draw line number + if (config.line_numbers) { + render.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]; + render.drawLineText(ctx, text_area.x, y, line_text, textarea_state.scroll_x, visible_cols, colors.text); + + // Draw selection on this line + if (textarea_state.selection_start != null) { + render.drawLineSelection( + ctx, + text_area.x, + y, + line_start, + i, + textarea_state.cursor, + textarea_state.selection_start.?, + textarea_state.scroll_x, + visible_cols, + char_width, + line_height, + colors.selection, + ); + } + + // Draw cursor if on this line + if (textarea_state.focused and cursor_pos.line == line_num) { + const cursor_x_pos = cursor_pos.col -| textarea_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 >= textarea_state.scroll_y and line_num < textarea_state.scroll_y + visible_lines) { + const draw_line = line_num - textarea_state.scroll_y; + const y = text_area.y + @as(i32, @intCast(draw_line * line_height)); + + // Draw line number + if (config.line_numbers) { + render.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 ""; + render.drawLineText(ctx, text_area.x, y, line_text, textarea_state.scroll_x, visible_cols, colors.text); + + // Draw selection on this line + if (textarea_state.selection_start != null) { + render.drawLineSelection( + ctx, + text_area.x, + y, + line_start, + txt.len, + textarea_state.cursor, + textarea_state.selection_start.?, + textarea_state.scroll_x, + visible_cols, + char_width, + line_height, + colors.selection, + ); + } + + // Draw cursor if on this line + if (textarea_state.focused and cursor_pos.line == line_num) { + const cursor_x_pos = cursor_pos.col -| textarea_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 (textarea_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; +} + +// ============================================================================= +// Tests +// ============================================================================= + +test "TextAreaState insert" { + var buf: [256]u8 = undefined; + var textarea_state = TextAreaState.init(&buf); + + textarea_state.insert("Hello"); + try std.testing.expectEqualStrings("Hello", textarea_state.text()); + try std.testing.expectEqual(@as(usize, 5), textarea_state.cursor); + + textarea_state.insertNewline(); + textarea_state.insert("World"); + try std.testing.expectEqualStrings("Hello\nWorld", textarea_state.text()); +} + +test "TextAreaState line count" { + var buf: [256]u8 = undefined; + var textarea_state = TextAreaState.init(&buf); + + textarea_state.insert("Line 1"); + try std.testing.expectEqual(@as(usize, 1), textarea_state.lineCount()); + + textarea_state.insertNewline(); + textarea_state.insert("Line 2"); + try std.testing.expectEqual(@as(usize, 2), textarea_state.lineCount()); + + textarea_state.insertNewline(); + textarea_state.insertNewline(); + textarea_state.insert("Line 4"); + try std.testing.expectEqual(@as(usize, 4), textarea_state.lineCount()); +} + +test "TextAreaState cursor position" { + var buf: [256]u8 = undefined; + var textarea_state = TextAreaState.init(&buf); + + textarea_state.insert("Hello\nWorld\nTest"); + + // Cursor at end + const pos = textarea_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 textarea_state = TextAreaState.init(&buf); + + textarea_state.insert("Line 1\nLine 2\nLine 3"); + + // Move up + textarea_state.cursorUp(false); + var pos = textarea_state.getCursorPosition(); + try std.testing.expectEqual(@as(usize, 1), pos.line); + + textarea_state.cursorUp(false); + pos = textarea_state.getCursorPosition(); + try std.testing.expectEqual(@as(usize, 0), pos.line); + + // Move down + textarea_state.cursorDown(false); + pos = textarea_state.getCursorPosition(); + try std.testing.expectEqual(@as(usize, 1), pos.line); +} + +test "TextAreaState home/end" { + var buf: [256]u8 = undefined; + var textarea_state = TextAreaState.init(&buf); + + textarea_state.insert("Hello World"); + textarea_state.cursorHome(false); + + try std.testing.expectEqual(@as(usize, 0), textarea_state.cursor); + + textarea_state.cursorEnd(false); + try std.testing.expectEqual(@as(usize, 11), textarea_state.cursor); +} + +test "TextAreaState selection" { + var buf: [256]u8 = undefined; + var textarea_state = TextAreaState.init(&buf); + + textarea_state.insert("Hello World"); + textarea_state.selectAll(); + + try std.testing.expectEqual(@as(?usize, 0), textarea_state.selection_start); + try std.testing.expectEqual(@as(usize, 11), textarea_state.cursor); + + textarea_state.insert("X"); + try std.testing.expectEqualStrings("X", textarea_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 textarea_state = TextAreaState.init(&buf); + + ctx.beginFrame(); + ctx.layout.row_height = 100; + + _ = textArea(&ctx, &textarea_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), render.countDigits(0)); + try std.testing.expectEqual(@as(usize, 1), render.countDigits(5)); + try std.testing.expectEqual(@as(usize, 2), render.countDigits(10)); + try std.testing.expectEqual(@as(usize, 3), render.countDigits(100)); + try std.testing.expectEqual(@as(usize, 4), render.countDigits(1234)); +} diff --git a/src/widgets/textarea/types.zig b/src/widgets/textarea/types.zig new file mode 100644 index 0000000..9230011 --- /dev/null +++ b/src/widgets/textarea/types.zig @@ -0,0 +1,59 @@ +//! TextArea Types - Configuration and result types +//! +//! Part of the textarea widget module. + +const Style = @import("../../core/style.zig"); + +/// 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, +}; diff --git a/src/widgets/widgets.zig b/src/widgets/widgets.zig index 8bd5c6d..40cc8b7 100644 --- a/src/widgets/widgets.zig +++ b/src/widgets/widgets.zig @@ -24,10 +24,10 @@ pub const scroll = @import("scroll.zig"); pub const menu = @import("menu.zig"); pub const tabs = @import("tabs.zig"); pub const radio = @import("radio.zig"); -pub const progress = @import("progress.zig"); +pub const progress = @import("progress/progress.zig"); pub const tooltip = @import("tooltip.zig"); pub const toast = @import("toast.zig"); -pub const textarea = @import("textarea.zig"); +pub const textarea = @import("textarea/textarea.zig"); pub const tree = @import("tree.zig"); pub const badge = @import("badge.zig"); pub const img = @import("image.zig");