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