//! 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.?); }