//! Selectable Widget - Clickable/selectable region //! //! A region that can be clicked and selected, with hover feedback. //! Used for building custom interactive components. 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"); /// Selection mode pub const SelectionMode = enum { /// Single selection (click toggles) single, /// Multi-selection (shift+click, ctrl+click) multi, /// Required selection (always has one selected) required, }; /// Selectable state pub const State = struct { /// Is currently selected is_selected: bool = false, /// Is currently focused is_focused: bool = false, /// Is being pressed is_pressed: bool = false, pub fn init() State { return .{}; } pub fn select(self: *State) void { self.is_selected = true; } pub fn deselect(self: *State) void { self.is_selected = false; } pub fn toggle(self: *State) void { self.is_selected = !self.is_selected; } }; /// Selectable configuration pub const Config = struct { /// Selection mode mode: SelectionMode = .single, /// Disabled state disabled: bool = false, /// Show selection indicator show_indicator: bool = true, /// Show focus ring show_focus: bool = true, /// Padding around content padding: u16 = 8, /// Border radius (visual hint) rounded: bool = true, }; /// Selectable colors pub const Colors = struct { /// Normal background background: Style.Color = Style.Color.rgba(0, 0, 0, 0), /// Hover background hover: Style.Color = Style.Color.rgba(255, 255, 255, 15), /// Pressed background pressed: Style.Color = Style.Color.rgba(255, 255, 255, 25), /// Selected background selected: Style.Color = Style.Color.rgba(66, 133, 244, 30), /// Selection indicator indicator: Style.Color = Style.Color.rgb(66, 133, 244), /// Focus ring focus: Style.Color = Style.Color.rgb(66, 133, 244), /// Disabled overlay disabled: Style.Color = Style.Color.rgba(128, 128, 128, 80), pub fn fromTheme(theme: Style.Theme) Colors { return .{ .background = Style.Color.transparent, .hover = theme.foreground.withAlpha(15), .pressed = theme.foreground.withAlpha(25), .selected = theme.primary.withAlpha(30), .indicator = theme.primary, .focus = theme.primary, .disabled = Style.Color.rgba(128, 128, 128, 80), }; } }; /// Selectable result pub const Result = struct { /// Was clicked this frame clicked: bool, /// Is hovered hovered: bool, /// Is selected selected: bool, /// Is focused focused: bool, /// Content area (inside padding) content_rect: Layout.Rect, /// Total bounds bounds: Layout.Rect, }; /// Simple selectable region pub fn selectable(ctx: *Context, state: *State) Result { return selectableEx(ctx, state, .{}, .{}); } /// Selectable with configuration pub fn selectableEx(ctx: *Context, state: *State, config: Config, colors: Colors) Result { const rect = ctx.layout.nextRect(); return selectableRect(ctx, rect, state, config, colors); } /// Selectable in specific rectangle pub fn selectableRect( ctx: *Context, bounds: Layout.Rect, state: *State, config: Config, colors: Colors, ) Result { if (bounds.isEmpty()) { return .{ .clicked = false, .hovered = false, .selected = state.is_selected, .focused = state.is_focused, .content_rect = Layout.Rect{ .x = 0, .y = 0, .w = 0, .h = 0 }, .bounds = bounds, }; } // Mouse interaction const mouse = ctx.input.mousePos(); const hovered = bounds.contains(mouse.x, mouse.y) and !config.disabled; const pressed = hovered and ctx.input.mousePressed(.left); const released = hovered and ctx.input.mouseReleased(.left); state.is_pressed = pressed; var clicked = false; // Handle click if (released and !config.disabled) { clicked = true; switch (config.mode) { .single => state.toggle(), .multi => state.toggle(), // Multi handled externally with modifiers .required => state.select(), } } // Determine background color var bg_color = colors.background; if (state.is_selected) { bg_color = colors.selected; } if (hovered and !state.is_pressed) { bg_color = if (state.is_selected) blendColors(colors.selected, colors.hover) else colors.hover; } if (state.is_pressed) { bg_color = colors.pressed; } // Draw background if (bg_color.a > 0) { ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, bg_color)); } // Draw selection indicator if (config.show_indicator and state.is_selected) { ctx.pushCommand(Command.rect(bounds.x, bounds.y, 3, bounds.h, colors.indicator)); } // Draw focus ring if (config.show_focus and state.is_focused) { ctx.pushCommand(Command.rectOutline( bounds.x - 1, bounds.y - 1, bounds.w + 2, bounds.h + 2, colors.focus, )); } // Draw disabled overlay if (config.disabled) { ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, colors.disabled)); } // Calculate content rect const padding = @as(i32, @intCast(config.padding)); const content_rect = Layout.Rect{ .x = bounds.x + padding, .y = bounds.y + padding, .w = bounds.w -| @as(u32, @intCast(config.padding * 2)), .h = bounds.h -| @as(u32, @intCast(config.padding * 2)), }; return .{ .clicked = clicked, .hovered = hovered, .selected = state.is_selected, .focused = state.is_focused, .content_rect = content_rect, .bounds = bounds, }; } /// Simple color blending (overlay) fn blendColors(base: Style.Color, overlay: Style.Color) Style.Color { const alpha = @as(f32, @floatFromInt(overlay.a)) / 255.0; const inv_alpha = 1.0 - alpha; return Style.Color.rgba( @intFromFloat(@as(f32, @floatFromInt(base.r)) * inv_alpha + @as(f32, @floatFromInt(overlay.r)) * alpha), @intFromFloat(@as(f32, @floatFromInt(base.g)) * inv_alpha + @as(f32, @floatFromInt(overlay.g)) * alpha), @intFromFloat(@as(f32, @floatFromInt(base.b)) * inv_alpha + @as(f32, @floatFromInt(overlay.b)) * alpha), @max(base.a, overlay.a), ); } // ============================================================================= // Group selection helpers // ============================================================================= /// Selection group for managing multiple selectables pub const SelectionGroup = struct { /// Selected indices selected: std.ArrayListUnmanaged(usize), /// Selection mode mode: SelectionMode, /// Allocator allocator: std.mem.Allocator, pub fn init(allocator: std.mem.Allocator, mode: SelectionMode) SelectionGroup { return .{ .selected = .{}, .mode = mode, .allocator = allocator, }; } pub fn deinit(self: *SelectionGroup) void { self.selected.deinit(self.allocator); } pub fn isSelected(self: *const SelectionGroup, index: usize) bool { for (self.selected.items) |sel| { if (sel == index) return true; } return false; } pub fn select(self: *SelectionGroup, index: usize) !void { switch (self.mode) { .single, .required => { self.selected.clearRetainingCapacity(); try self.selected.append(self.allocator, index); }, .multi => { if (!self.isSelected(index)) { try self.selected.append(self.allocator, index); } }, } } pub fn deselect(self: *SelectionGroup, index: usize) void { if (self.mode == .required and self.selected.items.len <= 1) { return; // Can't deselect last item in required mode } for (self.selected.items, 0..) |sel, i| { if (sel == index) { _ = self.selected.orderedRemove(i); break; } } } pub fn toggle(self: *SelectionGroup, index: usize) !void { if (self.isSelected(index)) { self.deselect(index); } else { try self.select(index); } } pub fn clear(self: *SelectionGroup) void { if (self.mode != .required) { self.selected.clearRetainingCapacity(); } } }; // ============================================================================= // Tests // ============================================================================= test "selectable state" { var state = State.init(); try std.testing.expect(!state.is_selected); state.toggle(); try std.testing.expect(state.is_selected); state.deselect(); try std.testing.expect(!state.is_selected); state.select(); try std.testing.expect(state.is_selected); } test "selectable generates commands" { var ctx = try Context.init(std.testing.allocator, 800, 600); defer ctx.deinit(); var state = State.init(); ctx.beginFrame(); ctx.layout.row_height = 40; const result = selectable(&ctx, &state); try std.testing.expect(!result.clicked); try std.testing.expect(!result.selected); ctx.endFrame(); } test "selectable selected state" { var ctx = try Context.init(std.testing.allocator, 800, 600); defer ctx.deinit(); var state = State.init(); state.is_selected = true; ctx.beginFrame(); ctx.layout.row_height = 40; const result = selectableEx(&ctx, &state, .{ .show_indicator = true, }, .{}); try std.testing.expect(result.selected); // Should have background + indicator commands try std.testing.expect(ctx.commands.items.len >= 2); ctx.endFrame(); } test "selection group single mode" { var group = SelectionGroup.init(std.testing.allocator, .single); defer group.deinit(); try group.select(0); try std.testing.expect(group.isSelected(0)); try group.select(1); try std.testing.expect(!group.isSelected(0)); // Previous deselected try std.testing.expect(group.isSelected(1)); } test "selection group multi mode" { var group = SelectionGroup.init(std.testing.allocator, .multi); defer group.deinit(); try group.select(0); try group.select(1); try group.select(2); try std.testing.expect(group.isSelected(0)); try std.testing.expect(group.isSelected(1)); try std.testing.expect(group.isSelected(2)); group.deselect(1); try std.testing.expect(!group.isSelected(1)); } test "selection group required mode" { var group = SelectionGroup.init(std.testing.allocator, .required); defer group.deinit(); try group.select(0); try std.testing.expect(group.isSelected(0)); // Can't deselect in required mode with only one selection group.deselect(0); try std.testing.expect(group.isSelected(0)); // Still selected }