//! Badge Widget - Labels and tags //! //! Colored badges/tags for status indicators, labels, and counts. //! Supports different variants, sizes, and optional dismiss buttons. 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 Input = @import("../core/input.zig"); /// Badge variant pub const Variant = enum { /// Default style default, /// Primary/accent color primary, /// Success/positive success, /// Warning/caution warning, /// Error/danger danger, /// Info/neutral info, /// Outline style (border only) outline, }; /// Badge size pub const Size = enum { small, medium, large, }; /// Badge configuration pub const Config = struct { /// Visual variant variant: Variant = .default, /// Size size: Size = .medium, /// Show dismiss/close button dismissible: bool = false, /// Pill shape (fully rounded) pill: bool = false, /// Optional icon (single character) icon: ?u8 = null, }; /// Badge colors pub const Colors = struct { /// Colors for each variant default_bg: Style.Color = Style.Color.rgba(80, 80, 80, 255), default_fg: Style.Color = Style.Color.rgba(220, 220, 220, 255), primary_bg: Style.Color = Style.Color.rgba(100, 149, 237, 255), primary_fg: Style.Color = Style.Color.rgba(255, 255, 255, 255), success_bg: Style.Color = Style.Color.rgba(46, 160, 67, 255), success_fg: Style.Color = Style.Color.rgba(255, 255, 255, 255), warning_bg: Style.Color = Style.Color.rgba(210, 153, 34, 255), warning_fg: Style.Color = Style.Color.rgba(30, 30, 30, 255), danger_bg: Style.Color = Style.Color.rgba(207, 34, 46, 255), danger_fg: Style.Color = Style.Color.rgba(255, 255, 255, 255), info_bg: Style.Color = Style.Color.rgba(56, 139, 253, 255), info_fg: Style.Color = Style.Color.rgba(255, 255, 255, 255), outline_bg: Style.Color = Style.Color.rgba(0, 0, 0, 0), outline_fg: Style.Color = Style.Color.rgba(180, 180, 180, 255), outline_border: Style.Color = Style.Color.rgba(150, 150, 150, 255), pub fn fromTheme(theme: Style.Theme) Colors { return .{ .default_bg = theme.secondary, .default_fg = theme.foreground, .primary_bg = theme.primary, .primary_fg = Style.Color.rgba(255, 255, 255, 255), .success_bg = theme.success, .success_fg = Style.Color.rgba(255, 255, 255, 255), .warning_bg = theme.warning, .warning_fg = Style.Color.rgba(30, 30, 30, 255), .danger_bg = theme.error_color, .danger_fg = Style.Color.rgba(255, 255, 255, 255), .info_bg = theme.info, .info_fg = Style.Color.rgba(255, 255, 255, 255), .outline_bg = Style.Color.rgba(0, 0, 0, 0), .outline_fg = theme.foreground, .outline_border = theme.border, }; } /// Get background color for variant pub fn getBg(self: Colors, variant: Variant) Style.Color { return switch (variant) { .default => self.default_bg, .primary => self.primary_bg, .success => self.success_bg, .warning => self.warning_bg, .danger => self.danger_bg, .info => self.info_bg, .outline => self.outline_bg, }; } /// Get foreground color for variant pub fn getFg(self: Colors, variant: Variant) Style.Color { return switch (variant) { .default => self.default_fg, .primary => self.primary_fg, .success => self.success_fg, .warning => self.warning_fg, .danger => self.danger_fg, .info => self.info_fg, .outline => self.outline_fg, }; } }; /// Result of badge widget pub const Result = struct { /// Badge was clicked clicked: bool = false, /// Dismiss button was clicked dismissed: bool = false, /// Badge bounds (for positioning related elements) bounds: Layout.Rect = Layout.Rect.zero(), }; /// Draw a badge and return interaction result pub fn badge(ctx: *Context, text: []const u8) Result { return badgeEx(ctx, text, .{}, .{}); } /// Draw a badge with specific variant pub fn badgeVariant(ctx: *Context, text: []const u8, variant: Variant) Result { return badgeEx(ctx, text, .{ .variant = variant }, .{}); } /// Draw a badge with custom configuration pub fn badgeEx(ctx: *Context, text: []const u8, config: Config, colors: Colors) Result { const bounds = ctx.layout.nextRect(); return badgeRect(ctx, bounds, text, config, colors); } /// Draw a badge in a specific rectangle pub fn badgeRect( ctx: *Context, bounds: Layout.Rect, text: []const u8, config: Config, colors: Colors, ) Result { var result = Result{ .bounds = bounds, }; if (bounds.isEmpty()) return result; // Check mouse interaction const mouse = ctx.input.mousePos(); const hovered = bounds.contains(mouse.x, mouse.y); const clicked = hovered and ctx.input.mousePressed(.left); if (clicked) { result.clicked = true; } // Get size parameters const params = getSizeParams(config.size); const padding_h = params.padding_h; const padding_v = params.padding_v; const char_height: u32 = 8; // Calculate text width const char_width: u32 = 8; var content_width: u32 = @intCast(text.len * char_width); // Add icon width if (config.icon != null) { content_width += char_width + 4; // icon + spacing } // Add dismiss button width const dismiss_width: u32 = if (config.dismissible) char_width + 4 else 0; content_width += dismiss_width; // Calculate badge dimensions const badge_width = @min(content_width + padding_h * 2, bounds.w); const badge_height = @min(char_height + padding_v * 2, bounds.h); // Get colors const bg_color = if (hovered) colors.getBg(config.variant).lighten(10) else colors.getBg(config.variant); const fg_color = colors.getFg(config.variant); // Draw background ctx.pushCommand(Command.rect(bounds.x, bounds.y, badge_width, badge_height, bg_color)); // Draw border for outline variant if (config.variant == .outline) { ctx.pushCommand(Command.rectOutline( bounds.x, bounds.y, badge_width, badge_height, colors.outline_border, )); } // Calculate text position var text_x = bounds.x + @as(i32, @intCast(padding_h)); const text_y = bounds.y + @as(i32, @intCast(padding_v)); // Draw icon if (config.icon) |icon| { const icon_str = &[_]u8{icon}; ctx.pushCommand(Command.text(text_x, text_y, icon_str, fg_color)); text_x += @as(i32, @intCast(char_width + 4)); } // Draw text ctx.pushCommand(Command.text(text_x, text_y, text, fg_color)); // Draw dismiss button if (config.dismissible) { const dismiss_x = bounds.x + @as(i32, @intCast(badge_width - padding_h - char_width)); ctx.pushCommand(Command.text(dismiss_x, text_y, "x", fg_color.darken(20))); // Check if dismiss button was clicked if (clicked) { const dismiss_bounds = Layout.Rect.init( dismiss_x, bounds.y, char_width + padding_h, badge_height, ); if (dismiss_bounds.contains(mouse.x, mouse.y)) { result.dismissed = true; result.clicked = false; } } } // Update result bounds with actual size result.bounds = Layout.Rect.init(bounds.x, bounds.y, badge_width, badge_height); return result; } /// Size parameters const SizeParams = struct { padding_h: u32, padding_v: u32, }; fn getSizeParams(size: Size) SizeParams { return switch (size) { .small => .{ .padding_h = 4, .padding_v = 2 }, .medium => .{ .padding_h = 8, .padding_v = 4 }, .large => .{ .padding_h = 12, .padding_v = 6 }, }; } // ============================================================================= // Tag Group - Multiple tags in a row // ============================================================================= /// Tag definition for tag groups pub const Tag = struct { text: []const u8, variant: Variant = .default, icon: ?u8 = null, dismissible: bool = false, user_data: ?*anyopaque = null, }; /// Tag group configuration pub const TagGroupConfig = struct { /// Spacing between tags spacing: u32 = 4, /// Size for all tags size: Size = .medium, /// Allow wrapping to multiple lines wrap: bool = true, }; /// Result of tag group pub const TagGroupResult = struct { /// Index of clicked tag (if any) clicked: ?usize = null, /// Index of dismissed tag (if any) dismissed: ?usize = null, }; /// Draw a group of tags pub fn tagGroup(ctx: *Context, tags: []const Tag) TagGroupResult { return tagGroupEx(ctx, tags, .{}, .{}); } /// Draw a group of tags with custom configuration pub fn tagGroupEx( ctx: *Context, tags: []const Tag, config: TagGroupConfig, colors: Colors, ) TagGroupResult { const bounds = ctx.layout.nextRect(); return tagGroupRect(ctx, bounds, tags, config, colors); } /// Draw a group of tags in a specific rectangle pub fn tagGroupRect( ctx: *Context, bounds: Layout.Rect, tags: []const Tag, config: TagGroupConfig, colors: Colors, ) TagGroupResult { var result = TagGroupResult{}; if (bounds.isEmpty() or tags.len == 0) return result; const char_width: u32 = 8; const char_height: u32 = 8; const params = getSizeParams(config.size); var x = bounds.x; var y = bounds.y; const tag_height = char_height + params.padding_v * 2; for (tags, 0..) |tag, i| { // Calculate tag width var tag_width: u32 = @intCast(tag.text.len * char_width); tag_width += params.padding_h * 2; if (tag.icon != null) tag_width += char_width + 4; if (tag.dismissible) tag_width += char_width + 4; // Check if we need to wrap if (config.wrap and x > bounds.x and @as(u32, @intCast(x - bounds.x)) + tag_width > bounds.w) { x = bounds.x; y += @as(i32, @intCast(tag_height + config.spacing)); } // Check if we're still within bounds if (y - bounds.y + @as(i32, @intCast(tag_height)) > @as(i32, @intCast(bounds.h))) break; // Draw tag const tag_bounds = Layout.Rect.init(x, y, tag_width, tag_height); const tag_result = badgeRect(ctx, tag_bounds, tag.text, .{ .variant = tag.variant, .size = config.size, .dismissible = tag.dismissible, .icon = tag.icon, }, colors); if (tag_result.clicked) { result.clicked = i; } if (tag_result.dismissed) { result.dismissed = i; } x += @as(i32, @intCast(tag_width + config.spacing)); } return result; } // ============================================================================= // Convenience constructors // ============================================================================= /// Create a primary badge pub fn primary(ctx: *Context, text: []const u8) Result { return badgeVariant(ctx, text, .primary); } /// Create a success badge pub fn success(ctx: *Context, text: []const u8) Result { return badgeVariant(ctx, text, .success); } /// Create a warning badge pub fn warning(ctx: *Context, text: []const u8) Result { return badgeVariant(ctx, text, .warning); } /// Create a danger badge pub fn danger(ctx: *Context, text: []const u8) Result { return badgeVariant(ctx, text, .danger); } /// Create an info badge pub fn info(ctx: *Context, text: []const u8) Result { return badgeVariant(ctx, text, .info); } /// Create an outline badge pub fn outline(ctx: *Context, text: []const u8) Result { return badgeVariant(ctx, text, .outline); } // ============================================================================= // Tests // ============================================================================= test "badge generates commands" { var ctx = try Context.init(std.testing.allocator, 800, 600); defer ctx.deinit(); ctx.beginFrame(); ctx.layout.row_height = 24; _ = badge(&ctx, "Test"); // Should generate: rect (bg) + text try std.testing.expect(ctx.commands.items.len >= 2); ctx.endFrame(); } test "badge variants" { var ctx = try Context.init(std.testing.allocator, 800, 600); defer ctx.deinit(); ctx.beginFrame(); ctx.layout.row_height = 24; _ = primary(&ctx, "Primary"); _ = success(&ctx, "Success"); _ = warning(&ctx, "Warning"); _ = danger(&ctx, "Danger"); _ = info(&ctx, "Info"); _ = outline(&ctx, "Outline"); try std.testing.expect(ctx.commands.items.len >= 12); ctx.endFrame(); } test "badge with icon" { var ctx = try Context.init(std.testing.allocator, 800, 600); defer ctx.deinit(); ctx.beginFrame(); ctx.layout.row_height = 24; _ = badgeEx(&ctx, "Status", .{ .icon = '*' }, .{}); // Should have icon text + label text try std.testing.expect(ctx.commands.items.len >= 3); ctx.endFrame(); } test "badge dismissible" { var ctx = try Context.init(std.testing.allocator, 800, 600); defer ctx.deinit(); ctx.beginFrame(); ctx.layout.row_height = 24; _ = badgeEx(&ctx, "Tag", .{ .dismissible = true }, .{}); // Should have bg + text + dismiss x try std.testing.expect(ctx.commands.items.len >= 3); ctx.endFrame(); } test "tagGroup" { var ctx = try Context.init(std.testing.allocator, 800, 600); defer ctx.deinit(); ctx.beginFrame(); ctx.layout.row_height = 100; const tags = [_]Tag{ .{ .text = "Tag1", .variant = .primary }, .{ .text = "Tag2", .variant = .success }, .{ .text = "Tag3", .variant = .warning }, }; _ = tagGroup(&ctx, &tags); // Should have commands for all 3 tags try std.testing.expect(ctx.commands.items.len >= 6); ctx.endFrame(); } test "Colors.getBg/getFg" { const colors = Colors{}; try std.testing.expectEqual(colors.primary_bg, colors.getBg(.primary)); try std.testing.expectEqual(colors.success_bg, colors.getBg(.success)); try std.testing.expectEqual(colors.primary_fg, colors.getFg(.primary)); try std.testing.expectEqual(colors.success_fg, colors.getFg(.success)); }