zcatgui/src/widgets/panel.zig
reugenio 2dccddeab0 feat: Paridad Visual DVUI Fase 3 - Sombras y Gradientes
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>
2025-12-17 13:27:48 +01:00

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);
}