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

338 lines
9.8 KiB
Zig

//! Sheet Widget - Side/Bottom panel
//!
//! A panel that slides in from the side or bottom.
//! Can be static or modal with scrim overlay.
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");
/// Sheet side/position
pub const Side = enum {
left,
right,
bottom,
};
/// Sheet state
pub const State = struct {
/// Is sheet open
is_open: bool = false,
/// Animation progress (0 = closed, 1 = open)
animation_progress: f32 = 0,
pub fn init() State {
return .{};
}
pub fn open(self: *State) void {
self.is_open = true;
}
pub fn close(self: *State) void {
self.is_open = false;
}
pub fn toggle(self: *State) void {
self.is_open = !self.is_open;
}
};
/// Sheet configuration
pub const Config = struct {
/// Which side the sheet appears from
side: Side = .right,
/// Width for left/right sheets
width: u16 = 320,
/// Height for bottom sheet
height: u16 = 400,
/// Show drag handle
show_handle: bool = true,
/// Modal (with scrim)
modal: bool = true,
/// Can be dismissed by clicking outside
dismiss_on_outside: bool = true,
/// Animation speed
animation_speed: f32 = 0.1,
};
/// Sheet colors
pub const Colors = struct {
/// Sheet background
background: Style.Color = Style.Color.rgb(40, 40, 40),
/// Handle color
handle: Style.Color = Style.Color.rgb(80, 80, 80),
/// Border/shadow
shadow: Style.Color = Style.Color.rgba(0, 0, 0, 60),
/// Scrim overlay
scrim: Style.Color = Style.Color.rgba(0, 0, 0, 120),
pub fn fromTheme(theme: Style.Theme) Colors {
return .{
.background = theme.panel_bg,
.handle = theme.border,
.shadow = Style.Color.rgba(0, 0, 0, 60),
.scrim = Style.Color.rgba(0, 0, 0, 120),
};
}
};
/// Sheet result
pub const Result = struct {
/// Sheet is visible
visible: bool,
/// Sheet was dismissed this frame
dismissed: bool,
/// Content area inside the sheet
content_rect: Layout.Rect,
/// Sheet bounds
bounds: Layout.Rect,
};
/// Simple sheet
pub fn sheet(ctx: *Context, state: *State) Result {
return sheetEx(ctx, state, .{}, .{});
}
/// Sheet with configuration
pub fn sheetEx(ctx: *Context, state: *State, config: Config, colors: Colors) Result {
// Update animation
const target: f32 = if (state.is_open) 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);
}
// Not visible
if (state.animation_progress < 0.01) {
return .{
.visible = false,
.dismissed = false,
.content_rect = Layout.Rect{ .x = 0, .y = 0, .w = 0, .h = 0 },
.bounds = Layout.Rect{ .x = 0, .y = 0, .w = 0, .h = 0 },
};
}
var dismissed = false;
const mouse = ctx.input.mousePos();
// Draw scrim if modal
if (config.modal) {
const scrim_alpha = @as(u8, @intFromFloat(@as(f32, @floatFromInt(colors.scrim.a)) * state.animation_progress));
ctx.pushCommand(Command.rect(
0,
0,
ctx.layout.area.w,
ctx.layout.area.h,
colors.scrim.withAlpha(scrim_alpha),
));
}
// Calculate sheet position based on side and animation
const bounds = calculateBounds(ctx, state.animation_progress, config);
// Check for outside click to dismiss
if (config.dismiss_on_outside and config.modal) {
if (ctx.input.mouseReleased(.left) and !bounds.contains(mouse.x, mouse.y)) {
state.close();
dismissed = true;
}
}
// Draw shadow
drawShadow(ctx, bounds, config.side, colors.shadow);
// Draw background
ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, colors.background));
// Draw handle
if (config.show_handle) {
drawHandle(ctx, bounds, config.side, colors.handle);
}
// Calculate content rect (inside padding)
const padding: i32 = 16;
const handle_offset: i32 = if (config.show_handle) 32 else 0;
const content_rect = switch (config.side) {
.bottom => Layout.Rect{
.x = bounds.x + padding,
.y = bounds.y + handle_offset,
.w = bounds.w -| @as(u32, @intCast(padding * 2)),
.h = bounds.h -| @as(u32, @intCast(handle_offset + padding)),
},
else => Layout.Rect{
.x = bounds.x + padding,
.y = bounds.y + padding,
.w = bounds.w -| @as(u32, @intCast(padding * 2)),
.h = bounds.h -| @as(u32, @intCast(padding * 2)),
},
};
return .{
.visible = true,
.dismissed = dismissed,
.content_rect = content_rect,
.bounds = bounds,
};
}
fn calculateBounds(ctx: *Context, progress: f32, config: Config) Layout.Rect {
return switch (config.side) {
.left => Layout.Rect{
.x = -@as(i32, @intCast(config.width)) + @as(i32, @intFromFloat(@as(f32, @floatFromInt(config.width)) * progress)),
.y = 0,
.w = config.width,
.h = ctx.layout.area.h,
},
.right => Layout.Rect{
.x = @as(i32, @intCast(ctx.layout.area.w)) - @as(i32, @intFromFloat(@as(f32, @floatFromInt(config.width)) * progress)),
.y = 0,
.w = config.width,
.h = ctx.layout.area.h,
},
.bottom => Layout.Rect{
.x = 0,
.y = @as(i32, @intCast(ctx.layout.area.h)) - @as(i32, @intFromFloat(@as(f32, @floatFromInt(config.height)) * progress)),
.w = ctx.layout.area.w,
.h = config.height,
},
};
}
fn drawShadow(ctx: *Context, bounds: Layout.Rect, side: Side, color: Style.Color) void {
const shadow_size: u32 = 8;
switch (side) {
.left => {
ctx.pushCommand(Command.rect(
bounds.x + @as(i32, @intCast(bounds.w)),
bounds.y,
shadow_size,
bounds.h,
color,
));
},
.right => {
ctx.pushCommand(Command.rect(
bounds.x - @as(i32, @intCast(shadow_size)),
bounds.y,
shadow_size,
bounds.h,
color,
));
},
.bottom => {
ctx.pushCommand(Command.rect(
bounds.x,
bounds.y - @as(i32, @intCast(shadow_size)),
bounds.w,
shadow_size,
color,
));
},
}
}
fn drawHandle(ctx: *Context, bounds: Layout.Rect, side: Side, color: Style.Color) void {
switch (side) {
.bottom => {
// Horizontal handle at top
const handle_w: u32 = 40;
const handle_h: u32 = 4;
const handle_x = bounds.x + @as(i32, @intCast((bounds.w - handle_w) / 2));
const handle_y = bounds.y + 12;
ctx.pushCommand(Command.rect(handle_x, handle_y, handle_w, handle_h, color));
},
.left => {
// Vertical handle on right edge
const handle_w: u32 = 4;
const handle_h: u32 = 40;
const handle_x = bounds.x + @as(i32, @intCast(bounds.w)) - 12;
const handle_y = bounds.y + @as(i32, @intCast((bounds.h - handle_h) / 2));
ctx.pushCommand(Command.rect(handle_x, handle_y, handle_w, handle_h, color));
},
.right => {
// Vertical handle on left edge
const handle_w: u32 = 4;
const handle_h: u32 = 40;
const handle_x = bounds.x + 8;
const handle_y = bounds.y + @as(i32, @intCast((bounds.h - handle_h) / 2));
ctx.pushCommand(Command.rect(handle_x, handle_y, handle_w, handle_h, color));
},
}
}
// =============================================================================
// Tests
// =============================================================================
test "sheet state" {
var state = State.init();
try std.testing.expect(!state.is_open);
state.open();
try std.testing.expect(state.is_open);
state.toggle();
try std.testing.expect(!state.is_open);
}
test "sheet closed is not visible" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
var state = State.init();
ctx.beginFrame();
const result = sheet(&ctx, &state);
try std.testing.expect(!result.visible);
ctx.endFrame();
}
test "sheet open generates commands" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
var state = State.init();
state.is_open = true;
state.animation_progress = 1.0;
ctx.beginFrame();
const result = sheetEx(&ctx, &state, .{ .side = .right }, .{});
try std.testing.expect(result.visible);
try std.testing.expect(ctx.commands.items.len >= 3);
ctx.endFrame();
}
test "sheet from different sides" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
const sides = [_]Side{ .left, .right, .bottom };
for (sides) |side| {
var state = State.init();
state.is_open = true;
state.animation_progress = 1.0;
ctx.beginFrame();
const result = sheetEx(&ctx, &state, .{ .side = side }, .{});
try std.testing.expect(result.visible);
try std.testing.expect(result.content_rect.w > 0);
ctx.endFrame();
}
}