zcatgui/src/widgets/navdrawer.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/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();
}