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>
403 lines
11 KiB
Zig
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
|
|
}
|