//! Panel Widget - Container with title bar //! //! A panel is a container that displays a title bar and content area. //! Similar to Fyne's InnerWindow but simpler. 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"); /// Panel state (caller-managed) pub const PanelState = struct { /// Whether the panel has focus focused: bool = false, /// Whether the panel is collapsed (title only) collapsed: bool = false, }; /// Panel configuration pub const PanelConfig = struct { /// Title text title: []const u8 = "", /// Title bar height title_height: u32 = 24, /// Border width border_width: u32 = 1, /// Padding inside content area content_padding: u32 = 4, /// Whether panel can be collapsed collapsible: bool = false, /// Show close button (X) closable: bool = false, /// Corner radius (default 6 for fancy mode) corner_radius: u8 = 6, /// Show shadow (fancy mode only) show_shadow: bool = true, }; /// Panel colors pub const PanelColors = struct { title_bg: Style.Color = Style.Color.rgb(50, 50, 55), title_bg_focused: Style.Color = Style.Color.rgb(60, 60, 70), title_fg: Style.Color = Style.Color.rgb(200, 200, 200), content_bg: Style.Color = Style.Color.rgb(35, 35, 40), border: Style.Color = Style.Color.rgb(70, 70, 75), border_focused: Style.Color = Style.Color.primary, shadow: Style.Color = Style.Color.rgba(0, 0, 0, 60), }; /// Panel result pub const PanelResult = struct { /// Content area rectangle (where child widgets should be drawn) content: Layout.Rect, /// Title bar was clicked title_clicked: bool, /// Close button was clicked close_clicked: bool, /// Collapse state changed collapse_changed: bool, }; /// Draw a panel and return the content area pub fn panel( ctx: *Context, state: *PanelState, title: []const u8, ) PanelResult { return panelEx(ctx, state, .{ .title = title }, .{}); } /// Draw a panel with custom configuration pub fn panelEx( ctx: *Context, state: *PanelState, config: PanelConfig, colors: PanelColors, ) PanelResult { const bounds = ctx.layout.nextRect(); return panelRect(ctx, bounds, state, config, colors); } /// Draw a panel in a specific rectangle pub fn panelRect( ctx: *Context, bounds: Layout.Rect, state: *PanelState, config: PanelConfig, colors: PanelColors, ) PanelResult { var result = PanelResult{ .content = Layout.Rect.zero(), .title_clicked = false, .close_clicked = false, .collapse_changed = false, }; if (bounds.isEmpty()) return result; const mouse = ctx.input.mousePos(); const panel_hovered = bounds.contains(mouse.x, mouse.y); // Click for focus if (panel_hovered and ctx.input.mousePressed(.left)) { state.focused = true; } // Border color const border_color = if (state.focused) colors.border_focused else colors.border; // Check render mode for fancy features const fancy = Style.isFancy() and config.corner_radius > 0; // Draw shadow first (behind panel) in fancy mode if (fancy and config.show_shadow) { ctx.pushCommand(Command.shadowDrop(bounds.x, bounds.y, bounds.w, bounds.h, config.corner_radius)); } // Draw outer border if (fancy) { ctx.pushCommand(Command.roundedRectOutline(bounds.x, bounds.y, bounds.w, bounds.h, border_color, config.corner_radius)); } else { ctx.pushCommand(Command.rectOutline(bounds.x, bounds.y, bounds.w, bounds.h, border_color)); } // Title bar bounds const title_bounds = Layout.Rect.init( bounds.x + @as(i32, @intCast(config.border_width)), bounds.y + @as(i32, @intCast(config.border_width)), bounds.w -| (config.border_width * 2), config.title_height, ); // Draw title bar const title_bg = if (state.focused) colors.title_bg_focused else colors.title_bg; ctx.pushCommand(Command.rect(title_bounds.x, title_bounds.y, title_bounds.w, title_bounds.h, title_bg)); // Title bar interaction if (title_bounds.contains(mouse.x, mouse.y) and ctx.input.mousePressed(.left)) { result.title_clicked = true; // Toggle collapse if collapsible if (config.collapsible) { state.collapsed = !state.collapsed; result.collapse_changed = true; } } // Draw collapse indicator if collapsible var title_text_x = title_bounds.x + 4; if (config.collapsible) { const indicator_size: u32 = 8; const indicator_x = title_bounds.x + 6; const indicator_y = title_bounds.y + @as(i32, @intCast((config.title_height -| indicator_size) / 2)); // Draw triangle (right = collapsed, down = expanded) if (state.collapsed) { // Right-pointing triangle ctx.pushCommand(Command.line( indicator_x, indicator_y, indicator_x, indicator_y + @as(i32, @intCast(indicator_size)), colors.title_fg, )); ctx.pushCommand(Command.line( indicator_x, indicator_y, indicator_x + @as(i32, @intCast(indicator_size / 2)), indicator_y + @as(i32, @intCast(indicator_size / 2)), colors.title_fg, )); ctx.pushCommand(Command.line( indicator_x, indicator_y + @as(i32, @intCast(indicator_size)), indicator_x + @as(i32, @intCast(indicator_size / 2)), indicator_y + @as(i32, @intCast(indicator_size / 2)), colors.title_fg, )); } else { // Down-pointing triangle ctx.pushCommand(Command.line( indicator_x, indicator_y, indicator_x + @as(i32, @intCast(indicator_size)), indicator_y, colors.title_fg, )); ctx.pushCommand(Command.line( indicator_x, indicator_y, indicator_x + @as(i32, @intCast(indicator_size / 2)), indicator_y + @as(i32, @intCast(indicator_size / 2)), colors.title_fg, )); ctx.pushCommand(Command.line( indicator_x + @as(i32, @intCast(indicator_size)), indicator_y, indicator_x + @as(i32, @intCast(indicator_size / 2)), indicator_y + @as(i32, @intCast(indicator_size / 2)), colors.title_fg, )); } title_text_x += @as(i32, @intCast(indicator_size + 8)); } // Draw close button if closable if (config.closable) { const close_size: u32 = 16; const close_x = title_bounds.right() - @as(i32, @intCast(close_size + 4)); const close_y = title_bounds.y + @as(i32, @intCast((config.title_height -| close_size) / 2)); const close_bounds = Layout.Rect.init(close_x, close_y, close_size, close_size); const close_hovered = close_bounds.contains(mouse.x, mouse.y); if (close_hovered) { ctx.pushCommand(Command.rect(close_x, close_y, close_size, close_size, Style.Color.danger.darken(20))); } // Draw X const x_margin: i32 = 4; ctx.pushCommand(Command.line( close_x + x_margin, close_y + x_margin, close_x + @as(i32, @intCast(close_size)) - x_margin, close_y + @as(i32, @intCast(close_size)) - x_margin, colors.title_fg, )); ctx.pushCommand(Command.line( close_x + @as(i32, @intCast(close_size)) - x_margin, close_y + x_margin, close_x + x_margin, close_y + @as(i32, @intCast(close_size)) - x_margin, colors.title_fg, )); if (close_hovered and ctx.input.mousePressed(.left)) { result.close_clicked = true; } } // Draw title text const char_height: u32 = 8; const title_text_y = title_bounds.y + @as(i32, @intCast((config.title_height -| char_height) / 2)); ctx.pushCommand(Command.text(title_text_x, title_text_y, config.title, colors.title_fg)); // Title bar bottom border ctx.pushCommand(Command.line( title_bounds.x, title_bounds.bottom(), title_bounds.right(), title_bounds.bottom(), colors.border, )); // Content area (if not collapsed) if (!state.collapsed) { const content_y = title_bounds.bottom() + 1; const content_h = bounds.h -| config.title_height -| (config.border_width * 2) -| 1; result.content = Layout.Rect.init( bounds.x + @as(i32, @intCast(config.border_width)), content_y, bounds.w -| (config.border_width * 2), content_h, ); // Draw content background ctx.pushCommand(Command.rect( result.content.x, result.content.y, result.content.w, result.content.h, colors.content_bg, )); // Apply content padding result.content = result.content.shrink(config.content_padding); } return result; } /// Begin a panel scope (pushes clip and ID) pub fn beginPanel(ctx: *Context, id: []const u8, content: Layout.Rect) void { ctx.pushId(ctx.getId(id)); ctx.pushCommand(Command.clip(content.x, content.y, content.w, content.h)); } /// End a panel scope pub fn endPanel(ctx: *Context) void { ctx.pushCommand(Command.clipEnd()); ctx.popId(); } // ============================================================================= // Tests // ============================================================================= test "panel generates commands" { var ctx = try Context.init(std.testing.allocator, 800, 600); defer ctx.deinit(); var state = PanelState{}; ctx.beginFrame(); ctx.layout.row_height = 200; const result = panel(&ctx, &state, "Test Panel"); try std.testing.expect(result.content.w > 0); try std.testing.expect(result.content.h > 0); try std.testing.expect(ctx.commands.items.len >= 3); // Border + title bg + title text ctx.endFrame(); } test "panel collapsed has no content" { var ctx = try Context.init(std.testing.allocator, 800, 600); defer ctx.deinit(); var state = PanelState{ .collapsed = true }; ctx.beginFrame(); ctx.layout.row_height = 200; const result = panelEx(&ctx, &state, .{ .title = "Collapsed", .collapsible = true }, .{}); try std.testing.expect(result.content.isEmpty()); ctx.endFrame(); } test "PanelState defaults" { const state = PanelState{}; try std.testing.expect(!state.focused); try std.testing.expect(!state.collapsed); }