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