- 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>
322 lines
9.6 KiB
Zig
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());
|
|
}
|