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>
341 lines
10 KiB
Zig
341 lines
10 KiB
Zig
//! Discloser Widget - Expandable/collapsible container
|
|
//!
|
|
//! A disclosure triangle that reveals content when expanded.
|
|
//! Similar to HTML details/summary or macOS disclosure triangles.
|
|
|
|
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");
|
|
|
|
/// Discloser icon style
|
|
pub const IconStyle = enum {
|
|
/// Triangle arrow (default)
|
|
arrow,
|
|
/// Plus/minus signs
|
|
plus_minus,
|
|
/// Chevron
|
|
chevron,
|
|
};
|
|
|
|
/// Discloser state
|
|
pub const State = struct {
|
|
/// Is content expanded
|
|
is_expanded: bool = false,
|
|
/// Animation progress (0 = collapsed, 1 = expanded)
|
|
animation_progress: f32 = 0,
|
|
|
|
pub fn init(initially_expanded: bool) State {
|
|
return .{
|
|
.is_expanded = initially_expanded,
|
|
.animation_progress = if (initially_expanded) 1.0 else 0.0,
|
|
};
|
|
}
|
|
|
|
pub fn toggle(self: *State) void {
|
|
self.is_expanded = !self.is_expanded;
|
|
}
|
|
|
|
pub fn expand(self: *State) void {
|
|
self.is_expanded = true;
|
|
}
|
|
|
|
pub fn collapse(self: *State) void {
|
|
self.is_expanded = false;
|
|
}
|
|
};
|
|
|
|
/// Discloser configuration
|
|
pub const Config = struct {
|
|
/// Header label
|
|
label: []const u8,
|
|
/// Icon style
|
|
icon_style: IconStyle = .arrow,
|
|
/// Header height
|
|
header_height: u16 = 32,
|
|
/// Content height (when expanded)
|
|
content_height: u16 = 100,
|
|
/// Indentation for content
|
|
indent: u16 = 24,
|
|
/// Animation speed
|
|
animation_speed: f32 = 0.15,
|
|
/// Show border around content
|
|
show_border: bool = false,
|
|
};
|
|
|
|
/// Discloser colors
|
|
pub const Colors = struct {
|
|
/// Header background
|
|
header_bg: Style.Color = Style.Color.rgba(0, 0, 0, 0),
|
|
/// Header background (hover)
|
|
header_hover: Style.Color = Style.Color.rgba(255, 255, 255, 10),
|
|
/// Header text
|
|
header_text: Style.Color = Style.Color.rgb(220, 220, 220),
|
|
/// Icon color
|
|
icon: Style.Color = Style.Color.rgb(150, 150, 150),
|
|
/// Content background
|
|
content_bg: Style.Color = Style.Color.rgba(0, 0, 0, 0),
|
|
/// Border
|
|
border: Style.Color = Style.Color.rgb(60, 60, 60),
|
|
|
|
pub fn fromTheme(theme: Style.Theme) Colors {
|
|
return .{
|
|
.header_bg = Style.Color.transparent,
|
|
.header_hover = theme.foreground.withAlpha(10),
|
|
.header_text = theme.foreground,
|
|
.icon = theme.foreground.darken(30),
|
|
.content_bg = Style.Color.transparent,
|
|
.border = theme.border,
|
|
};
|
|
}
|
|
};
|
|
|
|
/// Discloser result
|
|
pub const Result = struct {
|
|
/// Header was clicked
|
|
clicked: bool,
|
|
/// Is currently expanded
|
|
expanded: bool,
|
|
/// Content area (where to draw child content)
|
|
content_rect: Layout.Rect,
|
|
/// Total bounds used
|
|
bounds: Layout.Rect,
|
|
/// Should draw content this frame
|
|
should_draw_content: bool,
|
|
};
|
|
|
|
/// Simple discloser
|
|
pub fn discloser(ctx: *Context, state: *State, label_text: []const u8) Result {
|
|
return discloserEx(ctx, state, .{ .label = label_text }, .{});
|
|
}
|
|
|
|
/// Discloser with configuration
|
|
pub fn discloserEx(ctx: *Context, state: *State, config: Config, colors: Colors) Result {
|
|
const header_rect = ctx.layout.nextRect();
|
|
return discloserRect(ctx, header_rect, state, config, colors);
|
|
}
|
|
|
|
/// Discloser in specific rectangle
|
|
pub fn discloserRect(
|
|
ctx: *Context,
|
|
header_rect: Layout.Rect,
|
|
state: *State,
|
|
config: Config,
|
|
colors: Colors,
|
|
) Result {
|
|
if (header_rect.isEmpty()) {
|
|
return .{
|
|
.clicked = false,
|
|
.expanded = state.is_expanded,
|
|
.content_rect = Layout.Rect{ .x = 0, .y = 0, .w = 0, .h = 0 },
|
|
.bounds = header_rect,
|
|
.should_draw_content = false,
|
|
};
|
|
}
|
|
|
|
// Update animation
|
|
const target: f32 = if (state.is_expanded) 1.0 else 0.0;
|
|
if (state.animation_progress < target) {
|
|
state.animation_progress = @min(target, state.animation_progress + config.animation_speed);
|
|
} else if (state.animation_progress > target) {
|
|
state.animation_progress = @max(target, state.animation_progress - config.animation_speed);
|
|
}
|
|
|
|
// Mouse interaction
|
|
const mouse = ctx.input.mousePos();
|
|
const hovered = header_rect.contains(mouse.x, mouse.y);
|
|
const clicked = hovered and ctx.input.mouseReleased(.left);
|
|
|
|
if (clicked) {
|
|
state.toggle();
|
|
}
|
|
|
|
// Draw header background
|
|
if (hovered) {
|
|
ctx.pushCommand(Command.rect(header_rect.x, header_rect.y, header_rect.w, header_rect.h, colors.header_hover));
|
|
}
|
|
|
|
// Draw icon
|
|
const icon_x = header_rect.x + 4;
|
|
const icon_y = header_rect.y + @as(i32, @intCast((header_rect.h - 16) / 2));
|
|
drawIcon(ctx, icon_x, icon_y, config.icon_style, state.animation_progress, colors.icon);
|
|
|
|
// Draw label
|
|
const label_x = header_rect.x + 24;
|
|
const label_y = header_rect.y + @as(i32, @intCast((header_rect.h - 8) / 2));
|
|
ctx.pushCommand(Command.text(label_x, label_y, config.label, colors.header_text));
|
|
|
|
// Calculate content area
|
|
const content_height = @as(u32, @intFromFloat(@as(f32, @floatFromInt(config.content_height)) * state.animation_progress));
|
|
const content_rect = Layout.Rect{
|
|
.x = header_rect.x + @as(i32, @intCast(config.indent)),
|
|
.y = header_rect.y + @as(i32, @intCast(config.header_height)),
|
|
.w = header_rect.w -| config.indent,
|
|
.h = content_height,
|
|
};
|
|
|
|
// Draw content background and clip
|
|
if (state.animation_progress > 0.01) {
|
|
if (colors.content_bg.a > 0) {
|
|
ctx.pushCommand(Command.rect(content_rect.x, content_rect.y, content_rect.w, content_rect.h, colors.content_bg));
|
|
}
|
|
|
|
if (config.show_border and state.animation_progress > 0.5) {
|
|
ctx.pushCommand(Command.rectOutline(
|
|
content_rect.x - 1,
|
|
content_rect.y,
|
|
content_rect.w + 2,
|
|
content_rect.h,
|
|
colors.border,
|
|
));
|
|
}
|
|
|
|
// Push clip for content
|
|
ctx.pushCommand(Command.clip(content_rect.x, content_rect.y, content_rect.w, content_rect.h));
|
|
}
|
|
|
|
const total_height = config.header_height + content_height;
|
|
const total_bounds = Layout.Rect{
|
|
.x = header_rect.x,
|
|
.y = header_rect.y,
|
|
.w = header_rect.w,
|
|
.h = total_height,
|
|
};
|
|
|
|
return .{
|
|
.clicked = clicked,
|
|
.expanded = state.is_expanded,
|
|
.content_rect = content_rect,
|
|
.bounds = total_bounds,
|
|
.should_draw_content = state.animation_progress > 0.01,
|
|
};
|
|
}
|
|
|
|
/// End discloser content (pop clip)
|
|
pub fn discloserEnd(ctx: *Context, result: Result) void {
|
|
if (result.should_draw_content) {
|
|
ctx.pushCommand(.clip_end);
|
|
}
|
|
}
|
|
|
|
fn drawIcon(ctx: *Context, x: i32, y: i32, style: IconStyle, progress: f32, color: Style.Color) void {
|
|
const size: i32 = 12;
|
|
const half = size / 2;
|
|
|
|
switch (style) {
|
|
.arrow => {
|
|
// Rotating triangle
|
|
if (progress < 0.5) {
|
|
// Right-pointing arrow
|
|
ctx.pushCommand(Command.line(x + 2, y + 2, x + size - 2, y + half, color));
|
|
ctx.pushCommand(Command.line(x + size - 2, y + half, x + 2, y + size - 2, color));
|
|
} else {
|
|
// Down-pointing arrow
|
|
ctx.pushCommand(Command.line(x + 2, y + 2, x + half, y + size - 2, color));
|
|
ctx.pushCommand(Command.line(x + half, y + size - 2, x + size - 2, y + 2, color));
|
|
}
|
|
},
|
|
.plus_minus => {
|
|
// Horizontal line (always)
|
|
ctx.pushCommand(Command.line(x + 2, y + half, x + size - 2, y + half, color));
|
|
// Vertical line (when collapsed)
|
|
if (progress < 0.5) {
|
|
ctx.pushCommand(Command.line(x + half, y + 2, x + half, y + size - 2, color));
|
|
}
|
|
},
|
|
.chevron => {
|
|
if (progress < 0.5) {
|
|
// Right chevron
|
|
ctx.pushCommand(Command.line(x + 3, y + 2, x + size - 3, y + half, color));
|
|
ctx.pushCommand(Command.line(x + size - 3, y + half, x + 3, y + size - 2, color));
|
|
} else {
|
|
// Down chevron
|
|
ctx.pushCommand(Command.line(x + 2, y + 3, x + half, y + size - 3, color));
|
|
ctx.pushCommand(Command.line(x + half, y + size - 3, x + size - 2, y + 3, color));
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Tests
|
|
// =============================================================================
|
|
|
|
test "discloser state" {
|
|
var state = State.init(false);
|
|
try std.testing.expect(!state.is_expanded);
|
|
|
|
state.toggle();
|
|
try std.testing.expect(state.is_expanded);
|
|
|
|
state.collapse();
|
|
try std.testing.expect(!state.is_expanded);
|
|
|
|
state.expand();
|
|
try std.testing.expect(state.is_expanded);
|
|
}
|
|
|
|
test "discloser generates commands" {
|
|
var ctx = try Context.init(std.testing.allocator, 800, 600);
|
|
defer ctx.deinit();
|
|
|
|
var state = State.init(false);
|
|
|
|
ctx.beginFrame();
|
|
ctx.layout.row_height = 32;
|
|
|
|
const result = discloser(&ctx, &state, "Section");
|
|
|
|
try std.testing.expect(ctx.commands.items.len >= 2);
|
|
try std.testing.expect(!result.expanded);
|
|
|
|
ctx.endFrame();
|
|
}
|
|
|
|
test "discloser expanded shows content" {
|
|
var ctx = try Context.init(std.testing.allocator, 800, 600);
|
|
defer ctx.deinit();
|
|
|
|
var state = State.init(true);
|
|
state.animation_progress = 1.0;
|
|
|
|
ctx.beginFrame();
|
|
ctx.layout.row_height = 32;
|
|
|
|
const result = discloserEx(&ctx, &state, .{
|
|
.label = "Section",
|
|
.content_height = 100,
|
|
}, .{});
|
|
|
|
try std.testing.expect(result.expanded);
|
|
try std.testing.expect(result.should_draw_content);
|
|
try std.testing.expect(result.content_rect.h > 0);
|
|
|
|
discloserEnd(&ctx, result);
|
|
ctx.endFrame();
|
|
}
|
|
|
|
test "discloser icon styles" {
|
|
var ctx = try Context.init(std.testing.allocator, 800, 600);
|
|
defer ctx.deinit();
|
|
|
|
const styles = [_]IconStyle{ .arrow, .plus_minus, .chevron };
|
|
|
|
for (styles) |style| {
|
|
var state = State.init(false);
|
|
|
|
ctx.beginFrame();
|
|
ctx.layout.row_height = 32;
|
|
|
|
_ = discloserEx(&ctx, &state, .{
|
|
.label = "Test",
|
|
.icon_style = style,
|
|
}, .{});
|
|
|
|
try std.testing.expect(ctx.commands.items.len >= 2);
|
|
|
|
ctx.endFrame();
|
|
}
|
|
}
|