zcatgui/src/widgets/tooltip.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

583 lines
18 KiB
Zig

//! Tooltip Widget
//!
//! Tooltips that appear on hover to provide additional context.
//!
//! ## Features
//! - Configurable delay before showing
//! - Smart positioning (stays within screen bounds)
//! - Support for multi-line text with wrapping
//! - Arrow pointing to target element
//! - Fade animation
//!
//! ## Usage
//! ```zig
//! var tooltip_state = TooltipState{};
//!
//! // In your UI loop:
//! if (button(ctx, "Help")) { ... }
//! tooltip.show(ctx, &tooltip_state, "This button does something helpful");
//!
//! // Or wrap an area:
//! tooltip.area(ctx, my_rect, &tooltip_state, "Hover for info");
//! ```
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 Rect = Layout.Rect;
const Color = Style.Color;
// =============================================================================
// Configuration
// =============================================================================
/// Tooltip position relative to target
pub const Position = enum {
/// Automatically choose best position
auto,
/// Above the target
above,
/// Below the target
below,
/// Left of the target
left,
/// Right of the target
right,
};
/// Tooltip configuration
pub const Config = struct {
/// Delay before showing tooltip (milliseconds)
delay_ms: u32 = 500,
/// Maximum width before wrapping
max_width: u16 = 250,
/// Position preference
position: Position = .auto,
/// Show arrow pointing to target
show_arrow: bool = true,
/// Padding inside tooltip
padding: u8 = 6,
/// Background color (null = theme default)
bg_color: ?Color = null,
/// Text color (null = theme default)
text_color: ?Color = null,
/// Border color (null = theme default)
border_color: ?Color = null,
/// Arrow size
arrow_size: u8 = 6,
/// Offset from target
offset: u8 = 4,
};
/// Tooltip colors (theme-based)
pub const Colors = struct {
background: Color,
text: Color,
border: Color,
pub fn fromTheme() Colors {
const theme = Style.currentTheme();
return .{
.background = theme.surface_variant,
.text = theme.text_primary,
.border = theme.border,
};
}
};
// =============================================================================
// State
// =============================================================================
/// Tooltip state
pub const State = struct {
/// Whether tooltip is currently visible
visible: bool = false,
/// Time when hover started (milliseconds)
hover_start_ms: i64 = 0,
/// Target rect we're showing tooltip for
target_rect: Rect = Rect.zero(),
/// Calculated tooltip rect
tooltip_rect: Rect = Rect.zero(),
/// Actual position used (after auto-calculation)
actual_position: Position = .below,
/// Animation alpha (0-255)
alpha: u8 = 0,
const Self = @This();
/// Reset the tooltip state
pub fn reset(self: *Self) void {
self.visible = false;
self.hover_start_ms = 0;
self.alpha = 0;
}
/// Check if hover is active over a rect
pub fn isHovering(_: *Self, target: Rect, mouse_x: i32, mouse_y: i32) bool {
return target.contains(mouse_x, mouse_y);
}
/// Update tooltip visibility based on hover
pub fn update(self: *Self, target: Rect, mouse_x: i32, mouse_y: i32, delay_ms: u32) void {
const hovering = self.isHovering(target, mouse_x, mouse_y);
const now = std.time.milliTimestamp();
if (hovering) {
if (self.hover_start_ms == 0) {
// Start hover timer
self.hover_start_ms = now;
self.target_rect = target;
} else if (!self.visible and (now - self.hover_start_ms) >= delay_ms) {
// Show tooltip after delay
self.visible = true;
}
// Fade in
if (self.visible and self.alpha < 255) {
self.alpha = @min(255, self.alpha + 30);
}
} else {
// Reset when not hovering
self.reset();
}
}
};
// =============================================================================
// Rendering
// =============================================================================
/// Result from tooltip rendering
pub const Result = struct {
/// Whether tooltip is currently showing
visible: bool,
/// The tooltip bounds (if visible)
bounds: Rect,
};
/// Show tooltip for the previously drawn widget
/// Call this immediately after the widget you want to add a tooltip to
pub fn show(ctx: *Context, state: *State, text: []const u8) Result {
return showEx(ctx, state, text, .{});
}
/// Show tooltip with configuration
pub fn showEx(ctx: *Context, state: *State, text: []const u8, config: Config) Result {
// Get mouse position from input state
const mouse_x = ctx.input.mouse_x;
const mouse_y = ctx.input.mouse_y;
// Use the last widget's bounds as target (approximation)
// In a real implementation, the target would be passed explicitly
const target = state.target_rect;
// Update visibility state
state.update(target, mouse_x, mouse_y, config.delay_ms);
if (!state.visible) {
return .{ .visible = false, .bounds = Rect.zero() };
}
// Calculate tooltip dimensions
const colors = Colors.fromTheme();
const bg_color = config.bg_color orelse colors.background;
const text_color = config.text_color orelse colors.text;
const border_color = config.border_color orelse colors.border;
const text_lines = wrapText(text, config.max_width, 8); // 8px font width
const line_count = text_lines.count;
const max_line_width = text_lines.max_width;
const content_width = max_line_width + config.padding * 2;
const content_height: u32 = @as(u32, line_count) * 10 + config.padding * 2; // 10px line height
// Calculate position
const pos = if (config.position == .auto)
calculateBestPosition(target, content_width, content_height, ctx.width, ctx.height)
else
config.position;
state.actual_position = pos;
// Calculate tooltip rect based on position
const tooltip_rect = calculateTooltipRect(target, content_width, content_height, pos, config.offset, config.arrow_size);
state.tooltip_rect = tooltip_rect;
// Check render mode for fancy features
const corner_radius: u8 = 4;
const fancy = Style.isFancy();
// Draw shadow first (behind tooltip) in fancy mode
if (fancy) {
ctx.pushCommand(Command.shadow(tooltip_rect.x, tooltip_rect.y, tooltip_rect.w, tooltip_rect.h, corner_radius));
}
// Draw tooltip background
if (fancy) {
ctx.pushCommand(Command.roundedRect(tooltip_rect.x, tooltip_rect.y, tooltip_rect.w, tooltip_rect.h, bg_color, corner_radius));
} else {
ctx.pushCommand(.{
.rect = .{
.x = tooltip_rect.x,
.y = tooltip_rect.y,
.w = tooltip_rect.w,
.h = tooltip_rect.h,
.color = bg_color,
},
});
}
// Draw border
drawBorder(ctx, tooltip_rect, border_color);
// Draw arrow if enabled
if (config.show_arrow) {
drawArrow(ctx, target, tooltip_rect, pos, config.arrow_size, bg_color, border_color);
}
// Draw text
var y_offset: i32 = tooltip_rect.y + @as(i32, config.padding);
var line_start: usize = 0;
var line_idx: u32 = 0;
while (line_idx < line_count) : (line_idx += 1) {
const line_end = findLineEnd(text, line_start, config.max_width, 8);
const line = text[line_start..line_end];
ctx.pushCommand(.{
.text = .{
.x = tooltip_rect.x + @as(i32, config.padding),
.y = y_offset,
.text = line,
.color = text_color,
},
});
y_offset += 10; // Line height
line_start = line_end;
// Skip whitespace at start of next line
while (line_start < text.len and (text[line_start] == ' ' or text[line_start] == '\n')) {
line_start += 1;
}
}
ctx.countWidget();
return .{
.visible = true,
.bounds = tooltip_rect,
};
}
/// Create tooltip area that tracks hover
pub fn area(ctx: *Context, bounds: Rect, state: *State, text: []const u8) Result {
return areaEx(ctx, bounds, state, text, .{});
}
/// Create tooltip area with configuration
pub fn areaEx(ctx: *Context, bounds: Rect, state: *State, text: []const u8, config: Config) Result {
// Update target rect to the area bounds
state.target_rect = bounds;
return showEx(ctx, state, text, config);
}
// =============================================================================
// Helper Functions
// =============================================================================
const TextWrapResult = struct {
count: u32,
max_width: u32,
};
fn wrapText(text: []const u8, max_width: u16, char_width: u8) TextWrapResult {
if (text.len == 0) return .{ .count = 1, .max_width = 0 };
const chars_per_line = max_width / char_width;
var line_count: u32 = 1;
var current_line_len: u32 = 0;
var max_line_len: u32 = 0;
for (text) |c| {
if (c == '\n') {
max_line_len = @max(max_line_len, current_line_len);
current_line_len = 0;
line_count += 1;
} else {
current_line_len += 1;
if (current_line_len >= chars_per_line) {
max_line_len = @max(max_line_len, current_line_len);
current_line_len = 0;
line_count += 1;
}
}
}
max_line_len = @max(max_line_len, current_line_len);
return .{
.count = line_count,
.max_width = max_line_len * char_width,
};
}
fn findLineEnd(text: []const u8, start: usize, max_width: u16, char_width: u8) usize {
const chars_per_line = max_width / char_width;
var end = start;
var line_len: usize = 0;
while (end < text.len) : (end += 1) {
if (text[end] == '\n') {
return end;
}
line_len += 1;
if (line_len >= chars_per_line) {
// Try to break at word boundary
var break_pos = end;
while (break_pos > start and text[break_pos] != ' ') {
break_pos -= 1;
}
if (break_pos > start) {
return break_pos;
}
return end;
}
}
return end;
}
fn calculateBestPosition(target: Rect, _: u32, height: u32, screen_w: u32, screen_h: u32) Position {
const target_center_y = target.y + @as(i32, @intCast(target.h / 2));
const screen_center_y: i32 = @intCast(screen_h / 2);
// Prefer below if in upper half, above if in lower half
if (target_center_y < screen_center_y) {
// Check if below fits
const below_y = target.y + @as(i32, @intCast(target.h));
if (below_y + @as(i32, @intCast(height)) < @as(i32, @intCast(screen_h))) {
return .below;
}
} else {
// Check if above fits
if (target.y - @as(i32, @intCast(height)) >= 0) {
return .above;
}
}
// Fallback to checking left/right
const target_center_x = target.x + @as(i32, @intCast(target.w / 2));
const screen_center_x: i32 = @intCast(screen_w / 2);
if (target_center_x < screen_center_x) {
return .right;
} else {
return .left;
}
}
fn calculateTooltipRect(target: Rect, width: u32, height: u32, position: Position, offset: u8, arrow_size: u8) Rect {
const off: i32 = @intCast(offset + arrow_size);
return switch (position) {
.above => Rect.init(
target.x + @as(i32, @intCast(target.w / 2)) - @as(i32, @intCast(width / 2)),
target.y - @as(i32, @intCast(height)) - off,
width,
height,
),
.below => Rect.init(
target.x + @as(i32, @intCast(target.w / 2)) - @as(i32, @intCast(width / 2)),
target.y + @as(i32, @intCast(target.h)) + off,
width,
height,
),
.left => Rect.init(
target.x - @as(i32, @intCast(width)) - off,
target.y + @as(i32, @intCast(target.h / 2)) - @as(i32, @intCast(height / 2)),
width,
height,
),
.right => Rect.init(
target.x + @as(i32, @intCast(target.w)) + off,
target.y + @as(i32, @intCast(target.h / 2)) - @as(i32, @intCast(height / 2)),
width,
height,
),
.auto => calculateTooltipRect(target, width, height, .below, offset, arrow_size),
};
}
fn drawBorder(ctx: *Context, rect: Rect, color: Color) void {
// Top
ctx.pushCommand(.{
.rect = .{ .x = rect.x, .y = rect.y, .w = rect.w, .h = 1, .color = color },
});
// Bottom
ctx.pushCommand(.{
.rect = .{ .x = rect.x, .y = rect.y + @as(i32, @intCast(rect.h)) - 1, .w = rect.w, .h = 1, .color = color },
});
// Left
ctx.pushCommand(.{
.rect = .{ .x = rect.x, .y = rect.y, .w = 1, .h = rect.h, .color = color },
});
// Right
ctx.pushCommand(.{
.rect = .{ .x = rect.x + @as(i32, @intCast(rect.w)) - 1, .y = rect.y, .w = 1, .h = rect.h, .color = color },
});
}
fn drawArrow(ctx: *Context, target: Rect, tooltip: Rect, position: Position, size: u8, bg_color: Color, border_color: Color) void {
_ = border_color;
const arrow_size: i32 = @intCast(size);
const target_cx = target.x + @as(i32, @intCast(target.w / 2));
const target_cy = target.y + @as(i32, @intCast(target.h / 2));
// Draw simple arrow triangle approximation
switch (position) {
.above => {
// Arrow pointing down from tooltip bottom
const ax = target_cx;
const ay = tooltip.y + @as(i32, @intCast(tooltip.h));
var i: i32 = 0;
while (i < arrow_size) : (i += 1) {
ctx.pushCommand(.{
.rect = .{
.x = ax - (arrow_size - i),
.y = ay + i,
.w = @intCast((arrow_size - i) * 2),
.h = 1,
.color = bg_color,
},
});
}
},
.below => {
// Arrow pointing up from tooltip top
const ax = target_cx;
const ay = tooltip.y - arrow_size;
var i: i32 = 0;
while (i < arrow_size) : (i += 1) {
ctx.pushCommand(.{
.rect = .{
.x = ax - i,
.y = ay + i,
.w = @intCast(i * 2 + 1),
.h = 1,
.color = bg_color,
},
});
}
},
.left => {
// Arrow pointing right from tooltip right
const ax = tooltip.x + @as(i32, @intCast(tooltip.w));
const ay = target_cy;
var i: i32 = 0;
while (i < arrow_size) : (i += 1) {
ctx.pushCommand(.{
.rect = .{
.x = ax + i,
.y = ay - (arrow_size - i),
.w = 1,
.h = @intCast((arrow_size - i) * 2),
.color = bg_color,
},
});
}
},
.right => {
// Arrow pointing left from tooltip left
const ax = tooltip.x - arrow_size;
const ay = target_cy;
var i: i32 = 0;
while (i < arrow_size) : (i += 1) {
ctx.pushCommand(.{
.rect = .{
.x = ax + arrow_size - i - 1,
.y = ay - i,
.w = 1,
.h = @intCast(i * 2 + 1),
.color = bg_color,
},
});
}
},
.auto => {},
}
}
// =============================================================================
// Tests
// =============================================================================
test "Tooltip state" {
var state = State{};
try std.testing.expect(!state.visible);
try std.testing.expectEqual(@as(u8, 0), state.alpha);
state.reset();
try std.testing.expect(!state.visible);
}
test "Text wrap calculation" {
const result1 = wrapText("Hello", 100, 8);
try std.testing.expectEqual(@as(u32, 1), result1.count);
const result2 = wrapText("Hello\nWorld", 100, 8);
try std.testing.expectEqual(@as(u32, 2), result2.count);
}
test "Position calculation" {
const target = Rect.init(100, 50, 100, 30); // Near top
const pos = calculateBestPosition(target, 200, 100, 800, 600);
try std.testing.expectEqual(Position.below, pos);
const target2 = Rect.init(100, 500, 100, 30); // Near bottom
const pos2 = calculateBestPosition(target2, 200, 100, 800, 600);
try std.testing.expectEqual(Position.above, pos2);
}
test "Tooltip rect calculation" {
const target = Rect.init(100, 100, 80, 30);
const rect_below = calculateTooltipRect(target, 100, 40, .below, 4, 6);
try std.testing.expect(rect_below.y > target.y + @as(i32, @intCast(target.h)));
const rect_above = calculateTooltipRect(target, 100, 40, .above, 4, 6);
try std.testing.expect(rect_above.y + @as(i32, @intCast(rect_above.h)) < target.y);
}
test "Tooltip basic render" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
ctx.beginFrame();
var state = State{};
state.target_rect = Rect.init(100, 100, 80, 30);
state.visible = true;
const result = show(&ctx, &state, "Test tooltip");
// Tooltip is visible only if the state says so AND rendering happened
// The show function checks hover state, so we check commands were added
try std.testing.expect(ctx.commands.items.len >= 0); // May or may not have rendered based on state
_ = result;
ctx.endFrame();
}