zcatgui/src/widgets/switch.zig
reugenio 91e13f6956 feat: zcatgui Gio parity - 12 new widgets + gesture system
New widgets (12):
- Switch: Toggle switch with animation
- IconButton: Circular icon button (filled/outlined/ghost/tonal)
- Divider: Horizontal/vertical separator with optional label
- Loader: 7 spinner styles (circular/dots/bars/pulse/bounce/ring/square)
- Surface: Elevated container with shadow layers
- Grid: Layout grid with scrolling and selection
- Resize: Draggable resize handle (horizontal/vertical/both)
- AppBar: Application bar (top/bottom) with actions
- NavDrawer: Navigation drawer with items, icons, badges
- Sheet: Side/bottom sliding panel with modal support
- Discloser: Expandable/collapsible container (3 icon styles)
- Selectable: Clickable region with selection modes

Core systems added:
- GestureRecognizer: Tap, double-tap, long-press, drag, swipe
- Velocity tracking and fling detection
- Spring physics for fluid animations

Integration:
- All widgets exported via widgets.zig
- GestureRecognizer exported via zcatgui.zig
- Spring/SpringConfig exported from animation.zig
- Color.withAlpha() method added to style.zig

Stats: 47 widget files, 338+ tests, +5,619 LOC
Full Gio UI parity achieved.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 17:21:15 +01:00

346 lines
11 KiB
Zig

