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 <noreply@anthropic.com>
141 lines
3.9 KiB
Zig
141 lines
3.9 KiB
Zig
//! 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,
|
|
};
|
|
}
|