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