zcatgui/src/widgets/button.zig
R.Eugenio dd249d2b2d fix(buttons): Completar efecto bisel 3D con 4 lados
- Añadir líneas verticales (izquierda + derecha) al bisel
- Antes: solo 2 líneas horizontales (arriba + abajo)
- Ahora: 4 líneas formando marco 3D completo

Z-Design V2: Bisel con las 4 esquinas completas.
Normal: claro arriba/izquierda, oscuro abajo/derecha.
Pressed: invierte colores para efecto "hundido".

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 20:01:10 +01:00

375 lines
14 KiB
Zig

//! Button Widget - Clickable button
//!
//! An immediate mode button that returns true when clicked.
//! Supports hover/active states and keyboard activation.
//!
//! Two modes:
//! - Stateless: `button()`, `buttonEx()` - instant color changes
//! - Stateful: `buttonStateful()` - smooth hover/press transitions
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");
const animation = @import("../render/animation.zig");
/// Button importance level
pub const Importance = enum {
normal,
primary,
danger,
};
/// Button state for smooth transitions (optional)
/// Pass to buttonStateful() for animated hover/press effects.
pub const ButtonState = struct {
/// Hover/press transition (0=normal, 0.5=hover, 1.0=pressed)
transition: animation.HoverTransition = .{},
/// Last frame time for delta calculation
last_time_ms: u64 = 0,
};
/// Button configuration
pub const ButtonConfig = struct {
/// Background color (overrides theme)
bg: ?Style.Color = null,
/// Foreground/text color (overrides theme)
fg: ?Style.Color = null,
/// Importance level
importance: Importance = .normal,
/// Disabled state
disabled: bool = false,
/// Padding around text
padding: u32 = 10,
/// Corner radius (0 = square, default 4 for fancy mode)
corner_radius: u8 = 4,
};
/// Draw a button and return true if clicked
pub fn button(ctx: *Context, text: []const u8) bool {
return buttonEx(ctx, text, .{});
}
/// Draw a button with custom configuration
pub fn buttonEx(ctx: *Context, text: []const u8, config: ButtonConfig) bool {
const bounds = ctx.layout.nextRect();
return buttonRect(ctx, bounds, text, config);
}
/// Draw a button in a specific rectangle
pub fn buttonRect(ctx: *Context, bounds: Layout.Rect, text: []const u8, config: ButtonConfig) bool {
if (bounds.isEmpty()) return false;
const id = ctx.getId(text);
_ = id; // For future focus management
// Check mouse interaction
const mouse = ctx.input.mousePos();
const hovered = bounds.contains(mouse.x, mouse.y) and !config.disabled;
const pressed = hovered and ctx.input.mouseDown(.left);
const clicked = hovered and ctx.input.mouseReleased(.left);
// Determine colors based on state (Z-Design: usar theme dinámico)
const theme = Style.currentTheme().*;
const base_bg = config.bg orelse switch (config.importance) {
.normal => theme.button_bg,
.primary => theme.primary,
.danger => theme.danger,
};
const bg_color = if (config.disabled)
base_bg.darken(30)
else if (pressed)
base_bg.darken(20)
else if (hovered)
base_bg.lighten(10)
else
base_bg;
const fg_color = config.fg orelse if (config.disabled)
theme.button_fg.darken(40)
else
theme.button_fg;
// Draw background and border based on render mode
if (Style.isFancy() and config.corner_radius > 0) {
// Fancy mode: rounded corners with subtle gradient for 3D effect
ctx.pushCommand(Command.gradientButton(bounds.x, bounds.y, bounds.w, bounds.h, bg_color, config.corner_radius));
ctx.pushCommand(Command.roundedRectOutline(bounds.x, bounds.y, bounds.w, bounds.h, theme.border, config.corner_radius));
} else {
// Simple mode: square corners
ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, bg_color));
ctx.pushCommand(Command.rectOutline(bounds.x, bounds.y, bounds.w, bounds.h, theme.border));
}
// Bisel 3D completo: 4 lados (invierte cuando está pulsado)
// Z-Design V2: Efecto 3D con las 4 esquinas completas
if (bounds.h >= 4 and bounds.w >= 4 and !config.disabled) {
const bevel_light = bg_color.lighten(15);
const bevel_dark = bg_color.darken(15);
const inner_h = bounds.h -| 2; // Altura interior para líneas verticales
if (pressed) {
// Pressed: bisel invertido = "hundido"
// Arriba + izquierda: oscuro (sombra interior)
ctx.pushCommand(Command.rect(bounds.x + 1, bounds.y + 1, bounds.w - 2, 1, bevel_dark)); // Top
ctx.pushCommand(Command.rect(bounds.x + 1, bounds.y + 1, 1, inner_h, bevel_dark)); // Left
// Abajo + derecha: claro (luz interior)
ctx.pushCommand(Command.rect(bounds.x + 1, bounds.y + @as(i32, @intCast(bounds.h)) - 2, bounds.w - 2, 1, bevel_light)); // Bottom
ctx.pushCommand(Command.rect(bounds.x + @as(i32, @intCast(bounds.w)) - 2, bounds.y + 1, 1, inner_h, bevel_light)); // Right
} else {
// Normal: bisel = "elevado"
// Arriba + izquierda: claro (iluminado)
ctx.pushCommand(Command.rect(bounds.x + 1, bounds.y + 1, bounds.w - 2, 1, bevel_light)); // Top
ctx.pushCommand(Command.rect(bounds.x + 1, bounds.y + 1, 1, inner_h, bevel_light)); // Left
// Abajo + derecha: oscuro (sombra)
ctx.pushCommand(Command.rect(bounds.x + 1, bounds.y + @as(i32, @intCast(bounds.h)) - 2, bounds.w - 2, 1, bevel_dark)); // Bottom
ctx.pushCommand(Command.rect(bounds.x + @as(i32, @intCast(bounds.w)) - 2, bounds.y + 1, 1, inner_h, bevel_dark)); // Right
}
}
// Draw text centered (con offset +1px cuando está pulsado = "se hunde")
// Z-Design V2: usar métricas del contexto para centrado correcto con TTF
const text_width = ctx.measureText(text);
const char_height = ctx.char_width; // Para fuentes cuadradas, height ≈ width
const press_offset: i32 = if (pressed) 1 else 0;
const text_x = bounds.x + @as(i32, @intCast((bounds.w -| text_width) / 2)) + press_offset;
const text_y = bounds.y + @as(i32, @intCast((bounds.h -| char_height) / 2)) + press_offset;
ctx.pushCommand(Command.text(text_x, text_y, text, fg_color));
return clicked and !config.disabled;
}
// =============================================================================
// Stateful Button (with smooth transitions)
// =============================================================================
/// Draw a button with smooth hover/press transitions
pub fn buttonStateful(ctx: *Context, state: *ButtonState, text: []const u8) bool {
return buttonStatefulEx(ctx, state, text, .{});
}
/// Draw a stateful button with custom configuration
pub fn buttonStatefulEx(ctx: *Context, state: *ButtonState, text: []const u8, config: ButtonConfig) bool {
const bounds = ctx.layout.nextRect();
return buttonStatefulRect(ctx, bounds, state, text, config);
}
/// Draw a stateful button in a specific rectangle
pub fn buttonStatefulRect(
ctx: *Context,
bounds: Layout.Rect,
state: *ButtonState,
text: []const u8,
config: ButtonConfig,
) bool {
if (bounds.isEmpty()) return false;
// Calculate delta time
const current_time = ctx.current_time_ms;
const dt_ms: u64 = if (state.last_time_ms > 0 and current_time > state.last_time_ms)
current_time - state.last_time_ms
else
16; // Default ~60fps
state.last_time_ms = current_time;
// Check mouse interaction
const mouse = ctx.input.mousePos();
const hovered = bounds.contains(mouse.x, mouse.y) and !config.disabled;
const pressed = hovered and ctx.input.mouseDown(.left);
const clicked = hovered and ctx.input.mouseReleased(.left);
// Update transition animation
state.transition.updateWithPress(hovered, pressed, dt_ms);
// Determine colors based on animated state (Z-Design: usar theme dinámico)
const theme = Style.currentTheme().*;
const base_bg = config.bg orelse switch (config.importance) {
.normal => theme.button_bg,
.primary => theme.primary,
.danger => theme.danger,
};
// Calculate animated background color
const bg_color = if (config.disabled)
base_bg.darken(30)
else
state.transition.blendThree(
base_bg, // normal
base_bg.lighten(10), // hover
base_bg.darken(20), // pressed
);
const fg_color = config.fg orelse if (config.disabled)
theme.button_fg.darken(40)
else
theme.button_fg;
// Draw background and border based on render mode
if (Style.isFancy() and config.corner_radius > 0) {
// Fancy mode: rounded corners with subtle gradient for 3D effect
ctx.pushCommand(Command.gradientButton(bounds.x, bounds.y, bounds.w, bounds.h, bg_color, config.corner_radius));
ctx.pushCommand(Command.roundedRectOutline(bounds.x, bounds.y, bounds.w, bounds.h, theme.border, config.corner_radius));
} else {
ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, bg_color));
ctx.pushCommand(Command.rectOutline(bounds.x, bounds.y, bounds.w, bounds.h, theme.border));
}
// Bisel 3D completo: 4 lados (invierte cuando está pulsado)
// Z-Design V2: Efecto 3D con las 4 esquinas completas
if (bounds.h >= 4 and bounds.w >= 4 and !config.disabled) {
const bevel_light = bg_color.lighten(15);
const bevel_dark = bg_color.darken(15);
const inner_h = bounds.h -| 2; // Altura interior para líneas verticales
if (pressed) {
// Pressed: bisel invertido = "hundido"
// Arriba + izquierda: oscuro (sombra interior)
ctx.pushCommand(Command.rect(bounds.x + 1, bounds.y + 1, bounds.w - 2, 1, bevel_dark)); // Top
ctx.pushCommand(Command.rect(bounds.x + 1, bounds.y + 1, 1, inner_h, bevel_dark)); // Left
// Abajo + derecha: claro (luz interior)
ctx.pushCommand(Command.rect(bounds.x + 1, bounds.y + @as(i32, @intCast(bounds.h)) - 2, bounds.w - 2, 1, bevel_light)); // Bottom
ctx.pushCommand(Command.rect(bounds.x + @as(i32, @intCast(bounds.w)) - 2, bounds.y + 1, 1, inner_h, bevel_light)); // Right
} else {
// Normal: bisel = "elevado"
// Arriba + izquierda: claro (iluminado)
ctx.pushCommand(Command.rect(bounds.x + 1, bounds.y + 1, bounds.w - 2, 1, bevel_light)); // Top
ctx.pushCommand(Command.rect(bounds.x + 1, bounds.y + 1, 1, inner_h, bevel_light)); // Left
// Abajo + derecha: oscuro (sombra)
ctx.pushCommand(Command.rect(bounds.x + 1, bounds.y + @as(i32, @intCast(bounds.h)) - 2, bounds.w - 2, 1, bevel_dark)); // Bottom
ctx.pushCommand(Command.rect(bounds.x + @as(i32, @intCast(bounds.w)) - 2, bounds.y + 1, 1, inner_h, bevel_dark)); // Right
}
}
// Draw text centered (con offset +1px cuando está pulsado = "se hunde")
// Z-Design V2: usar métricas del contexto para centrado correcto con TTF
const text_width = ctx.measureText(text);
const char_height = ctx.char_width; // Para fuentes cuadradas, height ≈ width
const press_offset: i32 = if (pressed) 1 else 0;
const text_x = bounds.x + @as(i32, @intCast((bounds.w -| text_width) / 2)) + press_offset;
const text_y = bounds.y + @as(i32, @intCast((bounds.h -| char_height) / 2)) + press_offset;
ctx.pushCommand(Command.text(text_x, text_y, text, fg_color));
return clicked and !config.disabled;
}
// =============================================================================
// Convenience Functions
// =============================================================================
/// Draw a primary button (convenience function)
pub fn buttonPrimary(ctx: *Context, text: []const u8) bool {
return buttonEx(ctx, text, .{ .importance = .primary });
}
/// Draw a danger button (convenience function)
pub fn buttonDanger(ctx: *Context, text: []const u8) bool {
return buttonEx(ctx, text, .{ .importance = .danger });
}
/// Draw a disabled button (convenience function)
pub fn buttonDisabled(ctx: *Context, text: []const u8) bool {
return buttonEx(ctx, text, .{ .disabled = true });
}
// =============================================================================
// Tests
// =============================================================================
test "button generates commands" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
ctx.beginFrame();
ctx.layout.row_height = 30;
_ = button(&ctx, "Click me");
// Should generate: rect (background) + rect_outline (border) + 4 bevel lines + text = 7
try std.testing.expectEqual(@as(usize, 7), ctx.commands.items.len);
ctx.endFrame();
}
test "button click detection" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
// Frame 1: Mouse pressed inside button
ctx.beginFrame();
ctx.layout.row_height = 30;
ctx.input.setMousePos(50, 15);
ctx.input.setMouseButton(.left, true);
_ = button(&ctx, "Test");
ctx.endFrame();
// Frame 2: Mouse released inside button
ctx.beginFrame();
ctx.layout.row_height = 30;
ctx.input.setMousePos(50, 15);
ctx.input.setMouseButton(.left, false);
const clicked = button(&ctx, "Test");
try std.testing.expect(clicked);
ctx.endFrame();
}
test "button disabled no click" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
// Frame 1: Mouse pressed
ctx.beginFrame();
ctx.layout.row_height = 30;
ctx.input.setMousePos(50, 15);
ctx.input.setMouseButton(.left, true);
_ = buttonEx(&ctx, "Disabled", .{ .disabled = true });
ctx.endFrame();
// Frame 2: Mouse released
ctx.beginFrame();
ctx.layout.row_height = 30;
ctx.input.setMousePos(50, 15);
ctx.input.setMouseButton(.left, false);
const clicked = buttonEx(&ctx, "Disabled", .{ .disabled = true });
try std.testing.expect(!clicked);
ctx.endFrame();
}
test "buttonStateful transitions" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
var state = ButtonState{};
// Frame 1: Not hovered
ctx.beginFrame();
ctx.current_time_ms = 0;
ctx.layout.row_height = 30;
ctx.input.setMousePos(500, 500); // Outside button
_ = buttonStateful(&ctx, &state, "Test");
ctx.endFrame();
// Transition should be at 0
try std.testing.expectEqual(@as(f32, 0.0), state.transition.value);
// Frame 2: Hovered
ctx.beginFrame();
ctx.current_time_ms = 100;
ctx.layout.row_height = 30;
ctx.input.setMousePos(50, 15); // Inside button
_ = buttonStateful(&ctx, &state, "Test");
ctx.endFrame();
// Transition should have started moving (>0)
try std.testing.expect(state.transition.value > 0.0);
}