zcatgui/src/widgets/progress.zig
reugenio 2dccddeab0 feat: Paridad Visual DVUI Fase 3 - Sombras y Gradientes
Nuevas capacidades de rendering:
- ShadowCommand: sombras multi-capa con blur simulado
  - Helpers: shadow(), shadowDrop(), shadowFloat()
  - Quadratic alpha falloff para bordes suaves
- GradientCommand: gradientes suaves pixel a pixel
  - Direcciones: vertical, horizontal, diagonal
  - Helpers: gradientV/H(), gradientButton(), gradientProgress()
  - Soporte esquinas redondeadas

Widgets actualizados:
- Panel/Modal: sombras en fancy mode
- Select/Menu: dropdown con sombra + rounded corners
- Tooltip/Toast: sombra sutil + rounded corners
- Button: gradiente 3D (lighten top, darken bottom)
- Progress: gradientes suaves vs 4 bandas

IMPORTANTE: Compila y pasa tests (370/370) pero NO probado visualmente

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 13:27:48 +01:00

777 lines
24 KiB
Zig

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