zcatgui/src/widgets/discloser.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

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();
}
}