//! 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. //! Supports smooth hover transitions via HoverTransition. 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"); const animation = @import("../render/animation.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, /// Hover transition for smooth effects hover: animation.HoverTransition = .{}, /// Last frame time for delta calculation last_time_ms: u64 = 0, /// 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, /// Corner radius (default 3 for fancy mode) corner_radius: u8 = 3, }; /// 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; // Update hover transition const current_time = ctx.current_time_ms; const dt_ms: u64 = if (state.last_time_ms > 0 and current_time > state.last_time_ms) current_time - state.last_time_ms else 16; state.last_time_ms = current_time; state.hover.update(hovered and !config.disabled and !state.open, dt_ms); // Determine button colors with smooth transition const bg_color = if (config.disabled) theme.button_bg.darken(20) else if (state.open) theme.button_bg.lighten(10) else state.hover.blend(theme.button_bg, theme.button_bg.lighten(5)); const border_color = if (has_focus or state.open) theme.primary else theme.border; // Draw main button background based on render mode if (Style.isFancy() and config.corner_radius > 0) { ctx.pushCommand(Command.roundedRect(bounds.x, bounds.y, bounds.w, bounds.h, bg_color, config.corner_radius)); ctx.pushCommand(Command.roundedRectOutline(bounds.x, bounds.y, bounds.w, bounds.h, border_color, config.corner_radius)); // Draw focus ring when focused (not when dropdown is open) if (has_focus and !state.open) { ctx.pushCommand(Command.focusRing(bounds.x, bounds.y, bounds.w, bounds.h, config.corner_radius)); } } else { 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)); // Check render mode for fancy features const fancy = Style.isFancy() and config.corner_radius > 0; // Draw dropdown shadow first (behind dropdown) in fancy mode if (fancy) { ctx.pushCommand(Command.shadowDrop(bounds.x, dropdown_y, bounds.w, @intCast(dropdown_h), config.corner_radius)); } // Dropdown background if (fancy) { ctx.pushCommand(Command.roundedRect( bounds.x, dropdown_y, bounds.w, @intCast(dropdown_h), theme.background.lighten(5), config.corner_radius, )); ctx.pushCommand(Command.roundedRectOutline( bounds.x, dropdown_y, bounds.w, @intCast(dropdown_h), theme.border, config.corner_radius, )); } else { 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()); }