zcatgui/src/widgets/breadcrumb.zig
reugenio 34dfcfce18 feat: zcatgui v0.10.0 - Phase 4 Text & Navigation Widgets
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>
2025-12-09 13:27:21 +01:00

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