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>
338 lines
9.8 KiB
Zig
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();
|
|
}
|
|
}
|