//! Radio Button Widget - Mutually exclusive selection //! //! Provides: //! - RadioButton: Single radio button //! - RadioGroup: Group of mutually exclusive options //! //! Supports: //! - Keyboard navigation (arrows, space) //! - Mouse click //! - Horizontal or vertical layout 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"); // ============================================================================= // Radio Option // ============================================================================= /// Radio option definition pub const RadioOption = struct { /// Option label label: []const u8, /// Option value/ID value: u32 = 0, /// Is option disabled disabled: bool = false, }; // ============================================================================= // Radio State // ============================================================================= /// Radio group state (caller-managed) pub const RadioState = struct { /// Currently selected index (-1 for none) selected: i32 = -1, /// Has focus focused: bool = false, /// Focused option index (for keyboard navigation) focus_index: i32 = 0, const Self = @This(); /// Get selected value pub fn getSelected(self: Self) ?usize { if (self.selected < 0) return null; return @intCast(self.selected); } /// Set selected by index pub fn setSelected(self: *Self, index: usize) void { self.selected = @intCast(index); } /// Set selected by value pub fn setSelectedValue(self: *Self, options: []const RadioOption, value: u32) void { for (options, 0..) |opt, i| { if (opt.value == value) { self.selected = @intCast(i); return; } } } /// Get selected value pub fn getSelectedValue(self: Self, options: []const RadioOption) ?u32 { if (self.selected < 0) return null; const idx: usize = @intCast(self.selected); if (idx >= options.len) return null; return options[idx].value; } /// Move focus to next option pub fn focusNext(self: *Self, options: []const RadioOption) void { if (options.len == 0) return; var next = self.focus_index + 1; var attempts: usize = 0; while (attempts < options.len) { if (next >= @as(i32, @intCast(options.len))) next = 0; if (!options[@intCast(next)].disabled) { self.focus_index = next; return; } next += 1; attempts += 1; } } /// Move focus to previous option pub fn focusPrev(self: *Self, options: []const RadioOption) void { if (options.len == 0) return; var prev = self.focus_index - 1; var attempts: usize = 0; while (attempts < options.len) { if (prev < 0) prev = @as(i32, @intCast(options.len)) - 1; if (!options[@intCast(prev)].disabled) { self.focus_index = prev; return; } prev -= 1; attempts += 1; } } }; // ============================================================================= // Radio Configuration // ============================================================================= /// Radio group layout direction pub const Direction = enum { vertical, horizontal, }; /// Radio configuration pub const RadioConfig = struct { /// Layout direction direction: Direction = .vertical, /// Size of radio circle radio_size: u32 = 16, /// Spacing between options spacing: u32 = 8, /// Padding between radio and label label_padding: u32 = 8, }; /// Radio colors pub const RadioColors = struct { /// Radio circle border border: Style.Color = Style.Color.rgb(100, 100, 105), /// Radio circle border when focused border_focus: Style.Color = Style.Color.primary, /// Radio circle background background: Style.Color = Style.Color.rgb(40, 40, 45), /// Radio fill when selected fill: Style.Color = Style.Color.primary, /// Label text label: Style.Color = Style.Color.rgb(220, 220, 220), /// Disabled label text label_disabled: Style.Color = Style.Color.rgb(100, 100, 100), }; /// Radio result pub const RadioResult = struct { /// Selection changed changed: bool = false, /// Newly selected index selected: ?usize = null, /// Newly selected value value: ?u32 = null, }; // ============================================================================= // Radio Functions // ============================================================================= /// Draw a radio group pub fn radioGroup( ctx: *Context, state: *RadioState, options: []const RadioOption, ) RadioResult { return radioGroupEx(ctx, state, options, .{}, .{}); } /// Draw a radio group with configuration pub fn radioGroupEx( ctx: *Context, state: *RadioState, options: []const RadioOption, config: RadioConfig, colors: RadioColors, ) RadioResult { const bounds = ctx.layout.nextRect(); return radioGroupRect(ctx, bounds, state, options, config, colors); } /// Draw a radio group in a specific rectangle pub fn radioGroupRect( ctx: *Context, bounds: Layout.Rect, state: *RadioState, options: []const RadioOption, config: RadioConfig, colors: RadioColors, ) RadioResult { var result = RadioResult{}; if (bounds.isEmpty() or options.len == 0) return result; // Generate unique ID for this widget based on state address const widget_id: u64 = @intFromPtr(state); // Register as focusable in the active focus group ctx.registerFocusable(widget_id); const mouse = ctx.input.mousePos(); const mouse_pressed = ctx.input.mousePressed(.left); // Check if group area clicked (for focus) if (mouse_pressed and bounds.contains(mouse.x, mouse.y)) { ctx.requestFocus(widget_id); } // Check if this widget has focus const has_focus = ctx.hasFocus(widget_id); state.focused = has_focus; // Draw options var pos_x = bounds.x; var pos_y = bounds.y; for (options, 0..) |opt, i| { const is_selected = state.selected == @as(i32, @intCast(i)); const is_focused = state.focused and state.focus_index == @as(i32, @intCast(i)); // Calculate option bounds const label_width: u32 = @intCast(opt.label.len * 8); const option_width = config.radio_size + config.label_padding + label_width; const option_height = @max(config.radio_size, 16); const option_rect = Layout.Rect.init(pos_x, pos_y, option_width, option_height); const is_hovered = option_rect.contains(mouse.x, mouse.y) and !opt.disabled; // Radio circle position const radio_x = pos_x; const radio_y = pos_y + @as(i32, @intCast((option_height -| config.radio_size) / 2)); // Draw radio circle outline const border_color = if (opt.disabled) colors.border.darken(20) else if (is_focused) colors.border_focus else colors.border; // Draw outer circle (as rect, since we don't have circle primitive) const corner_radius: u8 = @intCast(@min(config.radio_size / 2, 8)); if (Style.isFancy() and corner_radius > 0) { ctx.pushCommand(Command.roundedRectOutline(radio_x, radio_y, config.radio_size, config.radio_size, border_color, corner_radius)); ctx.pushCommand(Command.roundedRect(radio_x + 1, radio_y + 1, config.radio_size - 2, config.radio_size - 2, colors.background, corner_radius)); // Focus ring around the focused option if (is_focused) { ctx.pushCommand(Command.focusRing(radio_x, radio_y, config.radio_size, config.radio_size, corner_radius)); } } else { ctx.pushCommand(Command.rectOutline(radio_x, radio_y, config.radio_size, config.radio_size, border_color)); ctx.pushCommand(Command.rect(radio_x + 1, radio_y + 1, config.radio_size - 2, config.radio_size - 2, colors.background)); } // Draw fill if selected if (is_selected) { const fill_margin: u32 = 4; const fill_size = config.radio_size -| (fill_margin * 2); const fill_color = if (opt.disabled) colors.fill.darken(30) else colors.fill; const fill_radius: u8 = @intCast(@min(fill_size / 2, 6)); if (Style.isFancy() and fill_radius > 0) { ctx.pushCommand(Command.roundedRect( radio_x + @as(i32, @intCast(fill_margin)), radio_y + @as(i32, @intCast(fill_margin)), fill_size, fill_size, fill_color, fill_radius, )); } else { ctx.pushCommand(Command.rect( radio_x + @as(i32, @intCast(fill_margin)), radio_y + @as(i32, @intCast(fill_margin)), fill_size, fill_size, fill_color, )); } } // Draw label const label_x = pos_x + @as(i32, @intCast(config.radio_size + config.label_padding)); const label_y = pos_y + @as(i32, @intCast((option_height -| 8) / 2)); const label_color = if (opt.disabled) colors.label_disabled else colors.label; ctx.pushCommand(Command.text(label_x, label_y, opt.label, label_color)); // Handle click if (mouse_pressed and is_hovered) { if (state.selected != @as(i32, @intCast(i))) { state.selected = @intCast(i); state.focus_index = @intCast(i); result.changed = true; result.selected = i; result.value = opt.value; } } // Update position for next option if (config.direction == .vertical) { pos_y += @as(i32, @intCast(option_height + config.spacing)); } else { pos_x += @as(i32, @intCast(option_width + config.spacing)); } } // Handle keyboard navigation if (state.focused) { const nav_prev = if (config.direction == .vertical) ctx.input.keyPressed(.up) else ctx.input.keyPressed(.left); const nav_next = if (config.direction == .vertical) ctx.input.keyPressed(.down) else ctx.input.keyPressed(.right); if (nav_prev) { state.focusPrev(options); } if (nav_next) { state.focusNext(options); } if (ctx.input.keyPressed(.space) or ctx.input.keyPressed(.enter)) { const focus_idx: usize = @intCast(state.focus_index); if (focus_idx < options.len and !options[focus_idx].disabled) { if (state.selected != state.focus_index) { state.selected = state.focus_index; result.changed = true; result.selected = focus_idx; result.value = options[focus_idx].value; } } } } return result; } // ============================================================================= // Single Radio Button // ============================================================================= /// Draw a single radio button (for custom layouts) pub fn radioButton( ctx: *Context, label: []const u8, selected: bool, disabled: bool, ) bool { return radioButtonEx(ctx, label, selected, disabled, .{}, .{}); } /// Draw a single radio button with configuration pub fn radioButtonEx( ctx: *Context, label: []const u8, selected: bool, disabled: bool, config: RadioConfig, colors: RadioColors, ) bool { const bounds = ctx.layout.nextRect(); if (bounds.isEmpty()) return false; const mouse = ctx.input.mousePos(); const mouse_pressed = ctx.input.mousePressed(.left); const is_hovered = bounds.contains(mouse.x, mouse.y) and !disabled; var clicked = false; // Radio circle position const radio_x = bounds.x; const radio_y = bounds.y + @as(i32, @intCast((bounds.h -| config.radio_size) / 2)); // Draw radio circle outline const border_color = if (disabled) colors.border.darken(20) else colors.border; ctx.pushCommand(Command.rectOutline(radio_x, radio_y, config.radio_size, config.radio_size, border_color)); ctx.pushCommand(Command.rect(radio_x + 1, radio_y + 1, config.radio_size - 2, config.radio_size - 2, colors.background)); // Draw fill if selected if (selected) { const fill_margin: u32 = 4; const fill_size = config.radio_size -| (fill_margin * 2); ctx.pushCommand(Command.rect( radio_x + @as(i32, @intCast(fill_margin)), radio_y + @as(i32, @intCast(fill_margin)), fill_size, fill_size, if (disabled) colors.fill.darken(30) else colors.fill, )); } // Draw label const label_x = bounds.x + @as(i32, @intCast(config.radio_size + config.label_padding)); const label_y = bounds.y + @as(i32, @intCast((bounds.h -| 8) / 2)); const label_color = if (disabled) colors.label_disabled else colors.label; ctx.pushCommand(Command.text(label_x, label_y, label, label_color)); // Handle click if (mouse_pressed and is_hovered) { clicked = true; } return clicked; } // ============================================================================= // Convenience Functions // ============================================================================= /// Create radio group from string labels pub fn radioFromLabels( ctx: *Context, state: *RadioState, labels: []const []const u8, ) RadioResult { var options: [32]RadioOption = undefined; const count = @min(labels.len, options.len); for (0..count) |i| { options[i] = .{ .label = labels[i], .value = @intCast(i) }; } return radioGroup(ctx, state, options[0..count]); } // ============================================================================= // Tests // ============================================================================= test "RadioState focus navigation" { const options = [_]RadioOption{ .{ .label = "Option 1" }, .{ .label = "Option 2", .disabled = true }, .{ .label = "Option 3" }, }; var state = RadioState{ .focus_index = 0 }; state.focusNext(&options); try std.testing.expectEqual(@as(i32, 2), state.focus_index); // Skips disabled state.focusNext(&options); try std.testing.expectEqual(@as(i32, 0), state.focus_index); // Wraps state.focusPrev(&options); try std.testing.expectEqual(@as(i32, 2), state.focus_index); // Wraps back, skips disabled } test "RadioState getSelectedValue" { const options = [_]RadioOption{ .{ .label = "A", .value = 10 }, .{ .label = "B", .value = 20 }, .{ .label = "C", .value = 30 }, }; var state = RadioState{ .selected = 1 }; try std.testing.expectEqual(@as(?u32, 20), state.getSelectedValue(&options)); state.selected = -1; try std.testing.expectEqual(@as(?u32, null), state.getSelectedValue(&options)); } test "RadioState setSelectedValue" { const options = [_]RadioOption{ .{ .label = "A", .value = 10 }, .{ .label = "B", .value = 20 }, .{ .label = "C", .value = 30 }, }; var state = RadioState{}; state.setSelectedValue(&options, 20); try std.testing.expectEqual(@as(i32, 1), state.selected); state.setSelectedValue(&options, 30); try std.testing.expectEqual(@as(i32, 2), state.selected); } test "radioGroup generates commands" { var ctx = try Context.init(std.testing.allocator, 800, 600); defer ctx.deinit(); var state = RadioState{ .selected = 0 }; const options = [_]RadioOption{ .{ .label = "Option A" }, .{ .label = "Option B" }, }; ctx.beginFrame(); ctx.layout.row_height = 100; _ = radioGroup(&ctx, &state, &options); // Should generate: outline + bg + fill (for selected) + label per option try std.testing.expect(ctx.commands.items.len >= 5); ctx.endFrame(); }