From 25728c151cf264b8cc712bc4e3db1648fe048078 Mon Sep 17 00:00:00 2001 From: reugenio Date: Wed, 17 Dec 2025 01:10:58 +0100 Subject: [PATCH] feat: Paridad Visual DVUI Fase 2 - transiciones hover/press MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HoverTransition helper: - animation.zig: HoverTransition struct para widgets - Métodos update(), updateWithPress() para animar hacia target - blend() y blendThree() para interpolar colores - Speed configurable (default 0.008/ms = ~125ms transición) Button con transiciones: - ButtonState struct opcional para transiciones suaves - buttonStateful(), buttonStatefulEx(), buttonStatefulRect() - Mantiene retrocompatibilidad (button() sigue siendo instantáneo) - Test buttonStateful transitions Uso: ```zig var btn_state = button.ButtonState{}; if (button.buttonStateful(&ctx, &btn_state, "Click me")) { // clicked } ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/render/animation.zig | 114 +++++++++++++++++++++++++++++++++ src/widgets/button.zig | 134 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 248 insertions(+) diff --git a/src/render/animation.zig b/src/render/animation.zig index 994d22f..7f90d7c 100644 --- a/src/render/animation.zig +++ b/src/render/animation.zig @@ -586,3 +586,117 @@ test "Spring snap" { try std.testing.expectEqual(@as(f32, 100.0), spring.position); try std.testing.expectEqual(@as(f32, 0.0), spring.velocity); } + +// ============================================================================= +// Hover Transition (for widget state) +// ============================================================================= + +/// Simple hover transition for widgets. +/// Embed in widget state structs for smooth hover/press effects. +/// +/// Usage: +/// ```zig +/// pub const ButtonState = struct { +/// hover: HoverTransition = .{}, +/// }; +/// +/// // In widget draw: +/// state.hover.update(is_hovered, dt_ms); +/// const bg_color = state.hover.blend(normal_color, hover_color); +/// ``` +pub const HoverTransition = struct { + /// Current transition value (0.0 = normal, 1.0 = fully hovered) + value: f32 = 0.0, + /// Transition speed (value change per millisecond) + speed: f32 = 0.008, + + const Self = @This(); + + /// Update transition towards target state + pub fn update(self: *Self, hovered: bool, dt_ms: u64) void { + const target: f32 = if (hovered) 1.0 else 0.0; + const delta = @as(f32, @floatFromInt(dt_ms)) * self.speed; + + if (self.value < target) { + self.value = @min(target, self.value + delta); + } else if (self.value > target) { + self.value = @max(target, self.value - delta); + } + } + + /// Update with press state (0=normal, 0.5=hover, 1.0=pressed) + pub fn updateWithPress(self: *Self, hovered: bool, pressed: bool, dt_ms: u64) void { + const target: f32 = if (pressed) 1.0 else if (hovered) 0.5 else 0.0; + const delta = @as(f32, @floatFromInt(dt_ms)) * self.speed * 1.5; // Press is faster + + if (self.value < target) { + self.value = @min(target, self.value + delta); + } else if (self.value > target) { + self.value = @max(target, self.value - delta); + } + } + + /// Blend between two colors based on transition value + pub fn blend(self: Self, normal: anytype, target: anytype) @TypeOf(normal) { + const Color = @TypeOf(normal); + return Color{ + .r = @intFromFloat(lerp(@floatFromInt(normal.r), @floatFromInt(target.r), self.value)), + .g = @intFromFloat(lerp(@floatFromInt(normal.g), @floatFromInt(target.g), self.value)), + .b = @intFromFloat(lerp(@floatFromInt(normal.b), @floatFromInt(target.b), self.value)), + .a = @intFromFloat(lerp(@floatFromInt(normal.a), @floatFromInt(target.a), self.value)), + }; + } + + /// Blend between three states: normal (0), hover (0.5), pressed (1.0) + pub fn blendThree(self: Self, normal: anytype, hover: anytype, pressed: anytype) @TypeOf(normal) { + const Color = @TypeOf(normal); + if (self.value <= 0.5) { + // Blend normal -> hover (t maps 0-0.5 to 0-1) + const t = self.value * 2.0; + return Color{ + .r = @intFromFloat(lerp(@floatFromInt(normal.r), @floatFromInt(hover.r), t)), + .g = @intFromFloat(lerp(@floatFromInt(normal.g), @floatFromInt(hover.g), t)), + .b = @intFromFloat(lerp(@floatFromInt(normal.b), @floatFromInt(hover.b), t)), + .a = @intFromFloat(lerp(@floatFromInt(normal.a), @floatFromInt(hover.a), t)), + }; + } else { + // Blend hover -> pressed (t maps 0.5-1.0 to 0-1) + const t = (self.value - 0.5) * 2.0; + return Color{ + .r = @intFromFloat(lerp(@floatFromInt(hover.r), @floatFromInt(pressed.r), t)), + .g = @intFromFloat(lerp(@floatFromInt(hover.g), @floatFromInt(pressed.g), t)), + .b = @intFromFloat(lerp(@floatFromInt(hover.b), @floatFromInt(pressed.b), t)), + .a = @intFromFloat(lerp(@floatFromInt(hover.a), @floatFromInt(pressed.a), t)), + }; + } + } + + /// Check if transition is complete (at target) + pub fn isSettled(self: Self, hovered: bool) bool { + const target: f32 = if (hovered) 1.0 else 0.0; + return @abs(self.value - target) < 0.001; + } +}; + +test "HoverTransition basic" { + var hover = HoverTransition{}; + + try std.testing.expectEqual(@as(f32, 0.0), hover.value); + + // Simulate hovering for 125ms (should reach 1.0) + hover.update(true, 125); + try std.testing.expectEqual(@as(f32, 1.0), hover.value); + + // Simulate un-hovering for 125ms (should reach 0.0) + hover.update(false, 125); + try std.testing.expectEqual(@as(f32, 0.0), hover.value); +} + +test "HoverTransition gradual" { + var hover = HoverTransition{}; + + // 50ms should be partial + hover.update(true, 50); + try std.testing.expect(hover.value > 0.0); + try std.testing.expect(hover.value < 1.0); +} diff --git a/src/widgets/button.zig b/src/widgets/button.zig index 7aac7d9..114e24d 100644 --- a/src/widgets/button.zig +++ b/src/widgets/button.zig @@ -2,6 +2,10 @@ //! //! 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; @@ -9,6 +13,7 @@ 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 { @@ -17,6 +22,15 @@ pub const Importance = enum { 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) @@ -103,6 +117,97 @@ pub fn buttonRect(ctx: *Context, bounds: Layout.Rect, text: []const u8, config: 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 + const theme = Style.Theme.dark; + + 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) { + ctx.pushCommand(Command.roundedRect(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)); + } + + // Draw text centered + const char_width: u32 = 8; + const char_height: u32 = 8; + const text_width = @as(u32, @intCast(text.len)) * char_width; + const text_x = bounds.x + @as(i32, @intCast((bounds.w -| text_width) / 2)); + const text_y = bounds.y + @as(i32, @intCast((bounds.h -| char_height) / 2)); + + 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 }); @@ -184,3 +289,32 @@ test "button disabled no click" { 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); +}