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