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>
777 lines
24 KiB
Zig
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();
|
|
}
|