- Añadir config.minimal: bool para activar estilo sin fondos - Añadir config.indicator_height para altura personalizable del underline - En modo minimal: no dibujar fondos de tabs - Texto inactivo usa text_secondary, activo usa primary - Hover en minimal ilumina el texto - Mantener retrocompatibilidad (minimal=false por defecto) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
485 lines
15 KiB
Zig
485 lines
15 KiB
Zig
//! 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();
|
|
}
|