Widgets actualizados: - NumberEntry: esquinas redondeadas + focus ring - Radio: esquinas redondeadas para círculos + focus ring en opción - Slider: esquinas redondeadas en track/thumb + focus ring - Tabs: esquinas redondeadas en tab seleccionado + focus ring - Table: focus ring alrededor de toda la tabla - TextArea: esquinas redondeadas + focus ring Nuevos campos: - TableColors.focus_ring para consistencia Total: +135 LOC en 7 archivos 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
500 lines
16 KiB
Zig
500 lines
16 KiB
Zig
//! 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();
|
|
}
|