Nuevas capacidades de rendering: - ShadowCommand: sombras multi-capa con blur simulado - Helpers: shadow(), shadowDrop(), shadowFloat() - Quadratic alpha falloff para bordes suaves - GradientCommand: gradientes suaves pixel a pixel - Direcciones: vertical, horizontal, diagonal - Helpers: gradientV/H(), gradientButton(), gradientProgress() - Soporte esquinas redondeadas Widgets actualizados: - Panel/Modal: sombras en fancy mode - Select/Menu: dropdown con sombra + rounded corners - Tooltip/Toast: sombra sutil + rounded corners - Button: gradiente 3D (lighten top, darken bottom) - Progress: gradientes suaves vs 4 bandas IMPORTANTE: Compila y pasa tests (370/370) pero NO probado visualmente 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
341 lines
11 KiB
Zig
341 lines
11 KiB
Zig
//! 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);
|
|
}
|