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>
333 lines
8.7 KiB
Zig
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();
|
|
}
|