Compare commits

..

2 commits

Author SHA1 Message Date
74e83d2334 feat: Transiciones hover en Select widget
SelectState ahora incluye:
- hover: HoverTransition para transiciones suaves
- last_time_ms: tracking de tiempo para delta

El color de fondo del botón principal del Select ahora
transiciona suavemente entre normal y hover (lighten 5%).

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-17 01:12:21 +01:00
25728c151c feat: Paridad Visual DVUI Fase 2 - transiciones hover/press
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 <noreply@anthropic.com>
2025-12-17 01:10:58 +01:00
3 changed files with 265 additions and 4 deletions

View file

@ -586,3 +586,117 @@ test "Spring snap" {
try std.testing.expectEqual(@as(f32, 100.0), spring.position); try std.testing.expectEqual(@as(f32, 100.0), spring.position);
try std.testing.expectEqual(@as(f32, 0.0), spring.velocity); 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);
}

View file

@ -2,6 +2,10 @@
//! //!
//! An immediate mode button that returns true when clicked. //! An immediate mode button that returns true when clicked.
//! Supports hover/active states and keyboard activation. //! 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 std = @import("std");
const Context = @import("../core/context.zig").Context; const Context = @import("../core/context.zig").Context;
@ -9,6 +13,7 @@ const Command = @import("../core/command.zig");
const Layout = @import("../core/layout.zig"); const Layout = @import("../core/layout.zig");
const Style = @import("../core/style.zig"); const Style = @import("../core/style.zig");
const Input = @import("../core/input.zig"); const Input = @import("../core/input.zig");
const animation = @import("../render/animation.zig");
/// Button importance level /// Button importance level
pub const Importance = enum { pub const Importance = enum {
@ -17,6 +22,15 @@ pub const Importance = enum {
danger, 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 /// Button configuration
pub const ButtonConfig = struct { pub const ButtonConfig = struct {
/// Background color (overrides theme) /// 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; 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) /// Draw a primary button (convenience function)
pub fn buttonPrimary(ctx: *Context, text: []const u8) bool { pub fn buttonPrimary(ctx: *Context, text: []const u8) bool {
return buttonEx(ctx, text, .{ .importance = .primary }); return buttonEx(ctx, text, .{ .importance = .primary });
@ -184,3 +289,32 @@ test "button disabled no click" {
try std.testing.expect(!clicked); try std.testing.expect(!clicked);
ctx.endFrame(); 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);
}

View file

@ -2,6 +2,7 @@
//! //!
//! A dropdown menu for selecting one option from a list. //! A dropdown menu for selecting one option from a list.
//! The dropdown opens on click and closes when an option is selected. //! The dropdown opens on click and closes when an option is selected.
//! Supports smooth hover transitions via HoverTransition.
const std = @import("std"); const std = @import("std");
const Context = @import("../core/context.zig").Context; const Context = @import("../core/context.zig").Context;
@ -9,6 +10,7 @@ const Command = @import("../core/command.zig");
const Layout = @import("../core/layout.zig"); const Layout = @import("../core/layout.zig");
const Style = @import("../core/style.zig"); const Style = @import("../core/style.zig");
const Input = @import("../core/input.zig"); const Input = @import("../core/input.zig");
const animation = @import("../render/animation.zig");
/// Select state (caller-managed) /// Select state (caller-managed)
pub const SelectState = struct { pub const SelectState = struct {
@ -20,6 +22,10 @@ pub const SelectState = struct {
scroll_offset: usize = 0, scroll_offset: usize = 0,
/// Whether this widget has focus /// Whether this widget has focus
focused: bool = false, focused: bool = false,
/// Hover transition for smooth effects
hover: animation.HoverTransition = .{},
/// Last frame time for delta calculation
last_time_ms: u64 = 0,
/// Get selected index as optional usize /// Get selected index as optional usize
pub fn selectedIndex(self: SelectState) ?usize { pub fn selectedIndex(self: SelectState) ?usize {
@ -110,15 +116,22 @@ pub fn selectRect(
const has_focus = ctx.hasFocus(widget_id); const has_focus = ctx.hasFocus(widget_id);
state.focused = has_focus; state.focused = has_focus;
// Determine button colors // Update hover transition
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;
state.last_time_ms = current_time;
state.hover.update(hovered and !config.disabled and !state.open, dt_ms);
// Determine button colors with smooth transition
const bg_color = if (config.disabled) const bg_color = if (config.disabled)
theme.button_bg.darken(20) theme.button_bg.darken(20)
else if (state.open) else if (state.open)
theme.button_bg.lighten(10) theme.button_bg.lighten(10)
else if (hovered)
theme.button_bg.lighten(5)
else else
theme.button_bg; state.hover.blend(theme.button_bg, theme.button_bg.lighten(5));
const border_color = if (has_focus or state.open) theme.primary else theme.border; const border_color = if (has_focus or state.open) theme.primary else theme.border;