//! Tabs Widget - Tab bar for switching between views //! //! Provides: //! - TabBar: Horizontal tab strip //! - Tabs at top or bottom //! - Closable tabs (optional) //! //! Supports: //! - Keyboard navigation (left/right arrows) //! - Mouse click to select //! - Close button on tabs 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"); // ============================================================================= // Tab Definition // ============================================================================= /// Tab definition pub const Tab = struct { /// Tab label label: []const u8, /// Tab ID (for callbacks) id: u32 = 0, /// Is tab closable closable: bool = false, /// Is tab disabled disabled: bool = false, }; // ============================================================================= // Tabs State // ============================================================================= /// Tabs state (caller-managed) pub const TabsState = struct { /// Currently selected tab index selected: usize = 0, /// Tab hovered by mouse (-1 for none) hovered: i32 = -1, /// Close button hovered (-1 for none) close_hovered: i32 = -1, /// Whether this widget has focus focused: bool = false, const Self = @This(); /// Select next tab pub fn selectNext(self: *Self, tab_count: usize) void { if (tab_count == 0) return; self.selected = (self.selected + 1) % tab_count; } /// Select previous tab pub fn selectPrev(self: *Self, tab_count: usize) void { if (tab_count == 0) return; if (self.selected == 0) { self.selected = tab_count - 1; } else { self.selected -= 1; } } /// Select specific tab pub fn selectTab(self: *Self, index: usize, tab_count: usize) void { if (index < tab_count) { self.selected = index; } } }; // ============================================================================= // Tabs Configuration // ============================================================================= /// Tab position pub const TabPosition = enum { top, bottom, }; /// Tabs configuration pub const TabsConfig = struct { /// Tab position position: TabPosition = .top, /// Tab height tab_height: u32 = 28, /// Horizontal padding per tab padding_h: u32 = 16, /// Minimum tab width min_tab_width: u32 = 60, /// Maximum tab width (0 = unlimited) max_tab_width: u32 = 200, /// Show close buttons show_close: bool = false, /// Close button size close_size: u32 = 14, /// Minimal style (no backgrounds, only text + underline indicator) minimal: bool = false, /// Underline indicator height (for minimal mode) indicator_height: u32 = 2, }; /// Tabs colors pub const TabsColors = struct { /// Tab bar background bar_bg: Style.Color = Style.Color.rgb(35, 35, 40), /// Inactive tab background tab_bg: Style.Color = Style.Color.rgb(45, 45, 50), /// Active tab background tab_active_bg: Style.Color = Style.Color.rgb(55, 55, 60), /// Hovered tab background tab_hover_bg: Style.Color = Style.Color.rgb(50, 50, 55), /// Tab text tab_text: Style.Color = Style.Color.rgb(180, 180, 180), /// Active tab text tab_active_text: Style.Color = Style.Color.rgb(240, 240, 240), /// Disabled tab text tab_disabled_text: Style.Color = Style.Color.rgb(100, 100, 100), /// Tab border tab_border: Style.Color = Style.Color.rgb(60, 60, 65), /// Active tab indicator indicator: Style.Color = Style.Color.primary, /// Close button color close_color: Style.Color = Style.Color.rgb(150, 150, 150), /// Close button hover color close_hover: Style.Color = Style.Color.rgb(200, 100, 100), }; /// Tabs result pub const TabsResult = struct { /// Tab selection changed changed: bool = false, /// Newly selected tab index selected: usize = 0, /// Tab was closed closed: bool = false, /// Closed tab index closed_index: ?usize = null, /// Content area rectangle (below/above tabs) content_area: Layout.Rect = Layout.Rect.init(0, 0, 0, 0), }; // ============================================================================= // Tabs Functions // ============================================================================= /// Draw a tab bar pub fn tabs( ctx: *Context, state: *TabsState, tab_list: []const Tab, ) TabsResult { return tabsEx(ctx, state, tab_list, .{}, .{}); } /// Draw a tab bar with configuration pub fn tabsEx( ctx: *Context, state: *TabsState, tab_list: []const Tab, config: TabsConfig, colors: TabsColors, ) TabsResult { const bounds = ctx.layout.nextRect(); return tabsRect(ctx, bounds, state, tab_list, config, colors); } /// Draw a tab bar in a specific rectangle pub fn tabsRect( ctx: *Context, bounds: Layout.Rect, state: *TabsState, tab_list: []const Tab, config: TabsConfig, colors: TabsColors, ) TabsResult { var result = TabsResult{ .selected = state.selected, }; if (bounds.isEmpty() or tab_list.len == 0) return result; // Generate unique ID for this widget based on state address const widget_id: u64 = @intFromPtr(state); // Register as focusable in the active focus group ctx.registerFocusable(widget_id); const mouse = ctx.input.mousePos(); const mouse_pressed = ctx.input.mousePressed(.left); // Calculate tab bar position const bar_rect = if (config.position == .top) blk: { break :blk Layout.Rect.init(bounds.x, bounds.y, bounds.w, config.tab_height); } else blk: { break :blk Layout.Rect.init( bounds.x, bounds.y + @as(i32, @intCast(bounds.h -| config.tab_height)), bounds.w, config.tab_height, ); }; // Calculate content area result.content_area = if (config.position == .top) blk: { break :blk Layout.Rect.init( bounds.x, bounds.y + @as(i32, @intCast(config.tab_height)), bounds.w, bounds.h -| config.tab_height, ); } else blk: { break :blk Layout.Rect.init( bounds.x, bounds.y, bounds.w, bounds.h -| config.tab_height, ); }; // Draw tab bar background ctx.pushCommand(Command.rect(bar_rect.x, bar_rect.y, bar_rect.w, bar_rect.h, colors.bar_bg)); // Reset hover states state.hovered = -1; state.close_hovered = -1; // Calculate tab widths var total_width: u32 = 0; var tab_widths: [32]u32 = undefined; for (tab_list, 0..) |tab, i| { if (i >= tab_widths.len) break; var width: u32 = @intCast(tab.label.len * 8 + config.padding_h * 2); if (config.show_close and tab.closable) { width += config.close_size + 8; } width = std.math.clamp(width, config.min_tab_width, if (config.max_tab_width > 0) config.max_tab_width else width); tab_widths[i] = width; total_width += width; } // Draw tabs var tab_x = bar_rect.x; for (tab_list, 0..) |tab, i| { if (i >= tab_widths.len) break; const tab_width = tab_widths[i]; const tab_rect = Layout.Rect.init(tab_x, bar_rect.y, tab_width, config.tab_height); const is_selected = state.selected == i; const is_hovered = tab_rect.contains(mouse.x, mouse.y) and !tab.disabled; if (is_hovered) { state.hovered = @intCast(i); } // Minimal mode: no backgrounds, just text + underline if (!config.minimal) { // Determine tab background const tab_bg = if (tab.disabled) colors.tab_bg.darken(10) else if (is_selected) colors.tab_active_bg else if (is_hovered) colors.tab_hover_bg else colors.tab_bg; // Draw tab background (rounded corners for selected tab in fancy mode) const tab_radius: u8 = if (is_selected) 4 else 0; if (Style.isFancy() and tab_radius > 0) { // Only round top corners for top position, bottom for bottom ctx.pushCommand(Command.roundedRect(tab_rect.x, tab_rect.y, tab_rect.w, tab_rect.h, tab_bg, tab_radius)); } else { ctx.pushCommand(Command.rect(tab_rect.x, tab_rect.y, tab_rect.w, tab_rect.h, tab_bg)); } } // Draw active indicator (underline) if (is_selected) { const indicator_h = config.indicator_height; const indicator_y = if (config.position == .top) bar_rect.y + @as(i32, @intCast(config.tab_height - indicator_h)) else bar_rect.y; ctx.pushCommand(Command.rect(tab_rect.x, indicator_y, tab_rect.w, indicator_h, colors.indicator)); } // Draw tab text (minimal mode: primary for active, text_secondary for inactive) const theme = Style.currentTheme().*; const text_color = if (tab.disabled) colors.tab_disabled_text else if (is_selected) if (config.minimal) theme.primary else colors.tab_active_text else if (is_hovered and config.minimal) colors.tab_active_text // Brighten on hover in minimal mode else if (config.minimal) theme.text_secondary else colors.tab_text; const text_y = bar_rect.y + @as(i32, @intCast((config.tab_height - 8) / 2)); ctx.pushCommand(Command.text(tab_x + @as(i32, @intCast(config.padding_h)), text_y, tab.label, text_color)); // Draw close button if (config.show_close and tab.closable) { const close_x = tab_x + @as(i32, @intCast(tab_width - config.close_size - 8)); const close_y = bar_rect.y + @as(i32, @intCast((config.tab_height - config.close_size) / 2)); const close_rect = Layout.Rect.init(close_x, close_y, config.close_size, config.close_size); const close_hovered = close_rect.contains(mouse.x, mouse.y); if (close_hovered) { state.close_hovered = @intCast(i); } const close_color = if (close_hovered) colors.close_hover else colors.close_color; // Draw X ctx.pushCommand(Command.text(close_x + 3, close_y + 2, "x", close_color)); // Handle close click if (mouse_pressed and close_hovered) { result.closed = true; result.closed_index = i; } } // Handle tab click if (mouse_pressed and is_hovered and state.close_hovered != @as(i32, @intCast(i))) { ctx.requestFocus(widget_id); if (state.selected != i) { state.selected = i; result.changed = true; result.selected = i; } } tab_x += @as(i32, @intCast(tab_width)); } // Check if this widget has focus const has_focus = ctx.hasFocus(widget_id); state.focused = has_focus; // Draw focus ring around the selected tab when focused if (has_focus and state.selected < tab_list.len) { // Calculate position of selected tab var focus_x = bar_rect.x; for (0..state.selected) |i| { if (i < tab_widths.len) { focus_x += @as(i32, @intCast(tab_widths[i])); } } const focus_w = if (state.selected < tab_widths.len) tab_widths[state.selected] else 0; if (focus_w > 0) { const focus_rect = Layout.Rect.init(focus_x, bar_rect.y, focus_w, config.tab_height); if (Style.isFancy()) { ctx.pushCommand(Command.focusRing(focus_rect.x, focus_rect.y, focus_rect.w, focus_rect.h, 4)); } else { ctx.pushCommand(Command.rectOutline( focus_rect.x - 1, focus_rect.y - 1, focus_rect.w + 2, focus_rect.h + 2, colors.indicator, )); } } } // Handle keyboard navigation (only when focused) if (has_focus and ctx.input.keyPressed(.left)) { // Find previous non-disabled tab var prev = if (state.selected == 0) tab_list.len - 1 else state.selected - 1; var attempts: usize = 0; while (attempts < tab_list.len and tab_list[prev].disabled) { prev = if (prev == 0) tab_list.len - 1 else prev - 1; attempts += 1; } if (!tab_list[prev].disabled and prev != state.selected) { state.selected = prev; result.changed = true; result.selected = prev; } } if (has_focus and ctx.input.keyPressed(.right)) { // Find next non-disabled tab var next = (state.selected + 1) % tab_list.len; var attempts: usize = 0; while (attempts < tab_list.len and tab_list[next].disabled) { next = (next + 1) % tab_list.len; attempts += 1; } if (!tab_list[next].disabled and next != state.selected) { state.selected = next; result.changed = true; result.selected = next; } } return result; } // ============================================================================= // Convenience Functions // ============================================================================= /// Create tabs from string labels pub fn tabsFromLabels( ctx: *Context, state: *TabsState, labels: []const []const u8, ) TabsResult { var tab_list: [32]Tab = undefined; const count = @min(labels.len, tab_list.len); for (0..count) |i| { tab_list[i] = .{ .label = labels[i], .id = @intCast(i) }; } return tabs(ctx, state, tab_list[0..count]); } // ============================================================================= // Tests // ============================================================================= test "TabsState select navigation" { var state = TabsState{}; state.selectNext(5); try std.testing.expectEqual(@as(usize, 1), state.selected); state.selectNext(5); try std.testing.expectEqual(@as(usize, 2), state.selected); state.selectPrev(5); try std.testing.expectEqual(@as(usize, 1), state.selected); // Wrap around state.selected = 4; state.selectNext(5); try std.testing.expectEqual(@as(usize, 0), state.selected); state.selectPrev(5); try std.testing.expectEqual(@as(usize, 4), state.selected); } test "TabsState selectTab" { var state = TabsState{}; state.selectTab(3, 5); try std.testing.expectEqual(@as(usize, 3), state.selected); // Out of bounds - no change state.selectTab(10, 5); try std.testing.expectEqual(@as(usize, 3), state.selected); } test "tabs generates commands" { var ctx = try Context.init(std.testing.allocator, 800, 600); defer ctx.deinit(); var state = TabsState{}; const tab_list = [_]Tab{ .{ .label = "Tab 1" }, .{ .label = "Tab 2" }, .{ .label = "Tab 3" }, }; ctx.beginFrame(); ctx.layout.row_height = 200; _ = tabs(&ctx, &state, &tab_list); // Should generate: bar bg + tab bgs + indicator + texts try std.testing.expect(ctx.commands.items.len >= 5); ctx.endFrame(); }