//! Progress Widget //! //! Visual feedback widgets for progress and loading states. //! //! ## Widgets //! - **ProgressBar**: Horizontal/vertical progress bar //! - **ProgressCircle**: Circular progress indicator //! - **Spinner**: Animated loading indicator //! //! ## Usage //! ```zig //! // Simple progress bar //! progress.bar(ctx, 0.75); //! //! // Progress with config //! progress.barEx(ctx, 0.5, .{ //! .show_percentage = true, //! .style = .striped, //! }); //! //! // Indeterminate spinner //! progress.spinner(ctx, .{ .style = .circular }); //! ``` const std = @import("std"); const Context = @import("../core/context.zig").Context; const Command = @import("../core/command.zig"); const Layout = @import("../core/layout.zig"); const Style = @import("../core/style.zig"); const Rect = Layout.Rect; const Color = Style.Color; // ============================================================================= // Progress Bar // ============================================================================= /// Progress bar style pub const BarStyle = enum { /// Solid fill solid, /// Striped pattern (animated) striped, /// Gradient fill gradient, /// Segmented blocks segmented, }; /// Progress bar configuration pub const BarConfig = struct { /// Visual style style: BarStyle = .solid, /// Show percentage text show_percentage: bool = true, /// Custom label (overrides percentage) label: ?[]const u8 = null, /// Orientation vertical: bool = false, /// Height (for horizontal) or Width (for vertical) thickness: u16 = 20, /// Corner radius corner_radius: u8 = 4, /// Animation enabled (for striped style) animated: bool = true, /// Track color (background) track_color: ?Color = null, /// Fill color fill_color: ?Color = null, /// Text color text_color: ?Color = null, /// Number of segments (for segmented style) segments: u8 = 10, }; /// Progress bar result pub const BarResult = struct { /// The bounds used bounds: Rect, /// Current progress value (clamped 0-1) progress: f32, }; /// Simple progress bar with default styling pub fn bar(ctx: *Context, value: f32) BarResult { return barEx(ctx, value, .{}); } /// Progress bar with configuration pub fn barEx(ctx: *Context, value: f32, config: BarConfig) BarResult { // Get theme colors const theme = Style.currentTheme(); const track_color = config.track_color orelse theme.border; const fill_color = config.fill_color orelse theme.primary; const text_color = config.text_color orelse theme.text_primary; // Clamp progress value const progress = std.math.clamp(value, 0.0, 1.0); // Calculate bounds based on layout const layout_rect = ctx.layout.area; const bounds = if (config.vertical) Rect.init(layout_rect.x, layout_rect.y, config.thickness, layout_rect.h) else Rect.init(layout_rect.x, layout_rect.y, layout_rect.w, config.thickness); // Draw track (background) ctx.pushCommand(.{ .rect = .{ .x = bounds.x, .y = bounds.y, .w = bounds.w, .h = bounds.h, .color = track_color, }, }); // Calculate fill dimensions const fill_bounds = if (config.vertical) blk: { const fill_height: u32 = @intFromFloat(@as(f32, @floatFromInt(bounds.h)) * progress); const fill_y = bounds.y + @as(i32, @intCast(bounds.h - fill_height)); break :blk Rect.init(bounds.x, fill_y, bounds.w, fill_height); } else blk: { const fill_width: u32 = @intFromFloat(@as(f32, @floatFromInt(bounds.w)) * progress); break :blk Rect.init(bounds.x, bounds.y, fill_width, bounds.h); }; // Draw fill based on style switch (config.style) { .solid => { if (fill_bounds.w > 0 and fill_bounds.h > 0) { ctx.pushCommand(.{ .rect = .{ .x = fill_bounds.x, .y = fill_bounds.y, .w = fill_bounds.w, .h = fill_bounds.h, .color = fill_color, }, }); } }, .striped => { drawStripedFill(ctx, fill_bounds, fill_color, config.animated); }, .gradient => { drawGradientFill(ctx, fill_bounds, fill_color, config.vertical); }, .segmented => { drawSegmentedFill(ctx, bounds, fill_color, progress, config.segments, config.vertical); }, } // Draw label or percentage if (config.show_percentage or config.label != null) { var label_buf: [32]u8 = undefined; const label_text = if (config.label) |lbl| lbl else blk: { const percent: u8 = @intFromFloat(progress * 100); const written = std.fmt.bufPrint(&label_buf, "{d}%", .{percent}) catch ""; break :blk written; }; // Center text in bar const text_width: u32 = @intCast(label_text.len * 8); // Assuming 8px font const text_x = bounds.x + @as(i32, @intCast((bounds.w -| text_width) / 2)); const text_y = bounds.y + @as(i32, @intCast((bounds.h -| 8) / 2)); ctx.pushCommand(.{ .text = .{ .x = text_x, .y = text_y, .text = label_text, .color = text_color, }, }); } ctx.countWidget(); return .{ .bounds = bounds, .progress = progress, }; } /// Draw at specific bounds pub fn barRect(ctx: *Context, bounds: Rect, value: f32, config: BarConfig) BarResult { // Override layout temporarily const saved_layout = ctx.layout; ctx.layout = Layout.LayoutState.init(bounds.w, bounds.h); ctx.layout.container = bounds; const result = barEx(ctx, value, config); ctx.layout = saved_layout; return result; } // ============================================================================= // Progress Circle // ============================================================================= /// Circle progress configuration pub const CircleConfig = struct { /// Diameter in pixels diameter: u16 = 48, /// Stroke width stroke_width: u8 = 4, /// Show percentage in center show_percentage: bool = true, /// Custom label label: ?[]const u8 = null, /// Start angle (0 = top, clockwise) start_angle: f32 = 0, /// Track color track_color: ?Color = null, /// Fill color fill_color: ?Color = null, /// Text color text_color: ?Color = null, }; /// Circle progress result pub const CircleResult = struct { bounds: Rect, progress: f32, }; /// Simple circle progress pub fn circle(ctx: *Context, value: f32) CircleResult { return circleEx(ctx, value, .{}); } /// Circle progress with configuration pub fn circleEx(ctx: *Context, value: f32, config: CircleConfig) CircleResult { const theme = Style.currentTheme(); const track_color = config.track_color orelse theme.border; const fill_color = config.fill_color orelse theme.primary; const text_color = config.text_color orelse theme.text_primary; const progress = std.math.clamp(value, 0.0, 1.0); // Get bounds from layout const layout_rect = ctx.layout.area; const bounds = Rect.init( layout_rect.x, layout_rect.y, config.diameter, config.diameter, ); const center_x = bounds.x + @as(i32, @intCast(bounds.w / 2)); const center_y = bounds.y + @as(i32, @intCast(bounds.h / 2)); const radius: i32 = @intCast(config.diameter / 2 - config.stroke_width); // Draw track circle (as approximation with arcs or just outline) drawCircleOutline(ctx, center_x, center_y, radius, config.stroke_width, track_color); // Draw progress arc if (progress > 0) { drawProgressArc(ctx, center_x, center_y, radius, config.stroke_width, fill_color, progress, config.start_angle); } // Draw label if (config.show_percentage or config.label != null) { var label_buf: [32]u8 = undefined; const label_text = if (config.label) |lbl| lbl else blk: { const percent: u8 = @intFromFloat(progress * 100); const written = std.fmt.bufPrint(&label_buf, "{d}%", .{percent}) catch ""; break :blk written; }; const text_width: u32 = @intCast(label_text.len * 8); const text_x = center_x - @as(i32, @intCast(text_width / 2)); const text_y = center_y - 4; ctx.pushCommand(.{ .text = .{ .x = text_x, .y = text_y, .text = label_text, .color = text_color, }, }); } ctx.countWidget(); return .{ .bounds = bounds, .progress = progress, }; } // ============================================================================= // Spinner // ============================================================================= /// Spinner style pub const SpinnerStyle = enum { /// Rotating arc circular, /// Pulsing dots dots, /// Bouncing bars (equalizer) bars, /// Ring with gap ring, }; /// Spinner configuration pub const SpinnerConfig = struct { /// Visual style style: SpinnerStyle = .circular, /// Size in pixels size: u16 = 24, /// Animation speed multiplier speed: f32 = 1.0, /// Optional label below spinner label: ?[]const u8 = null, /// Primary color color: ?Color = null, /// Number of elements (dots or bars) elements: u8 = 8, }; /// Spinner state (for animation) pub const SpinnerState = struct { /// Animation progress (0-1, loops) animation: f32 = 0, /// Last update timestamp last_update: i64 = 0, pub fn update(self: *SpinnerState, speed: f32) void { const now = std.time.milliTimestamp(); if (self.last_update == 0) { self.last_update = now; return; } const delta_ms = now - self.last_update; self.last_update = now; // Advance animation const delta_f: f32 = @floatFromInt(delta_ms); self.animation += (delta_f / 1000.0) * speed; if (self.animation >= 1.0) { self.animation -= 1.0; } } }; /// Spinner result pub const SpinnerResult = struct { bounds: Rect, }; /// Simple spinner pub fn spinner(ctx: *Context, state: *SpinnerState) SpinnerResult { return spinnerEx(ctx, state, .{}); } /// Spinner with configuration pub fn spinnerEx(ctx: *Context, state: *SpinnerState, config: SpinnerConfig) SpinnerResult { const theme = Style.currentTheme(); const color = config.color orelse theme.primary; // Update animation state.update(config.speed); // Get bounds const layout_rect = ctx.layout.area; const bounds = Rect.init( layout_rect.x, layout_rect.y, config.size, config.size, ); const center_x = bounds.x + @as(i32, @intCast(bounds.w / 2)); const center_y = bounds.y + @as(i32, @intCast(bounds.h / 2)); switch (config.style) { .circular => { drawRotatingArc(ctx, center_x, center_y, config.size / 2 - 2, color, state.animation); }, .dots => { drawPulsingDots(ctx, center_x, center_y, config.size, color, config.elements, state.animation); }, .bars => { drawBouncingBars(ctx, bounds, color, config.elements, state.animation); }, .ring => { drawRingWithGap(ctx, center_x, center_y, config.size / 2 - 2, color, state.animation); }, } // Draw label if present if (config.label) |label| { const text_x = bounds.x; const text_y = bounds.y + @as(i32, @intCast(bounds.h)) + 4; ctx.pushCommand(.{ .text = .{ .x = text_x, .y = text_y, .text = label, .color = theme.text_secondary, }, }); } ctx.countWidget(); return .{ .bounds = bounds, }; } // ============================================================================= // Helper Drawing Functions // ============================================================================= fn drawStripedFill(ctx: *Context, bounds: Rect, fill_color: Color, animated: bool) void { _ = animated; // TODO: Use frame time for animation offset if (bounds.w == 0 or bounds.h == 0) return; // Draw base fill ctx.pushCommand(.{ .rect = .{ .x = bounds.x, .y = bounds.y, .w = bounds.w, .h = bounds.h, .color = fill_color, }, }); // Draw stripes (darker lines) const stripe_color = Color.rgba( fill_color.r -| 30, fill_color.g -| 30, fill_color.b -| 30, fill_color.a, ); const stripe_width: i32 = 6; const stripe_gap: i32 = 12; var x = bounds.x; while (x < bounds.x + @as(i32, @intCast(bounds.w))) { const stripe_h = @min(@as(u32, @intCast(@max(0, bounds.x + @as(i32, @intCast(bounds.w)) - x))), @as(u32, @intCast(stripe_width))); if (stripe_h > 0) { ctx.pushCommand(.{ .rect = .{ .x = x, .y = bounds.y, .w = stripe_h, .h = bounds.h, .color = stripe_color, }, }); } x += stripe_gap; } } fn drawGradientFill(ctx: *Context, bounds: Rect, base_color: Color, vertical: bool) void { if (bounds.w == 0 or bounds.h == 0) return; // Use proper gradient command for smooth color transition const start_color = base_color.lighten(25); const end_color = base_color.darken(15); if (vertical) { ctx.pushCommand(Command.gradientV(bounds.x, bounds.y, bounds.w, bounds.h, start_color, end_color)); } else { ctx.pushCommand(Command.gradientH(bounds.x, bounds.y, bounds.w, bounds.h, start_color, end_color)); } } fn drawSegmentedFill(ctx: *Context, bounds: Rect, fill_color: Color, progress: f32, segments: u8, vertical: bool) void { const seg_count: u32 = segments; const gap: u32 = 2; if (vertical) { const seg_height = (bounds.h - (seg_count - 1) * gap) / seg_count; const filled_segs: u32 = @intFromFloat(@as(f32, @floatFromInt(seg_count)) * progress); var i: u32 = 0; while (i < seg_count) : (i += 1) { const seg_y = bounds.y + @as(i32, @intCast(bounds.h)) - @as(i32, @intCast((i + 1) * (seg_height + gap))); const color = if (i < filled_segs) fill_color else Color.rgba(fill_color.r / 4, fill_color.g / 4, fill_color.b / 4, fill_color.a); ctx.pushCommand(.{ .rect = .{ .x = bounds.x, .y = seg_y, .w = bounds.w, .h = seg_height, .color = color, }, }); } } else { const seg_width = (bounds.w - (seg_count - 1) * gap) / seg_count; const filled_segs: u32 = @intFromFloat(@as(f32, @floatFromInt(seg_count)) * progress); var i: u32 = 0; while (i < seg_count) : (i += 1) { const seg_x = bounds.x + @as(i32, @intCast(i * (seg_width + gap))); const color = if (i < filled_segs) fill_color else Color.rgba(fill_color.r / 4, fill_color.g / 4, fill_color.b / 4, fill_color.a); ctx.pushCommand(.{ .rect = .{ .x = seg_x, .y = bounds.y, .w = seg_width, .h = bounds.h, .color = color, }, }); } } } fn drawCircleOutline(ctx: *Context, cx: i32, cy: i32, radius: i32, stroke: u8, color: Color) void { // Approximate circle with octagon for simplicity in software rendering const r = radius; const s: i32 = @intCast(stroke); // Draw 8 segments around the circle const offsets = [_][2]i32{ .{ 0, -r }, // top .{ r, 0 }, // right .{ 0, r }, // bottom .{ -r, 0 }, // left }; for (offsets) |off| { ctx.pushCommand(.{ .rect = .{ .x = cx + off[0] - @divTrunc(s, 2), .y = cy + off[1] - @divTrunc(s, 2), .w = @intCast(s), .h = @intCast(s), .color = color, }, }); } } fn drawProgressArc(ctx: *Context, cx: i32, cy: i32, radius: i32, stroke: u8, color: Color, progress: f32, start_angle: f32) void { _ = start_angle; // Simplified arc drawing - draw filled segments const segments: u32 = 16; const filled: u32 = @intFromFloat(@as(f32, @floatFromInt(segments)) * progress); var i: u32 = 0; while (i < filled) : (i += 1) { const angle: f32 = (@as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(segments))) * 2.0 * std.math.pi - std.math.pi / 2.0; const px: i32 = cx + @as(i32, @intFromFloat(@cos(angle) * @as(f32, @floatFromInt(radius)))); const py: i32 = cy + @as(i32, @intFromFloat(@sin(angle) * @as(f32, @floatFromInt(radius)))); ctx.pushCommand(.{ .rect = .{ .x = px - @divTrunc(@as(i32, stroke), 2), .y = py - @divTrunc(@as(i32, stroke), 2), .w = stroke, .h = stroke, .color = color, }, }); } } fn drawRotatingArc(ctx: *Context, cx: i32, cy: i32, radius: i32, color: Color, animation: f32) void { const segments: u32 = 8; const arc_length: u32 = 5; // Number of segments in the arc const start_seg: u32 = @intFromFloat(animation * @as(f32, @floatFromInt(segments))); var i: u32 = 0; while (i < arc_length) : (i += 1) { const seg = (start_seg + i) % segments; const angle: f32 = (@as(f32, @floatFromInt(seg)) / @as(f32, @floatFromInt(segments))) * 2.0 * std.math.pi - std.math.pi / 2.0; const px: i32 = cx + @as(i32, @intFromFloat(@cos(angle) * @as(f32, @floatFromInt(radius)))); const py: i32 = cy + @as(i32, @intFromFloat(@sin(angle) * @as(f32, @floatFromInt(radius)))); // Fade based on position in arc const alpha: u8 = @intFromFloat((@as(f32, @floatFromInt(arc_length - i)) / @as(f32, @floatFromInt(arc_length))) * 255); const faded = Color.rgba(color.r, color.g, color.b, alpha); ctx.pushCommand(.{ .rect = .{ .x = px - 2, .y = py - 2, .w = 4, .h = 4, .color = faded, }, }); } } fn drawPulsingDots(ctx: *Context, cx: i32, cy: i32, size: u16, color: Color, count: u8, animation: f32) void { const radius: f32 = @as(f32, @floatFromInt(size)) / 3.0; var i: u8 = 0; while (i < count) : (i += 1) { const angle: f32 = (@as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(count))) * 2.0 * std.math.pi - std.math.pi / 2.0; const px: i32 = cx + @as(i32, @intFromFloat(@cos(angle) * radius)); const py: i32 = cy + @as(i32, @intFromFloat(@sin(angle) * radius)); // Pulse based on animation and position const phase = animation + @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(count)); const scale: f32 = 0.5 + 0.5 * @sin(phase * 2.0 * std.math.pi); const dot_size: u32 = @intFromFloat(2.0 + scale * 3.0); ctx.pushCommand(.{ .rect = .{ .x = px - @as(i32, @intCast(dot_size / 2)), .y = py - @as(i32, @intCast(dot_size / 2)), .w = dot_size, .h = dot_size, .color = color, }, }); } } fn drawBouncingBars(ctx: *Context, bounds: Rect, color: Color, count: u8, animation: f32) void { const bar_width = bounds.w / @as(u32, count); const max_height = bounds.h; var i: u8 = 0; while (i < count) : (i += 1) { // Each bar bounces with phase offset const phase = animation + @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(count)); const bounce: f32 = @abs(@sin(phase * 2.0 * std.math.pi)); const bar_height: u32 = @intFromFloat(@as(f32, @floatFromInt(max_height)) * (0.3 + 0.7 * bounce)); const bar_x = bounds.x + @as(i32, @intCast(@as(u32, i) * bar_width)); const bar_y = bounds.y + @as(i32, @intCast(max_height - bar_height)); ctx.pushCommand(.{ .rect = .{ .x = bar_x + 1, .y = bar_y, .w = bar_width -| 2, .h = bar_height, .color = color, }, }); } } fn drawRingWithGap(ctx: *Context, cx: i32, cy: i32, radius: i32, color: Color, animation: f32) void { const segments: u32 = 12; const gap_size: u32 = 3; // Number of segments for the gap const gap_start: u32 = @intFromFloat(animation * @as(f32, @floatFromInt(segments))); var i: u32 = 0; while (i < segments) : (i += 1) { // Skip gap segments const distance_from_gap = @min((i + segments - gap_start) % segments, (gap_start + segments - i) % segments); if (distance_from_gap < gap_size / 2) continue; const angle: f32 = (@as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(segments))) * 2.0 * std.math.pi - std.math.pi / 2.0; const px: i32 = cx + @as(i32, @intFromFloat(@cos(angle) * @as(f32, @floatFromInt(radius)))); const py: i32 = cy + @as(i32, @intFromFloat(@sin(angle) * @as(f32, @floatFromInt(radius)))); ctx.pushCommand(.{ .rect = .{ .x = px - 2, .y = py - 2, .w = 4, .h = 4, .color = color, }, }); } } // ============================================================================= // Tests // ============================================================================= test "ProgressBar basic" { var ctx = try Context.init(std.testing.allocator, 800, 600); defer ctx.deinit(); ctx.beginFrame(); const result = bar(&ctx, 0.5); try std.testing.expectEqual(@as(f32, 0.5), result.progress); try std.testing.expect(ctx.commands.items.len > 0); ctx.endFrame(); } test "ProgressBar clamping" { var ctx = try Context.init(std.testing.allocator, 800, 600); defer ctx.deinit(); ctx.beginFrame(); // Test value clamping const result1 = bar(&ctx, -0.5); try std.testing.expectEqual(@as(f32, 0.0), result1.progress); const result2 = bar(&ctx, 1.5); try std.testing.expectEqual(@as(f32, 1.0), result2.progress); ctx.endFrame(); } test "ProgressBar styles" { var ctx = try Context.init(std.testing.allocator, 800, 600); defer ctx.deinit(); ctx.beginFrame(); // Test different styles _ = barEx(&ctx, 0.75, .{ .style = .solid }); _ = barEx(&ctx, 0.75, .{ .style = .striped }); _ = barEx(&ctx, 0.75, .{ .style = .gradient }); _ = barEx(&ctx, 0.75, .{ .style = .segmented, .segments = 5 }); try std.testing.expect(ctx.commands.items.len > 0); ctx.endFrame(); } test "ProgressCircle basic" { var ctx = try Context.init(std.testing.allocator, 800, 600); defer ctx.deinit(); ctx.beginFrame(); const result = circle(&ctx, 0.75); try std.testing.expectEqual(@as(f32, 0.75), result.progress); ctx.endFrame(); } test "Spinner state" { var state = SpinnerState{}; // Initial state try std.testing.expectEqual(@as(f32, 0), state.animation); // Update advances animation state.update(1.0); state.last_update -= 100; // Simulate 100ms passed state.update(1.0); try std.testing.expect(state.animation >= 0); } test "Spinner basic" { var ctx = try Context.init(std.testing.allocator, 800, 600); defer ctx.deinit(); ctx.beginFrame(); var state = SpinnerState{}; const result = spinner(&ctx, &state); try std.testing.expect(result.bounds.w > 0); ctx.endFrame(); }