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