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>
440 lines
13 KiB
Zig
440 lines
13 KiB
Zig
//! NavDrawer Widget - Navigation drawer
|
|
//!
|
|
//! A side panel for app navigation with items and optional header.
|
|
//! Can be static or modal (with scrim overlay).
|
|
|
|
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");
|
|
|
|
/// Navigation item
|
|
pub const NavItem = struct {
|
|
/// Item ID for selection tracking
|
|
id: u32,
|
|
/// Item label
|
|
label: []const u8,
|
|
/// Optional icon
|
|
icon: ?icon_module.IconType = null,
|
|
/// Badge text (e.g., notification count)
|
|
badge: ?[]const u8 = null,
|
|
/// Disabled state
|
|
disabled: bool = false,
|
|
/// Divider after this item
|
|
divider_after: bool = false,
|
|
};
|
|
|
|
/// Drawer header
|
|
pub const Header = struct {
|
|
/// Header title
|
|
title: []const u8,
|
|
/// Subtitle
|
|
subtitle: ?[]const u8 = null,
|
|
/// Header height
|
|
height: u16 = 160,
|
|
};
|
|
|
|
/// NavDrawer state
|
|
pub const State = struct {
|
|
/// Currently selected item ID
|
|
selected_id: ?u32 = null,
|
|
/// Hovered item ID
|
|
hovered_id: ?u32 = null,
|
|
/// Is drawer open (for modal drawer)
|
|
is_open: bool = false,
|
|
/// Animation progress (0 = closed, 1 = open)
|
|
animation_progress: f32 = 0,
|
|
|
|
pub fn init() State {
|
|
return .{};
|
|
}
|
|
|
|
pub fn open(self: *State) void {
|
|
self.is_open = true;
|
|
}
|
|
|
|
pub fn close(self: *State) void {
|
|
self.is_open = false;
|
|
}
|
|
|
|
pub fn toggle(self: *State) void {
|
|
self.is_open = !self.is_open;
|
|
}
|
|
};
|
|
|
|
/// NavDrawer configuration
|
|
pub const Config = struct {
|
|
/// Drawer width
|
|
width: u16 = 280,
|
|
/// Navigation items
|
|
items: []const NavItem = &.{},
|
|
/// Optional header
|
|
header: ?Header = null,
|
|
/// Item height
|
|
item_height: u16 = 48,
|
|
/// Show selection indicator
|
|
show_indicator: bool = true,
|
|
};
|
|
|
|
/// NavDrawer colors
|
|
pub const Colors = struct {
|
|
/// Drawer background
|
|
background: Style.Color = Style.Color.rgb(30, 30, 30),
|
|
/// Header background
|
|
header_bg: Style.Color = Style.Color.rgb(45, 45, 45),
|
|
/// Header title
|
|
header_title: Style.Color = Style.Color.rgb(255, 255, 255),
|
|
/// Header subtitle
|
|
header_subtitle: Style.Color = Style.Color.rgb(180, 180, 180),
|
|
/// Item text
|
|
item_text: Style.Color = Style.Color.rgb(220, 220, 220),
|
|
/// Item text (selected)
|
|
item_selected: Style.Color = Style.Color.rgb(66, 133, 244),
|
|
/// Item background (hover)
|
|
item_hover: Style.Color = Style.Color.rgba(255, 255, 255, 15),
|
|
/// Item background (selected)
|
|
item_selected_bg: Style.Color = Style.Color.rgba(66, 133, 244, 30),
|
|
/// Selection indicator
|
|
indicator: Style.Color = Style.Color.rgb(66, 133, 244),
|
|
/// Icon color
|
|
icon: Style.Color = Style.Color.rgb(180, 180, 180),
|
|
/// Icon color (selected)
|
|
icon_selected: Style.Color = Style.Color.rgb(66, 133, 244),
|
|
/// Divider
|
|
divider_color: Style.Color = Style.Color.rgb(60, 60, 60),
|
|
/// Badge background
|
|
badge_bg: Style.Color = Style.Color.rgb(244, 67, 54),
|
|
/// Badge text
|
|
badge_text: Style.Color = Style.Color.white,
|
|
/// Scrim (for modal)
|
|
scrim: Style.Color = Style.Color.rgba(0, 0, 0, 120),
|
|
|
|
pub fn fromTheme(theme: Style.Theme) Colors {
|
|
return .{
|
|
.background = theme.panel_bg,
|
|
.header_bg = theme.panel_bg.lighten(10),
|
|
.header_title = theme.foreground,
|
|
.header_subtitle = theme.foreground.darken(20),
|
|
.item_text = theme.foreground,
|
|
.item_selected = theme.primary,
|
|
.item_hover = theme.foreground.withAlpha(15),
|
|
.item_selected_bg = theme.primary.withAlpha(30),
|
|
.indicator = theme.primary,
|
|
.icon = theme.foreground.darken(20),
|
|
.icon_selected = theme.primary,
|
|
.divider_color = theme.border,
|
|
.badge_bg = theme.danger,
|
|
.badge_text = Style.Color.white,
|
|
.scrim = Style.Color.rgba(0, 0, 0, 120),
|
|
};
|
|
}
|
|
};
|
|
|
|
/// NavDrawer result
|
|
pub const Result = struct {
|
|
/// Item that was clicked (ID)
|
|
clicked: ?u32,
|
|
/// Drawer bounds
|
|
bounds: Layout.Rect,
|
|
/// Content area (to the right of drawer)
|
|
content_rect: Layout.Rect,
|
|
};
|
|
|
|
/// Static navigation drawer
|
|
pub fn navDrawer(ctx: *Context, state: *State, config: Config, colors: Colors) Result {
|
|
const bounds = Layout.Rect{
|
|
.x = 0,
|
|
.y = 0,
|
|
.w = config.width,
|
|
.h = ctx.layout.area.h,
|
|
};
|
|
|
|
return navDrawerRect(ctx, bounds, state, config, colors);
|
|
}
|
|
|
|
/// Navigation drawer in specific rectangle
|
|
pub fn navDrawerRect(
|
|
ctx: *Context,
|
|
bounds: Layout.Rect,
|
|
state: *State,
|
|
config: Config,
|
|
colors: Colors,
|
|
) Result {
|
|
if (bounds.isEmpty()) {
|
|
return .{
|
|
.clicked = null,
|
|
.bounds = bounds,
|
|
.content_rect = Layout.Rect{ .x = 0, .y = 0, .w = 0, .h = 0 },
|
|
};
|
|
}
|
|
|
|
var clicked: ?u32 = null;
|
|
|
|
// Draw background
|
|
ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, colors.background));
|
|
|
|
var current_y = bounds.y;
|
|
|
|
// Draw header
|
|
if (config.header) |header| {
|
|
ctx.pushCommand(Command.rect(bounds.x, current_y, bounds.w, header.height, colors.header_bg));
|
|
|
|
// Title
|
|
const title_x = bounds.x + 16;
|
|
const title_y = current_y + @as(i32, @intCast(header.height)) - 40;
|
|
ctx.pushCommand(Command.text(title_x, title_y, header.title, colors.header_title));
|
|
|
|
// Subtitle
|
|
if (header.subtitle) |subtitle| {
|
|
ctx.pushCommand(Command.text(title_x, title_y + 16, subtitle, colors.header_subtitle));
|
|
}
|
|
|
|
current_y += @as(i32, @intCast(header.height));
|
|
}
|
|
|
|
// Reset hovered
|
|
state.hovered_id = null;
|
|
|
|
// Draw items
|
|
const mouse = ctx.input.mousePos();
|
|
|
|
for (config.items) |item| {
|
|
const item_bounds = Layout.Rect{
|
|
.x = bounds.x,
|
|
.y = current_y,
|
|
.w = bounds.w,
|
|
.h = config.item_height,
|
|
};
|
|
|
|
const is_selected = state.selected_id == item.id;
|
|
const is_hovered = item_bounds.contains(mouse.x, mouse.y) and !item.disabled;
|
|
|
|
if (is_hovered) {
|
|
state.hovered_id = item.id;
|
|
}
|
|
|
|
// Handle click
|
|
if (is_hovered and ctx.input.mouseReleased(.left)) {
|
|
state.selected_id = item.id;
|
|
clicked = item.id;
|
|
}
|
|
|
|
// Draw item background
|
|
if (is_selected) {
|
|
ctx.pushCommand(Command.rect(item_bounds.x, item_bounds.y, item_bounds.w, item_bounds.h, colors.item_selected_bg));
|
|
|
|
// Selection indicator
|
|
if (config.show_indicator) {
|
|
ctx.pushCommand(Command.rect(item_bounds.x, item_bounds.y, 4, item_bounds.h, colors.indicator));
|
|
}
|
|
} else if (is_hovered) {
|
|
ctx.pushCommand(Command.rect(item_bounds.x, item_bounds.y, item_bounds.w, item_bounds.h, colors.item_hover));
|
|
}
|
|
|
|
// Draw icon
|
|
var text_x = bounds.x + 16;
|
|
if (item.icon) |icon_type| {
|
|
const icon_y = current_y + @as(i32, @intCast((config.item_height - 24) / 2));
|
|
const icon_color = if (is_selected) colors.icon_selected else colors.icon;
|
|
|
|
icon_module.iconRect(ctx, .{
|
|
.x = text_x,
|
|
.y = icon_y,
|
|
.w = 24,
|
|
.h = 24,
|
|
}, icon_type, .{}, .{ .foreground = icon_color });
|
|
|
|
text_x += 40;
|
|
}
|
|
|
|
// Draw label
|
|
const label_y = current_y + @as(i32, @intCast((config.item_height - 8) / 2));
|
|
const label_color = if (item.disabled)
|
|
colors.item_text.darken(40)
|
|
else if (is_selected)
|
|
colors.item_selected
|
|
else
|
|
colors.item_text;
|
|
|
|
ctx.pushCommand(Command.text(text_x, label_y, item.label, label_color));
|
|
|
|
// Draw badge
|
|
if (item.badge) |badge_text| {
|
|
if (badge_text.len > 0) {
|
|
const badge_w = @max(20, badge_text.len * 8 + 8);
|
|
const badge_x = bounds.x + @as(i32, @intCast(bounds.w)) - @as(i32, @intCast(badge_w)) - 16;
|
|
const badge_y = current_y + @as(i32, @intCast((config.item_height - 20) / 2));
|
|
|
|
ctx.pushCommand(Command.rect(badge_x, badge_y, @intCast(badge_w), 20, colors.badge_bg));
|
|
ctx.pushCommand(Command.text(badge_x + 6, badge_y + 6, badge_text, colors.badge_text));
|
|
}
|
|
}
|
|
|
|
current_y += @as(i32, @intCast(config.item_height));
|
|
|
|
// Draw divider
|
|
if (item.divider_after) {
|
|
ctx.pushCommand(Command.rect(bounds.x + 16, current_y, bounds.w - 32, 1, colors.divider_color));
|
|
current_y += 8;
|
|
}
|
|
}
|
|
|
|
// Content rect
|
|
const content_rect = Layout.Rect{
|
|
.x = bounds.x + @as(i32, @intCast(bounds.w)),
|
|
.y = 0,
|
|
.w = ctx.layout.area.w -| bounds.w,
|
|
.h = ctx.layout.area.h,
|
|
};
|
|
|
|
return .{
|
|
.clicked = clicked,
|
|
.bounds = bounds,
|
|
.content_rect = content_rect,
|
|
};
|
|
}
|
|
|
|
/// Modal navigation drawer with scrim
|
|
pub fn modalNavDrawer(
|
|
ctx: *Context,
|
|
state: *State,
|
|
config: Config,
|
|
colors: Colors,
|
|
) Result {
|
|
// Update animation
|
|
const target: f32 = if (state.is_open) 1.0 else 0.0;
|
|
const speed: f32 = 0.1;
|
|
if (state.animation_progress < target) {
|
|
state.animation_progress = @min(target, state.animation_progress + speed);
|
|
} else if (state.animation_progress > target) {
|
|
state.animation_progress = @max(target, state.animation_progress - speed);
|
|
}
|
|
|
|
if (state.animation_progress < 0.01) {
|
|
return .{
|
|
.clicked = null,
|
|
.bounds = Layout.Rect{ .x = 0, .y = 0, .w = 0, .h = 0 },
|
|
.content_rect = Layout.Rect{
|
|
.x = 0,
|
|
.y = 0,
|
|
.w = ctx.layout.area.w,
|
|
.h = ctx.layout.area.h,
|
|
},
|
|
};
|
|
}
|
|
|
|
// Draw scrim
|
|
const scrim_alpha = @as(u8, @intFromFloat(@as(f32, @floatFromInt(colors.scrim.a)) * state.animation_progress));
|
|
ctx.pushCommand(Command.rect(
|
|
0,
|
|
0,
|
|
ctx.layout.area.w,
|
|
ctx.layout.area.h,
|
|
colors.scrim.withAlpha(scrim_alpha),
|
|
));
|
|
|
|
// Handle scrim click to close
|
|
const mouse = ctx.input.mousePos();
|
|
if (ctx.input.mouseReleased(.left) and mouse.x > @as(i32, @intCast(config.width))) {
|
|
state.close();
|
|
}
|
|
|
|
// Slide in drawer
|
|
const drawer_x = -@as(i32, @intCast(config.width)) + @as(i32, @intFromFloat(@as(f32, @floatFromInt(config.width)) * state.animation_progress));
|
|
|
|
const bounds = Layout.Rect{
|
|
.x = drawer_x,
|
|
.y = 0,
|
|
.w = config.width,
|
|
.h = ctx.layout.area.h,
|
|
};
|
|
|
|
var result = navDrawerRect(ctx, bounds, state, config, colors);
|
|
result.content_rect = Layout.Rect{
|
|
.x = 0,
|
|
.y = 0,
|
|
.w = ctx.layout.area.w,
|
|
.h = ctx.layout.area.h,
|
|
};
|
|
|
|
return result;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Tests
|
|
// =============================================================================
|
|
|
|
test "navDrawer state" {
|
|
var state = State.init();
|
|
try std.testing.expect(!state.is_open);
|
|
|
|
state.open();
|
|
try std.testing.expect(state.is_open);
|
|
|
|
state.toggle();
|
|
try std.testing.expect(!state.is_open);
|
|
}
|
|
|
|
test "navDrawer generates commands" {
|
|
var ctx = try Context.init(std.testing.allocator, 800, 600);
|
|
defer ctx.deinit();
|
|
|
|
var state = State.init();
|
|
|
|
ctx.beginFrame();
|
|
|
|
const items = [_]NavItem{
|
|
.{ .id = 1, .label = "Home", .icon = .home },
|
|
.{ .id = 2, .label = "Settings", .icon = .settings },
|
|
};
|
|
|
|
const result = navDrawer(&ctx, &state, .{ .items = &items }, .{});
|
|
|
|
try std.testing.expect(ctx.commands.items.len >= 3);
|
|
try std.testing.expect(result.content_rect.x > 0);
|
|
|
|
ctx.endFrame();
|
|
}
|
|
|
|
test "navDrawer with header" {
|
|
var ctx = try Context.init(std.testing.allocator, 800, 600);
|
|
defer ctx.deinit();
|
|
|
|
var state = State.init();
|
|
|
|
ctx.beginFrame();
|
|
|
|
_ = navDrawer(&ctx, &state, .{
|
|
.header = .{ .title = "My App" },
|
|
}, .{});
|
|
|
|
// Should include header background and title
|
|
try std.testing.expect(ctx.commands.items.len >= 2);
|
|
|
|
ctx.endFrame();
|
|
}
|
|
|
|
test "navDrawer selection" {
|
|
var ctx = try Context.init(std.testing.allocator, 800, 600);
|
|
defer ctx.deinit();
|
|
|
|
var state = State.init();
|
|
state.selected_id = 1;
|
|
|
|
ctx.beginFrame();
|
|
|
|
const items = [_]NavItem{
|
|
.{ .id = 1, .label = "Home" },
|
|
.{ .id = 2, .label = "About" },
|
|
};
|
|
|
|
_ = navDrawer(&ctx, &state, .{ .items = &items }, .{});
|
|
|
|
// Selection should be visible
|
|
try std.testing.expect(ctx.commands.items.len >= 3);
|
|
|
|
ctx.endFrame();
|
|
}
|