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

698 lines
20 KiB
Zig

//! Toast/Notification Widget
//!
//! Non-blocking notifications that appear temporarily to inform users.
//!
//! ## Features
//! - Multiple toast types (info, success, warning, error)
//! - Configurable position and duration
//! - Stack multiple toasts
//! - Optional action buttons
//! - Auto-dismiss with countdown
//!
//! ## Usage
//! ```zig
//! var toasts = ToastManager.init();
//!
//! // Show a toast
//! toasts.info("File saved successfully");
//! toasts.warning("Low disk space");
//! toasts.error("Failed to connect");
//!
//! // In your render loop
//! toasts.render(ctx);
//! ```
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;
// =============================================================================
// Types
// =============================================================================
/// Toast notification type
pub const ToastType = enum {
info,
success,
warning,
@"error",
pub fn getColor(self: ToastType) Color {
const theme = Style.currentTheme();
return switch (self) {
.info => theme.primary,
.success => theme.success,
.warning => theme.warning,
.@"error" => theme.danger,
};
}
pub fn getIcon(self: ToastType) []const u8 {
return switch (self) {
.info => "i",
.success => "+",
.warning => "!",
.@"error" => "x",
};
}
};
/// Toast position on screen
pub const Position = enum {
top_left,
top_center,
top_right,
bottom_left,
bottom_center,
bottom_right,
};
/// Toast configuration
pub const Config = struct {
/// Default duration in milliseconds
duration_ms: u32 = 4000,
/// Position on screen
position: Position = .bottom_right,
/// Maximum number of visible toasts
max_visible: u8 = 5,
/// Width of each toast
width: u16 = 300,
/// Padding inside toast
padding: u8 = 12,
/// Gap between toasts
gap: u8 = 8,
/// Show dismiss button
show_dismiss: bool = true,
/// Animate entrance/exit
animated: bool = true,
/// Margin from screen edge
margin: u16 = 16,
};
/// Toast colors
pub const Colors = struct {
background: Color,
text: Color,
icon_bg: Color,
border: Color,
pub fn fromTheme(toast_type: ToastType) Colors {
const theme = Style.currentTheme();
return .{
.background = theme.surface,
.text = theme.text_primary,
.icon_bg = toast_type.getColor(),
.border = theme.border,
};
}
};
// =============================================================================
// Toast Item
// =============================================================================
/// Individual toast notification
pub const Toast = struct {
/// Unique identifier
id: u32,
/// Toast type
toast_type: ToastType,
/// Message text
message: [256]u8,
message_len: usize,
/// Creation timestamp
created_ms: i64,
/// Duration in milliseconds (0 = persistent)
duration_ms: u32,
/// Whether it's being dismissed
dismissing: bool,
/// Animation progress (0-1)
animation: f32,
/// Action button text (if any)
action_text: [32]u8,
action_len: usize,
/// Whether action was clicked
action_clicked: bool,
const Self = @This();
pub fn init(id: u32, toast_type: ToastType, message: []const u8, duration_ms: u32) Self {
var toast = Self{
.id = id,
.toast_type = toast_type,
.message = undefined,
.message_len = @min(message.len, 256),
.created_ms = std.time.milliTimestamp(),
.duration_ms = duration_ms,
.dismissing = false,
.animation = 0,
.action_text = undefined,
.action_len = 0,
.action_clicked = false,
};
@memcpy(toast.message[0..toast.message_len], message[0..toast.message_len]);
return toast;
}
pub fn getMessage(self: *const Self) []const u8 {
return self.message[0..self.message_len];
}
pub fn getAction(self: *const Self) ?[]const u8 {
if (self.action_len == 0) return null;
return self.action_text[0..self.action_len];
}
pub fn setAction(self: *Self, text: []const u8) void {
self.action_len = @min(text.len, 32);
@memcpy(self.action_text[0..self.action_len], text[0..self.action_len]);
}
pub fn shouldDismiss(self: *const Self) bool {
if (self.duration_ms == 0) return false;
const elapsed = std.time.milliTimestamp() - self.created_ms;
return elapsed >= self.duration_ms;
}
pub fn getRemainingMs(self: *const Self) i64 {
if (self.duration_ms == 0) return -1;
const elapsed = std.time.milliTimestamp() - self.created_ms;
return @max(0, @as(i64, self.duration_ms) - elapsed);
}
};
// =============================================================================
// Toast Manager
// =============================================================================
/// Maximum number of toasts to track
pub const MAX_TOASTS = 16;
/// Toast manager - handles multiple toasts
pub const Manager = struct {
/// Active toasts
toasts: [MAX_TOASTS]Toast,
/// Number of active toasts
count: usize,
/// Next toast ID
next_id: u32,
/// Configuration
config: Config,
const Self = @This();
/// Initialize toast manager
pub fn init() Self {
return initWithConfig(.{});
}
/// Initialize with custom config
pub fn initWithConfig(config: Config) Self {
return .{
.toasts = undefined,
.count = 0,
.next_id = 1,
.config = config,
};
}
// =========================================================================
// Show Methods
// =========================================================================
/// Show an info toast
pub fn info(self: *Self, message: []const u8) u32 {
return self.show(message, .info);
}
/// Show a success toast
pub fn success(self: *Self, message: []const u8) u32 {
return self.show(message, .success);
}
/// Show a warning toast
pub fn warning(self: *Self, message: []const u8) u32 {
return self.show(message, .warning);
}
/// Show an error toast
pub fn err(self: *Self, message: []const u8) u32 {
return self.show(message, .@"error");
}
/// Show a toast with specific type
pub fn show(self: *Self, message: []const u8, toast_type: ToastType) u32 {
return self.showWithDuration(message, toast_type, self.config.duration_ms);
}
/// Show a toast with custom duration
pub fn showWithDuration(self: *Self, message: []const u8, toast_type: ToastType, duration_ms: u32) u32 {
// Remove oldest if at capacity
if (self.count >= MAX_TOASTS) {
self.removeAt(0);
}
const id = self.next_id;
self.next_id += 1;
self.toasts[self.count] = Toast.init(id, toast_type, message, duration_ms);
self.count += 1;
return id;
}
/// Show a toast with action button
pub fn showWithAction(self: *Self, message: []const u8, toast_type: ToastType, action: []const u8) u32 {
const id = self.show(message, toast_type);
// Find and set action
var i: usize = 0;
while (i < self.count) : (i += 1) {
if (self.toasts[i].id == id) {
self.toasts[i].setAction(action);
break;
}
}
return id;
}
// =========================================================================
// Dismiss Methods
// =========================================================================
/// Dismiss a specific toast by ID
pub fn dismiss(self: *Self, id: u32) void {
var i: usize = 0;
while (i < self.count) : (i += 1) {
if (self.toasts[i].id == id) {
self.toasts[i].dismissing = true;
return;
}
}
}
/// Dismiss all toasts
pub fn dismissAll(self: *Self) void {
self.count = 0;
}
/// Remove toast at index
fn removeAt(self: *Self, index: usize) void {
if (index >= self.count) return;
// Shift remaining toasts down
var i = index;
while (i < self.count - 1) : (i += 1) {
self.toasts[i] = self.toasts[i + 1];
}
self.count -= 1;
}
// =========================================================================
// Update & Render
// =========================================================================
/// Update toast states (call each frame)
pub fn update(self: *Self) void {
var i: usize = 0;
while (i < self.count) {
// Check if should auto-dismiss
if (self.toasts[i].shouldDismiss() or self.toasts[i].dismissing) {
self.removeAt(i);
// Don't increment i since we removed an item
} else {
// Update animation
if (self.toasts[i].animation < 1.0) {
self.toasts[i].animation = @min(1.0, self.toasts[i].animation + 0.1);
}
i += 1;
}
}
}
/// Render all toasts
pub fn render(self: *Self, ctx: *Context) ToastResult {
self.update();
var result = ToastResult{
.visible_count = 0,
.action_clicked = null,
};
if (self.count == 0) return result;
const screen_w = ctx.width;
const screen_h = ctx.height;
// Calculate starting position based on config
var base_x: i32 = 0;
var base_y: i32 = 0;
const toast_height: u32 = 60; // Approximate height
switch (self.config.position) {
.top_left => {
base_x = @intCast(self.config.margin);
base_y = @intCast(self.config.margin);
},
.top_center => {
base_x = @as(i32, @intCast(screen_w / 2)) - @as(i32, @intCast(self.config.width / 2));
base_y = @intCast(self.config.margin);
},
.top_right => {
base_x = @as(i32, @intCast(screen_w)) - @as(i32, @intCast(self.config.width + self.config.margin));
base_y = @intCast(self.config.margin);
},
.bottom_left => {
base_x = @intCast(self.config.margin);
base_y = @as(i32, @intCast(screen_h)) - @as(i32, @intCast(toast_height + self.config.margin));
},
.bottom_center => {
base_x = @as(i32, @intCast(screen_w / 2)) - @as(i32, @intCast(self.config.width / 2));
base_y = @as(i32, @intCast(screen_h)) - @as(i32, @intCast(toast_height + self.config.margin));
},
.bottom_right => {
base_x = @as(i32, @intCast(screen_w)) - @as(i32, @intCast(self.config.width + self.config.margin));
base_y = @as(i32, @intCast(screen_h)) - @as(i32, @intCast(toast_height + self.config.margin));
},
}
// Determine stack direction
const stack_down = switch (self.config.position) {
.top_left, .top_center, .top_right => true,
.bottom_left, .bottom_center, .bottom_right => false,
};
// Render visible toasts (most recent first or last based on position)
const visible_count = @min(self.count, @as(usize, self.config.max_visible));
var rendered: usize = 0;
while (rendered < visible_count) : (rendered += 1) {
const idx = if (stack_down)
rendered
else
self.count - 1 - rendered;
if (idx >= self.count) continue;
const toast = &self.toasts[idx];
const offset: i32 = @as(i32, @intCast(rendered)) * @as(i32, @intCast(toast_height + self.config.gap));
const y = if (stack_down)
base_y + offset
else
base_y - offset;
const toast_result = renderToast(ctx, toast, base_x, y, self.config);
if (toast_result.dismissed) {
toast.dismissing = true;
}
if (toast_result.action_clicked) {
result.action_clicked = toast.id;
}
result.visible_count += 1;
}
return result;
}
/// Get number of active toasts
pub fn getCount(self: *const Self) usize {
return self.count;
}
/// Check if a toast with given ID exists
pub fn exists(self: *const Self, id: u32) bool {
for (self.toasts[0..self.count]) |*toast| {
if (toast.id == id) return true;
}
return false;
}
/// Check if action was clicked for a toast
pub fn wasActionClicked(self: *Self, id: u32) bool {
for (self.toasts[0..self.count]) |*toast| {
if (toast.id == id and toast.action_clicked) {
toast.action_clicked = false;
return true;
}
}
return false;
}
};
/// Result from toast manager render
pub const ToastResult = struct {
/// Number of visible toasts
visible_count: usize,
/// ID of toast whose action was clicked (if any)
action_clicked: ?u32,
};
// =============================================================================
// Rendering Helper
// =============================================================================
const SingleToastResult = struct {
dismissed: bool,
action_clicked: bool,
};
fn renderToast(ctx: *Context, toast: *const Toast, x: i32, y: i32, config: Config) SingleToastResult {
var result = SingleToastResult{
.dismissed = false,
.action_clicked = false,
};
const colors = Colors.fromTheme(toast.toast_type);
const padding: i32 = @intCast(config.padding);
const width: u32 = config.width;
// Calculate height based on text (simplified - assume single line for now)
const height: u32 = 56;
// Check render mode for fancy features
const corner_radius: u8 = 6;
const fancy = Style.isFancy();
// Draw shadow first (behind toast) in fancy mode
if (fancy) {
ctx.pushCommand(Command.shadow(x, y, width, height, corner_radius));
}
// Draw background
if (fancy) {
ctx.pushCommand(Command.roundedRect(x, y, width, height, colors.background, corner_radius));
} else {
ctx.pushCommand(.{
.rect = .{
.x = x,
.y = y,
.w = width,
.h = height,
.color = colors.background,
},
});
}
// Draw left accent bar
ctx.pushCommand(.{
.rect = .{
.x = x,
.y = y,
.w = 4,
.h = height,
.color = colors.icon_bg,
},
});
// Draw border
drawBorder(ctx, Rect.init(x, y, width, height), colors.border);
// Draw icon
const icon = toast.toast_type.getIcon();
ctx.pushCommand(.{
.text = .{
.x = x + padding,
.y = y + padding,
.text = icon,
.color = colors.icon_bg,
},
});
// Draw message
ctx.pushCommand(.{
.text = .{
.x = x + padding + 16,
.y = y + padding,
.text = toast.getMessage(),
.color = colors.text,
},
});
// Draw dismiss button if enabled
if (config.show_dismiss) {
const btn_x = x + @as(i32, @intCast(width)) - padding - 8;
const btn_y = y + padding;
ctx.pushCommand(.{
.text = .{
.x = btn_x,
.y = btn_y,
.text = "x",
.color = colors.text,
},
});
// Check for click on dismiss button
const mouse_x = ctx.input.mouse_x;
const mouse_y = ctx.input.mouse_y;
const btn_rect = Rect.init(btn_x - 4, btn_y - 4, 16, 16);
if (btn_rect.contains(mouse_x, mouse_y) and ctx.input.mouse_pressed) {
result.dismissed = true;
}
}
// Draw action button if present
if (toast.getAction()) |action| {
const action_x = x + @as(i32, @intCast(width)) - padding - @as(i32, @intCast(action.len * 8)) - 20;
const action_y = y + @as(i32, @intCast(height)) - padding - 12;
ctx.pushCommand(.{
.text = .{
.x = action_x,
.y = action_y,
.text = action,
.color = colors.icon_bg,
},
});
// Check for click on action
const mouse_x = ctx.input.mouse_x;
const mouse_y = ctx.input.mouse_y;
const action_rect = Rect.init(action_x - 4, action_y - 4, @intCast(action.len * 8 + 8), 20);
if (action_rect.contains(mouse_x, mouse_y) and ctx.input.mouse_pressed) {
result.action_clicked = true;
}
}
// Draw progress bar for remaining time
if (toast.duration_ms > 0) {
const remaining = toast.getRemainingMs();
const progress: f32 = @as(f32, @floatFromInt(remaining)) / @as(f32, @floatFromInt(toast.duration_ms));
const bar_width: u32 = @intFromFloat(@as(f32, @floatFromInt(width - 8)) * progress);
ctx.pushCommand(.{
.rect = .{
.x = x + 4,
.y = y + @as(i32, @intCast(height)) - 3,
.w = bar_width,
.h = 2,
.color = colors.icon_bg,
},
});
}
ctx.countWidget();
return result;
}
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 },
});
}
// =============================================================================
// Tests
// =============================================================================
test "Toast init" {
const toast = Toast.init(1, .info, "Test message", 3000);
try std.testing.expectEqual(@as(u32, 1), toast.id);
try std.testing.expectEqual(ToastType.info, toast.toast_type);
try std.testing.expectEqualStrings("Test message", toast.getMessage());
try std.testing.expectEqual(@as(u32, 3000), toast.duration_ms);
}
test "ToastManager basic" {
var manager = Manager.init();
try std.testing.expectEqual(@as(usize, 0), manager.getCount());
const id1 = manager.info("Info message");
try std.testing.expectEqual(@as(usize, 1), manager.getCount());
try std.testing.expect(manager.exists(id1));
const id2 = manager.success("Success!");
try std.testing.expectEqual(@as(usize, 2), manager.getCount());
_ = id2;
}
test "ToastManager dismiss" {
var manager = Manager.init();
const id = manager.info("Test");
try std.testing.expect(manager.exists(id));
manager.dismiss(id);
manager.update();
try std.testing.expect(!manager.exists(id));
}
test "ToastManager dismissAll" {
var manager = Manager.init();
_ = manager.info("One");
_ = manager.info("Two");
_ = manager.info("Three");
try std.testing.expectEqual(@as(usize, 3), manager.getCount());
manager.dismissAll();
try std.testing.expectEqual(@as(usize, 0), manager.getCount());
}
test "ToastType colors" {
const info_color = ToastType.info.getColor();
const success_color = ToastType.success.getColor();
try std.testing.expect(info_color.r != success_color.r or
info_color.g != success_color.g or
info_color.b != success_color.b);
}
test "Toast action" {
var toast = Toast.init(1, .info, "Test", 3000);
toast.setAction("Undo");
const action = toast.getAction();
try std.testing.expect(action != null);
try std.testing.expectEqualStrings("Undo", action.?);
}