zcatgui/src/widgets/selectable.zig
reugenio 91e13f6956 feat: zcatgui Gio parity - 12 new widgets + gesture system
New widgets (12):
- Switch: Toggle switch with animation
- IconButton: Circular icon button (filled/outlined/ghost/tonal)
- Divider: Horizontal/vertical separator with optional label
- Loader: 7 spinner styles (circular/dots/bars/pulse/bounce/ring/square)
- Surface: Elevated container with shadow layers
- Grid: Layout grid with scrolling and selection
- Resize: Draggable resize handle (horizontal/vertical/both)
- AppBar: Application bar (top/bottom) with actions
- NavDrawer: Navigation drawer with items, icons, badges
- Sheet: Side/bottom sliding panel with modal support
- Discloser: Expandable/collapsible container (3 icon styles)
- Selectable: Clickable region with selection modes

Core systems added:
- GestureRecognizer: Tap, double-tap, long-press, drag, swipe
- Velocity tracking and fling detection
- Spring physics for fluid animations

Integration:
- All widgets exported via widgets.zig
- GestureRecognizer exported via zcatgui.zig
- Spring/SpringConfig exported from animation.zig
- Color.withAlpha() method added to style.zig

Stats: 47 widget files, 338+ tests, +5,619 LOC
Full Gio UI parity achieved.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 17:21:15 +01:00

403 lines
11 KiB
Zig

//! 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
}