New Widgets (3): - NumberEntry: Numeric input with spinner buttons, min/max limits, prefix/suffix, validation - RichText: Styled text display with bold, italic, underline, strikethrough, colors, clickable links, simple markdown parsing - Breadcrumb: Navigation path display with clickable segments, separators, home icon, collapse support Widget count: 30 widgets Test count: 200 tests passing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
303 lines
9.1 KiB
Zig
303 lines
9.1 KiB
Zig
//! Breadcrumb Widget - Navigation path display
|
|
//!
|
|
//! A horizontal path display for hierarchical navigation.
|
|
//! Shows clickable path segments with separators.
|
|
|
|
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");
|
|
|
|
/// Breadcrumb item
|
|
pub const Item = struct {
|
|
/// Display label
|
|
label: []const u8,
|
|
/// Optional icon (single character)
|
|
icon: ?u8 = null,
|
|
/// Associated data/path
|
|
data: ?[]const u8 = null,
|
|
/// Is this item disabled/unclickable
|
|
disabled: bool = false,
|
|
};
|
|
|
|
/// Breadcrumb configuration
|
|
pub const Config = struct {
|
|
/// Separator between items
|
|
separator: []const u8 = " > ",
|
|
/// Maximum visible items (0 = unlimited)
|
|
max_items: usize = 0,
|
|
/// Collapse to "..." when exceeding max
|
|
collapse_middle: bool = true,
|
|
/// Show home icon for first item
|
|
show_home_icon: bool = false,
|
|
/// Padding
|
|
padding: u32 = 4,
|
|
};
|
|
|
|
/// Breadcrumb colors
|
|
pub const Colors = struct {
|
|
background: ?Style.Color = null,
|
|
text: Style.Color = Style.Color.rgba(180, 180, 180, 255),
|
|
text_current: Style.Color = Style.Color.rgba(220, 220, 220, 255),
|
|
text_hover: Style.Color = Style.Color.rgba(255, 255, 255, 255),
|
|
separator: Style.Color = Style.Color.rgba(120, 120, 120, 255),
|
|
disabled: Style.Color = Style.Color.rgba(100, 100, 100, 255),
|
|
icon: Style.Color = Style.Color.rgba(150, 150, 150, 255),
|
|
|
|
pub fn fromTheme(theme: Style.Theme) Colors {
|
|
return .{
|
|
.text = theme.secondary,
|
|
.text_current = theme.foreground,
|
|
.text_hover = theme.primary,
|
|
.separator = theme.secondary.darken(20),
|
|
.disabled = theme.secondary.darken(30),
|
|
.icon = theme.secondary,
|
|
};
|
|
}
|
|
};
|
|
|
|
/// Breadcrumb result
|
|
pub const Result = struct {
|
|
/// Index of clicked item (if any)
|
|
clicked: ?usize = null,
|
|
/// Data of clicked item
|
|
clicked_data: ?[]const u8 = null,
|
|
/// Hovered item index
|
|
hovered: ?usize = null,
|
|
};
|
|
|
|
/// Draw breadcrumbs
|
|
pub fn breadcrumb(ctx: *Context, items: []const Item) Result {
|
|
return breadcrumbEx(ctx, items, .{}, .{});
|
|
}
|
|
|
|
/// Draw breadcrumbs with configuration
|
|
pub fn breadcrumbEx(
|
|
ctx: *Context,
|
|
items: []const Item,
|
|
config: Config,
|
|
colors: Colors,
|
|
) Result {
|
|
const bounds = ctx.layout.nextRect();
|
|
return breadcrumbRect(ctx, bounds, items, config, colors);
|
|
}
|
|
|
|
/// Draw breadcrumbs in specific rectangle
|
|
pub fn breadcrumbRect(
|
|
ctx: *Context,
|
|
bounds: Layout.Rect,
|
|
items: []const Item,
|
|
config: Config,
|
|
colors: Colors,
|
|
) Result {
|
|
var result = Result{};
|
|
|
|
if (bounds.isEmpty() or items.len == 0) return result;
|
|
|
|
const mouse = ctx.input.mousePos();
|
|
const mouse_pressed = ctx.input.mousePressed(.left);
|
|
|
|
// Draw background if specified
|
|
if (colors.background) |bg| {
|
|
ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, bg));
|
|
}
|
|
|
|
const char_width: u32 = 8;
|
|
const char_height: u32 = 8;
|
|
const padding = config.padding;
|
|
|
|
var x = bounds.x + @as(i32, @intCast(padding));
|
|
const y = bounds.y + @as(i32, @intCast((bounds.h - char_height) / 2));
|
|
const max_x = bounds.x + @as(i32, @intCast(bounds.w)) - @as(i32, @intCast(padding));
|
|
|
|
// Determine which items to show
|
|
var start_idx: usize = 0;
|
|
var show_ellipsis = false;
|
|
|
|
if (config.max_items > 0 and items.len > config.max_items) {
|
|
if (config.collapse_middle) {
|
|
// Show first, ..., last few items
|
|
show_ellipsis = true;
|
|
start_idx = items.len - config.max_items + 1;
|
|
}
|
|
}
|
|
|
|
// Draw items
|
|
var item_idx: usize = 0;
|
|
while (item_idx < items.len) : (item_idx += 1) {
|
|
// Handle ellipsis for collapsed middle
|
|
if (show_ellipsis and item_idx == 1) {
|
|
// Draw ellipsis
|
|
ctx.pushCommand(Command.text(x, y, "...", colors.separator));
|
|
x += 3 * @as(i32, @intCast(char_width));
|
|
|
|
// Draw separator
|
|
ctx.pushCommand(Command.text(x, y, config.separator, colors.separator));
|
|
x += @as(i32, @intCast(config.separator.len * char_width));
|
|
|
|
// Skip to end items
|
|
item_idx = start_idx;
|
|
continue;
|
|
}
|
|
|
|
// Skip middle items if collapsed
|
|
if (show_ellipsis and item_idx > 0 and item_idx < start_idx) {
|
|
continue;
|
|
}
|
|
|
|
const item = items[item_idx];
|
|
const is_last = item_idx == items.len - 1;
|
|
|
|
// Calculate item width
|
|
var item_width: u32 = @intCast(item.label.len * char_width);
|
|
if (item.icon != null) {
|
|
item_width += char_width + 4;
|
|
}
|
|
if (config.show_home_icon and item_idx == 0) {
|
|
item_width += char_width + 4;
|
|
}
|
|
|
|
// Check if we have room
|
|
if (x + @as(i32, @intCast(item_width)) > max_x and !is_last) {
|
|
// No room, show ellipsis and skip to last
|
|
ctx.pushCommand(Command.text(x, y, "...", colors.separator));
|
|
item_idx = items.len - 2;
|
|
x += 3 * @as(i32, @intCast(char_width));
|
|
continue;
|
|
}
|
|
|
|
// Item bounds
|
|
const item_rect = Layout.Rect.init(x - 2, bounds.y, item_width + 4, bounds.h);
|
|
const is_hovered = item_rect.contains(mouse.x, mouse.y);
|
|
|
|
if (is_hovered) {
|
|
result.hovered = item_idx;
|
|
}
|
|
|
|
// Handle click
|
|
if (is_hovered and mouse_pressed and !item.disabled and !is_last) {
|
|
result.clicked = item_idx;
|
|
result.clicked_data = item.data;
|
|
}
|
|
|
|
// Determine color
|
|
var text_color: Style.Color = undefined;
|
|
if (item.disabled) {
|
|
text_color = colors.disabled;
|
|
} else if (is_last) {
|
|
text_color = colors.text_current;
|
|
} else if (is_hovered) {
|
|
text_color = colors.text_hover;
|
|
} else {
|
|
text_color = colors.text;
|
|
}
|
|
|
|
// Draw home icon
|
|
if (config.show_home_icon and item_idx == 0) {
|
|
ctx.pushCommand(Command.text(x, y, "~", colors.icon));
|
|
x += @as(i32, @intCast(char_width)) + 4;
|
|
}
|
|
|
|
// Draw icon
|
|
if (item.icon) |icon| {
|
|
const icon_str = &[_]u8{icon};
|
|
ctx.pushCommand(Command.text(x, y, icon_str, colors.icon));
|
|
x += @as(i32, @intCast(char_width)) + 4;
|
|
}
|
|
|
|
// Draw label
|
|
ctx.pushCommand(Command.text(x, y, item.label, text_color));
|
|
x += @as(i32, @intCast(item.label.len * char_width));
|
|
|
|
// Draw separator (except for last item)
|
|
if (!is_last) {
|
|
ctx.pushCommand(Command.text(x, y, config.separator, colors.separator));
|
|
x += @as(i32, @intCast(config.separator.len * char_width));
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// Create a path from a slash-separated string
|
|
pub fn fromPath(allocator: std.mem.Allocator, path: []const u8) ![]Item {
|
|
var items: std.ArrayListUnmanaged(Item) = .{};
|
|
errdefer items.deinit(allocator);
|
|
|
|
var start: usize = 0;
|
|
var i: usize = 0;
|
|
|
|
while (i <= path.len) : (i += 1) {
|
|
const at_sep = i < path.len and path[i] == '/';
|
|
const at_end = i == path.len;
|
|
|
|
if (at_sep or at_end) {
|
|
if (i > start) {
|
|
try items.append(allocator, .{
|
|
.label = path[start..i],
|
|
.data = path[0..i],
|
|
});
|
|
}
|
|
start = i + 1;
|
|
}
|
|
}
|
|
|
|
return items.toOwnedSlice(allocator);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Tests
|
|
// =============================================================================
|
|
|
|
test "Item creation" {
|
|
const item = Item{
|
|
.label = "Home",
|
|
.icon = '~',
|
|
.data = "/home",
|
|
};
|
|
|
|
try std.testing.expectEqualStrings("Home", item.label);
|
|
try std.testing.expectEqual(@as(?u8, '~'), item.icon);
|
|
}
|
|
|
|
test "breadcrumb generates commands" {
|
|
var ctx = try Context.init(std.testing.allocator, 800, 600);
|
|
defer ctx.deinit();
|
|
|
|
const items = [_]Item{
|
|
.{ .label = "Home" },
|
|
.{ .label = "Documents" },
|
|
.{ .label = "File.txt" },
|
|
};
|
|
|
|
ctx.beginFrame();
|
|
ctx.layout.row_height = 24;
|
|
|
|
_ = breadcrumb(&ctx, &items);
|
|
|
|
// Should have text commands for labels and separators
|
|
try std.testing.expect(ctx.commands.items.len >= 3);
|
|
|
|
ctx.endFrame();
|
|
}
|
|
|
|
test "fromPath" {
|
|
const items = try fromPath(std.testing.allocator, "/home/user/docs");
|
|
defer std.testing.allocator.free(items);
|
|
|
|
try std.testing.expectEqual(@as(usize, 3), items.len);
|
|
try std.testing.expectEqualStrings("home", items[0].label);
|
|
try std.testing.expectEqualStrings("user", items[1].label);
|
|
try std.testing.expectEqualStrings("docs", items[2].label);
|
|
}
|
|
|
|
test "fromPath with data" {
|
|
const items = try fromPath(std.testing.allocator, "a/b/c");
|
|
defer std.testing.allocator.free(items);
|
|
|
|
try std.testing.expectEqualStrings("a", items[0].data.?);
|
|
try std.testing.expectEqualStrings("a/b", items[1].data.?);
|
|
try std.testing.expectEqualStrings("a/b/c", items[2].data.?);
|
|
}
|