zcatgui/src/widgets/radio.zig
reugenio e0cbbf6413 feat: Focus ring AA para todos los widgets focusables
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>
2025-12-17 09:24:50 +01:00

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();
}