- 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>
375 lines
14 KiB
Zig
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);
|
|
}
|