zcatgui/src/widgets/select.zig
reugenio 3517a6f972 docs: Documentar sistema de focus completado y widgets adaptados
- Actualizar FOCUS_TRANSITION_2025-12-11.md con patrón de integración
- Actualizar CLAUDE.md: sección SISTEMA DE FOCUS - RESUELTO
- Widgets adaptados a FocusSystem:
  - numberentry.zig: registerFocusable, requestFocus, hasFocus
  - textarea.zig: registerFocusable, requestFocus, hasFocus
  - select.zig: campo focused, integración completa
  - radio.zig: reemplazado focus manual por FocusSystem
  - slider.zig: reemplazado focus manual por FocusSystem
  - tabs.zig: navegación teclado solo cuando tiene focus

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 18:50:37 +01:00

322 lines
9.6 KiB
Zig

//! Select Widget - Dropdown selection
//!
//! A dropdown menu for selecting one option from a list.
//! The dropdown opens on click and closes when an option is selected.
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");
/// Select state (caller-managed)
pub const SelectState = struct {
/// Currently selected index (-1 for none)
selected: i32 = -1,
/// Whether dropdown is open
open: bool = false,
/// Scroll offset in dropdown (for many items)
scroll_offset: usize = 0,
/// Whether this widget has focus
focused: bool = false,
/// Get selected index as optional usize
pub fn selectedIndex(self: SelectState) ?usize {
if (self.selected < 0) return null;
return @intCast(self.selected);
}
};
/// Select configuration
pub const SelectConfig = struct {
/// Placeholder text when nothing selected
placeholder: []const u8 = "Select...",
/// Disabled state
disabled: bool = false,
/// Maximum visible items in dropdown
max_visible_items: usize = 8,
/// Height of each item
item_height: u32 = 24,
/// Padding
padding: u32 = 4,
};
/// Select result
pub const SelectResult = struct {
/// Selection changed this frame
changed: bool,
/// Newly selected index (valid if changed)
new_index: ?usize,
};
/// Draw a select dropdown
pub fn select(
ctx: *Context,
state: *SelectState,
options: []const []const u8,
) SelectResult {
return selectEx(ctx, state, options, .{});
}
/// Draw a select dropdown with custom configuration
pub fn selectEx(
ctx: *Context,
state: *SelectState,
options: []const []const u8,
config: SelectConfig,
) SelectResult {
const bounds = ctx.layout.nextRect();
return selectRect(ctx, bounds, state, options, config);
}
/// Draw a select dropdown in a specific rectangle
pub fn selectRect(
ctx: *Context,
bounds: Layout.Rect,
state: *SelectState,
options: []const []const u8,
config: SelectConfig,
) SelectResult {
var result = SelectResult{
.changed = false,
.new_index = null,
};
if (bounds.isEmpty()) 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 theme = Style.Theme.dark;
// Check mouse interaction on main button
const mouse = ctx.input.mousePos();
const hovered = bounds.contains(mouse.x, mouse.y) and !config.disabled;
const clicked = hovered and ctx.input.mousePressed(.left);
// Toggle dropdown on click and request focus
if (clicked) {
ctx.requestFocus(widget_id);
state.open = !state.open;
}
// Check if this widget has focus
const has_focus = ctx.hasFocus(widget_id);
state.focused = has_focus;
// Determine button colors
const bg_color = if (config.disabled)
theme.button_bg.darken(20)
else if (state.open)
theme.button_bg.lighten(10)
else if (hovered)
theme.button_bg.lighten(5)
else
theme.button_bg;
const border_color = if (has_focus or state.open) theme.primary else theme.border;
// Draw main button background
ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, bg_color));
ctx.pushCommand(Command.rectOutline(bounds.x, bounds.y, bounds.w, bounds.h, border_color));
// Draw selected text or placeholder
const display_text = if (state.selectedIndex()) |idx|
if (idx < options.len) options[idx] else config.placeholder
else
config.placeholder;
const text_color = if (config.disabled)
theme.foreground.darken(40)
else if (state.selected < 0)
theme.secondary
else
theme.foreground;
const inner = bounds.shrink(config.padding);
const char_height: u32 = 8;
const text_y = inner.y + @as(i32, @intCast((inner.h -| char_height) / 2));
ctx.pushCommand(Command.text(inner.x, text_y, display_text, text_color));
// Draw dropdown arrow
const arrow_size: u32 = 8;
const arrow_x = bounds.x + @as(i32, @intCast(bounds.w)) - @as(i32, @intCast(config.padding + arrow_size));
const arrow_y = bounds.y + @as(i32, @intCast((bounds.h -| arrow_size) / 2));
// Simple arrow: draw a "v" shape
const arrow_color = if (config.disabled) theme.secondary.darken(20) else theme.foreground;
ctx.pushCommand(Command.line(
arrow_x,
arrow_y,
arrow_x + @as(i32, @intCast(arrow_size / 2)),
arrow_y + @as(i32, @intCast(arrow_size / 2)),
arrow_color,
));
ctx.pushCommand(Command.line(
arrow_x + @as(i32, @intCast(arrow_size / 2)),
arrow_y + @as(i32, @intCast(arrow_size / 2)),
arrow_x + @as(i32, @intCast(arrow_size)),
arrow_y,
arrow_color,
));
// Draw dropdown list if open
if (state.open and options.len > 0) {
const visible_items = @min(options.len, config.max_visible_items);
const dropdown_h = visible_items * config.item_height;
const dropdown_y = bounds.y + @as(i32, @intCast(bounds.h));
// Dropdown background
ctx.pushCommand(Command.rect(
bounds.x,
dropdown_y,
bounds.w,
@intCast(dropdown_h),
theme.background.lighten(5),
));
ctx.pushCommand(Command.rectOutline(
bounds.x,
dropdown_y,
bounds.w,
@intCast(dropdown_h),
theme.border,
));
// Draw visible items
var item_y = dropdown_y;
const start = state.scroll_offset;
const end = @min(start + visible_items, options.len);
for (start..end) |i| {
const item_bounds = Layout.Rect.init(
bounds.x,
item_y,
bounds.w,
config.item_height,
);
const item_hovered = item_bounds.contains(mouse.x, mouse.y);
const item_clicked = item_hovered and ctx.input.mousePressed(.left);
// Item background
const item_bg = if (state.selected == @as(i32, @intCast(i)))
theme.selection_bg
else if (item_hovered)
theme.button_hover
else
Style.Color.transparent;
if (item_bg.a > 0) {
ctx.pushCommand(Command.rect(
item_bounds.x + 1,
item_bounds.y,
item_bounds.w - 2,
item_bounds.h,
item_bg,
));
}
// Item text
const item_inner = item_bounds.shrink(config.padding);
const item_text_y = item_inner.y + @as(i32, @intCast((item_inner.h -| char_height) / 2));
const item_text_color = if (state.selected == @as(i32, @intCast(i)))
theme.selection_fg
else
theme.foreground;
ctx.pushCommand(Command.text(item_inner.x, item_text_y, options[i], item_text_color));
// Handle selection
if (item_clicked) {
state.selected = @intCast(i);
state.open = false;
result.changed = true;
result.new_index = i;
}
item_y += @as(i32, @intCast(config.item_height));
}
// Close dropdown if clicked outside
if (ctx.input.mousePressed(.left) and !bounds.contains(mouse.x, mouse.y)) {
// Check if click is in dropdown area
const dropdown_bounds = Layout.Rect.init(
bounds.x,
dropdown_y,
bounds.w,
@intCast(dropdown_h),
);
if (!dropdown_bounds.contains(mouse.x, mouse.y)) {
state.open = false;
}
}
}
return result;
}
/// Get selected option text
pub fn getSelectedText(state: SelectState, options: []const []const u8) ?[]const u8 {
if (state.selectedIndex()) |idx| {
if (idx < options.len) {
return options[idx];
}
}
return null;
}
// =============================================================================
// Tests
// =============================================================================
test "select opens on click" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
var state = SelectState{};
const options = [_][]const u8{ "Option 1", "Option 2", "Option 3" };
// Frame 1: Click to open
ctx.beginFrame();
ctx.layout.row_height = 30;
ctx.input.setMousePos(50, 15);
ctx.input.setMouseButton(.left, true);
_ = select(&ctx, &state, &options);
ctx.endFrame();
try std.testing.expect(state.open);
}
test "select generates commands" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
var state = SelectState{};
const options = [_][]const u8{ "A", "B", "C" };
ctx.beginFrame();
ctx.layout.row_height = 30;
_ = select(&ctx, &state, &options);
// Should generate: rect (bg) + rect_outline (border) + text + 2 lines (arrow)
try std.testing.expect(ctx.commands.items.len >= 4);
ctx.endFrame();
}
test "SelectState selectedIndex" {
var state = SelectState{ .selected = 2 };
try std.testing.expectEqual(@as(?usize, 2), state.selectedIndex());
state.selected = -1;
try std.testing.expectEqual(@as(?usize, null), state.selectedIndex());
}