zcatgui/src/widgets/appbar.zig
reugenio 91e13f6956 feat: zcatgui Gio parity - 12 new widgets + gesture system
New widgets (12):
- Switch: Toggle switch with animation
- IconButton: Circular icon button (filled/outlined/ghost/tonal)
- Divider: Horizontal/vertical separator with optional label
- Loader: 7 spinner styles (circular/dots/bars/pulse/bounce/ring/square)
- Surface: Elevated container with shadow layers
- Grid: Layout grid with scrolling and selection
- Resize: Draggable resize handle (horizontal/vertical/both)
- AppBar: Application bar (top/bottom) with actions
- NavDrawer: Navigation drawer with items, icons, badges
- Sheet: Side/bottom sliding panel with modal support
- Discloser: Expandable/collapsible container (3 icon styles)
- Selectable: Clickable region with selection modes

Core systems added:
- GestureRecognizer: Tap, double-tap, long-press, drag, swipe
- Velocity tracking and fling detection
- Spring physics for fluid animations

Integration:
- All widgets exported via widgets.zig
- GestureRecognizer exported via zcatgui.zig
- Spring/SpringConfig exported from animation.zig
- Color.withAlpha() method added to style.zig

Stats: 47 widget files, 338+ tests, +5,619 LOC
Full Gio UI parity achieved.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 17:21:15 +01:00

333 lines
8.7 KiB
Zig

