zcatgui/src/widgets/tabs.zig
R.Eugenio ae600f4341 feat: Modo minimal para tabs (estilo Laravel)
- 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>
2025-12-30 00:13:51 +01:00

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();
}