New Widgets: - TextArea: Multi-line text editor with cursor navigation, line numbers, selection, and scrolling support - Tree: Hierarchical tree view with expand/collapse, keyboard navigation, and selection - Badge: Status labels with variants (primary, success, warning, danger, info, outline), dismissible option - TagGroup: Multiple badges in a row with wrapping From previous session (v0.7.0): - Progress: Bar (solid, striped, gradient, segmented), Circle, Spinner (circular, dots, bars, ring) - Tooltip: Hover tooltips with smart positioning - Toast: Non-blocking notifications with auto-dismiss Widget count: 23 widgets Test count: 163 tests passing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
502 lines
14 KiB
Zig
502 lines
14 KiB
Zig
//! 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));
|
|
}
|