//! AppBar Widget - Application bar
//!
//! A top or bottom bar for app navigation and actions.
//! Supports leading icon, title, and action buttons.
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");
const icon_module = @import("icon.zig");
const iconbutton = @import("iconbutton.zig");
/// AppBar position
pub const Position = enum {
top,
bottom,
};
/// AppBar action button
pub const Action = struct {
/// Action icon
icon_type: icon_module.IconType,
/// Action ID (for click detection)
id: u32,
/// Tooltip text
tooltip: ?[]const u8 = null,
/// Badge (notification count, etc.)
badge: ?[]const u8 = null,
/// Disabled state
disabled: bool = false,
};
/// AppBar configuration
pub const Config = struct {
/// Bar position
position: Position = .top,
/// Bar height
height: u16 = 56,
/// Title text
title: []const u8 = "",
/// Subtitle text
subtitle: ?[]const u8 = null,
/// Leading icon (e.g., menu, back)
leading_icon: ?icon_module.IconType = null,
/// Action buttons
actions: []const Action = &.{},
/// Elevation
elevated: bool = true,
/// Center title
center_title: bool = false,
};
/// AppBar colors
pub const Colors = struct {
/// Background
background: Style.Color = Style.Color.rgb(33, 33, 33),
/// Title color
title: Style.Color = Style.Color.rgb(255, 255, 255),
/// Subtitle color
subtitle: Style.Color = Style.Color.rgb(180, 180, 180),
/// Icon color
icon: Style.Color = Style.Color.rgb(255, 255, 255),
/// Shadow color
shadow: Style.Color = Style.Color.rgba(0, 0, 0, 40),
pub fn fromTheme(theme: Style.Theme) Colors {
return .{
.background = theme.primary,
.title = Style.Color.white,
.subtitle = Style.Color.white.darken(20),
.icon = Style.Color.white,
.shadow = Style.Color.rgba(0, 0, 0, 40),
};
}
};
/// AppBar result
pub const Result = struct {
/// Leading icon clicked
leading_clicked: bool,
/// Action that was clicked (ID)
action_clicked: ?u32,
/// Bar bounds
bounds: Layout.Rect,
/// Content area (below/above the bar)
content_rect: Layout.Rect,
};
/// Simple app bar with title
pub fn appBar(ctx: *Context, title_text: []const u8) Result {
return appBarEx(ctx, .{ .title = title_text }, .{});
}
/// App bar with configuration
pub fn appBarEx(ctx: *Context, config: Config, colors: Colors) Result {
const screen_width = ctx.layout.area.w;
const bar_y: i32 = if (config.position == .top) 0 else @as(i32, @intCast(ctx.layout.area.h - config.height));
const bounds = Layout.Rect{
.x = 0,
.y = bar_y,
.w = screen_width,
.h = config.height,
};
return appBarRect(ctx, bounds, config, colors);
}
/// App bar in specific rectangle
pub fn appBarRect(
ctx: *Context,
bounds: Layout.Rect,
config: Config,
colors: Colors,
) Result {
if (bounds.isEmpty()) {
return .{
.leading_clicked = false,
.action_clicked = null,
.bounds = bounds,
.content_rect = Layout.Rect{ .x = 0, .y = 0, .w = 0, .h = 0 },
};
}
var leading_clicked = false;
var action_clicked: ?u32 = null;
// Draw shadow (if elevated and at top)
if (config.elevated and config.position == .top) {
ctx.pushCommand(Command.rect(
bounds.x,
bounds.y + @as(i32, @intCast(bounds.h)),
bounds.w,
4,
colors.shadow,
));
} else if (config.elevated and config.position == .bottom) {
ctx.pushCommand(Command.rect(
bounds.x,
bounds.y - 4,
bounds.w,
4,
colors.shadow,
));
}
// Draw background
ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, colors.background));
const padding: i32 = 8;
var current_x = bounds.x + padding;
const center_y = bounds.y + @as(i32, @intCast(bounds.h / 2));
// Draw leading icon
if (config.leading_icon) |icon_type| {
const icon_size: u32 = 24;
const icon_bounds = Layout.Rect{
.x = current_x,
.y = center_y - @as(i32, @intCast(icon_size / 2)),
.w = 36,
.h = 36,
};
const result = iconbutton.iconButtonRect(ctx, icon_bounds, .{
.icon_type = icon_type,
.size = .medium,
.style = .ghost,
}, .{
.icon = colors.icon,
.icon_hover = colors.icon,
.ghost_hover = colors.icon.withAlpha(30),
});
if (result.clicked) {
leading_clicked = true;
}
current_x += 44;
}
// Calculate title position
const title_y = if (config.subtitle != null)
center_y - 10
else
center_y - 4;
// Draw title
if (config.title.len > 0) {
var title_x = current_x + 8;
if (config.center_title) {
const title_width = config.title.len * 8;
title_x = bounds.x + @as(i32, @intCast((bounds.w - @as(u32, @intCast(title_width))) / 2));
}
ctx.pushCommand(Command.text(title_x, title_y, config.title, colors.title));
// Draw subtitle
if (config.subtitle) |subtitle_text| {
ctx.pushCommand(Command.text(title_x, title_y + 12, subtitle_text, colors.subtitle));
}
}
// Draw action buttons (right side)
var action_x = bounds.x + @as(i32, @intCast(bounds.w)) - padding;
for (config.actions) |action| {
const icon_size: u32 = 36;
action_x -= @as(i32, @intCast(icon_size));
const action_bounds = Layout.Rect{
.x = action_x,
.y = center_y - @as(i32, @intCast(icon_size / 2)),
.w = icon_size,
.h = icon_size,
};
const result = iconbutton.iconButtonRect(ctx, action_bounds, .{
.icon_type = action.icon_type,
.size = .medium,
.style = .ghost,
.disabled = action.disabled,
.badge = action.badge,
}, .{
.icon = colors.icon,
.icon_hover = colors.icon,
.ghost_hover = colors.icon.withAlpha(30),
});
if (result.clicked) {
action_clicked = action.id;
}
action_x -= 4; // Spacing
}
// Calculate content rect
const content_rect = if (config.position == .top)
Layout.Rect{
.x = 0,
.y = bounds.y + @as(i32, @intCast(bounds.h)),
.w = bounds.w,
.h = ctx.layout.area.h -| bounds.h,
}
else
Layout.Rect{
.x = 0,
.y = 0,
.w = bounds.w,
.h = ctx.layout.area.h -| bounds.h,
};
return .{
.leading_clicked = leading_clicked,
.action_clicked = action_clicked,
.bounds = bounds,
.content_rect = content_rect,
};
}
// =============================================================================
// Tests
// =============================================================================
test "appBar generates commands" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
ctx.beginFrame();
const result = appBar(&ctx, "My App");
// Should generate: shadow + background + title
try std.testing.expect(ctx.commands.items.len >= 2);
try std.testing.expect(result.bounds.h == 56);
ctx.endFrame();
}
test "appBar with actions" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
ctx.beginFrame();
const actions = [_]Action{
.{ .icon_type = .search, .id = 1 },
.{ .icon_type = .settings, .id = 2 },
};
_ = appBarEx(&ctx, .{
.title = "My App",
.actions = &actions,
}, .{});
try std.testing.expect(ctx.commands.items.len >= 4);
ctx.endFrame();
}
test "appBar with leading icon" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
ctx.beginFrame();
_ = appBarEx(&ctx, .{
.title = "My App",
.leading_icon = .menu,
}, .{});
try std.testing.expect(ctx.commands.items.len >= 3);
ctx.endFrame();
}
test "appBar bottom position" {
var ctx = try Context.init(std.testing.allocator, 800, 600);
defer ctx.deinit();
ctx.beginFrame();
const result = appBarEx(&ctx, .{
.title = "Bottom Bar",
.position = .bottom,
}, .{});
try std.testing.expect(result.bounds.y > 0);
ctx.endFrame();
}