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