//! Switch Widget - Toggle on/off control
//!
//! A toggle switch similar to iOS/Android switches.
//! More visual than a checkbox, typically used for settings.
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");
/// Switch state
pub const State = struct {
/// Current on/off state
is_on: bool = false,
/// Animation progress (0.0 = off position, 1.0 = on position)
animation_progress: f32 = 0,
/// Internal: last frame time for animation
_last_update: i64 = 0,
pub fn init(initial_on: bool) State {
return .{
.is_on = initial_on,
.animation_progress = if (initial_on) 1.0 else 0.0,
};
}
};
/// Switch configuration
pub const Config = struct {
/// Label text (appears to the right)
label: []const u8 = "",
/// Disabled state
disabled: bool = false,
/// Track dimensions
track_width: u16 = 44,
track_height: u16 = 24,
/// Thumb (circle) size
thumb_size: u16 = 20,
/// Gap between switch and label
gap: u16 = 8,
/// Animation duration in ms (0 = instant)
animation_ms: u16 = 150,
/// Label position
label_position: enum { left, right } = .right,
};
/// Switch colors
pub const Colors = struct {
/// Track color when off
track_off: Style.Color = Style.Color.rgba(100, 100, 100, 255),
/// Track color when on
track_on: Style.Color = Style.Color.rgba(76, 175, 80, 255), // Green
/// Track color when disabled
track_disabled: Style.Color = Style.Color.rgba(60, 60, 60, 255),
/// Thumb color
thumb: Style.Color = Style.Color.white,
/// Thumb color when disabled
thumb_disabled: Style.Color = Style.Color.rgba(180, 180, 180, 255),
/// Label color
label_color: Style.Color = Style.Color.rgba(220, 220, 220, 255),
/// Label color when disabled
label_disabled: Style.Color = Style.Color.rgba(120, 120, 120, 255),
pub fn fromTheme(theme: Style.Theme) Colors {
return .{
.track_off = theme.secondary,
.track_on = theme.success,
.track_disabled = theme.secondary.darken(30),
.thumb = Style.Color.white,
.thumb_disabled = theme.foreground.darken(40),
.label_color = theme.foreground,
.label_disabled = theme.foreground.darken(40),
};
}
};
/// Switch result
pub const Result = struct {
/// True if state was toggled this frame
changed: bool,
/// True if switch is currently hovered
hovered: bool,
/// Current on/off state
is_on: bool,
};
/// Simple switch with just a label
pub fn switch_(ctx: *Context, state: *State, label_text: []const u8) Result {
return switchEx(ctx, state, .{ .label = label_text }, .{});
}
/// Switch with custom configuration
pub fn switchEx(ctx: *Context, state: *State, config: Config, colors: Colors) Result {
const bounds = ctx.layout.nextRect();
return switchRect(ctx, bounds, state, config, colors);
}
/// Switch in a specific rectangle
pub fn switchRect(
ctx: *Context,
bounds: Layout.Rect,
state: *State,
config: Config,
colors: Colors,
) Result {
if (bounds.isEmpty()) return .{ .changed = false, .hovered = false, .is_on = state.is_on };
// Update animation
updateAnimation(state, config);
// Check mouse interaction
const mouse = ctx.input.mousePos();
const switch_width = config.track_width;
// Calculate switch position based on label position
const switch_x = if (config.label_position == .left and config.label.len > 0)
bounds.x + @as(i32, @intCast(config.label.len * 8 + config.gap))
else
bounds.x;
const switch_rect = Layout.Rect{
.x = switch_x,
.y = bounds.y + @as(i32, @intCast((bounds.h -| config.track_height) / 2)),
.w = switch_width,
.h = config.track_height,
};
const hovered = switch_rect.contains(mouse.x, mouse.y) and !config.disabled;
const clicked = hovered and ctx.input.mouseReleased(.left);
// Toggle on click
var changed = false;
if (clicked) {
state.is_on = !state.is_on;
changed = true;
}
// Draw track
const track_color = if (config.disabled)
colors.track_disabled
else
blendColors(colors.track_off, colors.track_on, state.animation_progress);
// Draw rounded track
drawRoundedRect(ctx, switch_rect, config.track_height / 2, track_color);
// Draw thumb
const thumb_margin: i32 = @intCast((config.track_height - config.thumb_size) / 2);
const thumb_travel: f32 = @floatFromInt(config.track_width - config.thumb_size - @as(u16, @intCast(thumb_margin * 2)));
const thumb_offset: i32 = @intFromFloat(thumb_travel * state.animation_progress);
const thumb_x = switch_rect.x + thumb_margin + thumb_offset;
const thumb_y = switch_rect.y + thumb_margin;
const thumb_color = if (config.disabled) colors.thumb_disabled else colors.thumb;
// Draw thumb as filled circle (approximated with rounded rect)
drawRoundedRect(ctx, .{
.x = thumb_x,
.y = thumb_y,
.w = config.thumb_size,
.h = config.thumb_size,
}, config.thumb_size / 2, thumb_color);
// Draw hover highlight
if (hovered) {
// Subtle highlight around thumb
const highlight_size = config.thumb_size + 4;
const highlight_x = thumb_x - 2;
const highlight_y = thumb_y - 2;
drawRoundedRect(ctx, .{
.x = highlight_x,
.y = highlight_y,
.w = highlight_size,
.h = highlight_size,
}, highlight_size / 2, Style.Color.rgba(255, 255, 255, 30));
}
// Draw label
if (config.label.len > 0) {
const char_height: u32 = 8;
const label_y = bounds.y + @as(i32, @intCast((bounds.h -| char_height) / 2));
const label_color = if (config.disabled) colors.label_disabled else colors.label_color;
const label_x = if (config.label_position == .left)
bounds.x
else
switch_rect.x + @as(i32, @intCast(config.track_width + config.gap));
ctx.pushCommand(Command.text(label_x, label_y, config.label, label_color));
}
return .{
.changed = changed,
.hovered = hovered,
.is_on = state.is_on,
};
}
/// Update animation progress
fn updateAnimation(state: *State, config: Config) void {
if (config.animation_ms == 0) {
// Instant transition
state.animation_progress = if (state.is_on) 1.0 else 0.0;
return;
}
const target: f32 = if (state.is_on) 1.0 else 0.0;
const diff = target - state.animation_progress;
if (@abs(diff) < 0.01) {
state.animation_progress = target;
return;
}
// Simple lerp animation (assumes ~16ms per frame)
const speed: f32 = 16.0 / @as(f32, @floatFromInt(config.animation_ms));
if (diff > 0) {
state.animation_progress = @min(target, state.animation_progress + speed);
} else {
state.animation_progress = @max(target, state.animation_progress - speed);
}
}
/// Blend two colors based on factor (0.0 = a, 1.0 = b)
fn blendColors(a: Style.Color, b: Style.Color, factor: f32) Style.Color {
const f = @max(0.0, @min(1.0, factor));
const inv_f = 1.0 - f;
return Style.Color.rgba(
@intFromFloat(@as(f32, @floatFromInt(a.r)) * inv_f + @as(f32, @floatFromInt(b.r)) * f),
@intFromFloat(@as(f32, @floatFromInt(a.g)) * inv_f + @as(f32, @floatFromInt(b.g)) * f),
@intFromFloat(@as(f32, @floatFromInt(a.b)) * inv_f + @as(f32, @floatFromInt(b.b)) * f),
@intFromFloat(@as(f32, @floatFromInt(a.a)) * inv_f + @as(f32, @floatFromInt(b.a)) * f),
);
}
/// Draw a rounded rectangle (approximated)
fn drawRoundedRect(ctx: *Context, rect: Layout.Rect, radius: u16, color: Style.Color) void {
// For now, just draw a regular rectangle
// TODO: Use proper rounded rect when available
ctx.pushCommand(Command.rect(rect.x, rect.y, rect.w, rect.h, color));
// Draw corner circles to approximate rounding
if (radius > 0 and rect.w >= radius * 2 and rect.h >= radius * 2) {
// This is a simplified version - real implementation would use proper AA circles
// For now, the basic rect is fine
}
}
// =============================================================================
// Tests
// =============================================================================
test "switch toggle" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
var state = State.init(false);
// Frame 1: Click inside switch
ctx.beginFrame();
ctx.layout.row_height = 32;
ctx.input.setMousePos(22, 16); // Center of switch
ctx.input.setMouseButton(.left, true);
_ = switch_(&ctx, &state, "Enable");
ctx.endFrame();
// Frame 2: Release
ctx.beginFrame();
ctx.layout.row_height = 32;
ctx.input.setMousePos(22, 16);
ctx.input.setMouseButton(.left, false);
const result = switch_(&ctx, &state, "Enable");
ctx.endFrame();
try std.testing.expect(result.changed);
try std.testing.expect(result.is_on);
try std.testing.expect(state.is_on);
}
test "switch animation progress" {
var state = State.init(false);
try std.testing.expectEqual(@as(f32, 0.0), state.animation_progress);
state.is_on = true;
updateAnimation(&state, .{ .animation_ms = 0 });
try std.testing.expectEqual(@as(f32, 1.0), state.animation_progress);
}
test "switch disabled no toggle" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
var state = State.init(false);
// Frame 1: Click
ctx.beginFrame();
ctx.layout.row_height = 32;
ctx.input.setMousePos(22, 16);
ctx.input.setMouseButton(.left, true);
_ = switchEx(&ctx, &state, .{ .label = "Disabled", .disabled = true }, .{});
ctx.endFrame();
// Frame 2: Release
ctx.beginFrame();
ctx.layout.row_height = 32;
ctx.input.setMousePos(22, 16);
ctx.input.setMouseButton(.left, false);
const result = switchEx(&ctx, &state, .{ .label = "Disabled", .disabled = true }, .{});
ctx.endFrame();
try std.testing.expect(!result.changed);
try std.testing.expect(!result.is_on);
}
test "switch generates commands" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
var state = State.init(true);
ctx.beginFrame();
ctx.layout.row_height = 32;
_ = switch_(&ctx, &state, "With label");
ctx.endFrame();
// Should generate: track rect + thumb rect + text
try std.testing.expect(ctx.commands.items.len >= 3);
}
test "color blending" {
const black = Style.Color.rgba(0, 0, 0, 255);
const white = Style.Color.rgba(255, 255, 255, 255);
const mid = blendColors(black, white, 0.5);
try std.testing.expect(mid.r >= 127 and mid.r <= 128);
try std.testing.expect(mid.g >= 127 and mid.g <= 128);
try std.testing.expect(mid.b >= 127 and mid.b <= 128);
const full_black = blendColors(black, white, 0.0);
try std.testing.expectEqual(@as(u8, 0), full_black.r);
const full_white = blendColors(black, white, 1.0);
try std.testing.expectEqual(@as(u8, 255), full_white.r);
}