//! Context - Central state for immediate mode UI //! //! The Context holds all state needed for a frame: //! - Input state (keyboard, mouse) //! - Command list (draw commands) //! - Layout state //! - ID tracking for widgets //! - Focus management for keyboard navigation //! //! ## Performance Features //! - FrameArena for O(1) per-frame allocations //! - Command pooling for zero-allocation hot paths //! - Dirty rectangle tracking for minimal redraws //! //! ## Focus Management //! The Context uses FocusSystem for managing widget focus: //! - Group 0 is the implicit global group (always exists) //! - If no groups are created, Tab navigates all widgets (like microui/Gio) //! - If groups are created, Tab navigates within active group //! //! Usage (simple app - no groups needed): //! ```zig //! ctx.focus.register(widget_id); //! if (ctx.focus.hasFocus(widget_id)) { ... } //! ``` //! //! Usage (complex app with panels): //! ```zig //! _ = ctx.focus.createGroup(1); // Create group for panel 1 //! _ = ctx.focus.createGroup(2); // Create group for panel 2 //! //! ctx.focus.setActiveGroup(1); //! panel1.draw(); // widgets register in group 1 //! //! ctx.focus.setActiveGroup(2); //! panel2.draw(); // widgets register in group 2 //! ``` const std = @import("std"); const Allocator = std.mem.Allocator; const Command = @import("command.zig"); const Input = @import("input.zig"); const Layout = @import("layout.zig"); const Style = @import("style.zig"); const animation = @import("../render/animation.zig"); pub const ColorTransition = animation.ColorTransition; const arena_mod = @import("../utils/arena.zig"); const FrameArena = arena_mod.FrameArena; const focus_mod = @import("focus.zig"); const FocusSystem = focus_mod.FocusSystem; const FocusGroup = focus_mod.FocusGroup; /// Central context for immediate mode UI pub const Context = struct { /// Parent allocator (for long-lived allocations) allocator: Allocator, /// Frame arena for per-frame allocations (reset each frame) frame_arena: FrameArena, /// Draw commands for current frame commands: std.ArrayListUnmanaged(Command.DrawCommand), /// Overlay commands (drawn AFTER all regular commands - for dropdowns, tooltips, popups) overlay_commands: std.ArrayListUnmanaged(Command.DrawCommand), /// Input state input: Input.InputState, /// Layout state layout: Layout.LayoutState, /// ID stack for widget identification id_stack: std.ArrayListUnmanaged(u32), /// Current frame number frame: u64, /// Screen dimensions width: u32, height: u32, /// Dirty rectangles for partial redraw dirty_rects: std.ArrayListUnmanaged(Layout.Rect), /// Whether the entire screen needs redraw full_redraw: bool, /// Frame statistics stats: FrameStats, /// Unified focus management system focus: FocusSystem, /// Current time in milliseconds (set by application each frame) /// Used for animations, cursor blinking, etc. current_time_ms: u64 = 0, /// Time delta since last frame in milliseconds frame_delta_ms: u32 = 0, /// Last time there was user input (keyboard or mouse activity) /// Used for idle detection (e.g., cursor stops blinking after inactivity) last_input_time_ms: u64 = 0, /// Flag set by widgets that have ongoing animations (e.g., color transitions). /// Main loop should check this and request another frame if true. needs_animation_frame: bool = false, /// Optional text measurement function (set by application with TTF font) /// Returns pixel width of text. If null, falls back to char_width * len. text_measure_fn: ?*const fn ([]const u8) u32 = null, /// Default character width for fallback measurement (bitmap fonts) char_width: u32 = 8, /// Default character height for vertical centering (TTF fonts typically 1.2-1.5x width) char_height: u32 = 14, /// Idle timeout for cursor blinking (ms). After this time without input, /// cursor becomes solid and no animation frames are needed. pub const CURSOR_IDLE_TIMEOUT_MS: u64 = 20000; /// Cursor blink period (ms). Cursor toggles visibility at this rate. /// 300ms = ~3.3 blinks/sec (faster for better editing feedback) pub const CURSOR_BLINK_PERIOD_MS: u64 = 300; const Self = @This(); /// Frame statistics for performance monitoring pub const FrameStats = struct { /// Number of commands this frame command_count: usize = 0, /// Number of widgets drawn widget_count: usize = 0, /// Arena bytes used this frame arena_bytes: usize = 0, /// High water mark for arena arena_high_water: usize = 0, /// Number of dirty rectangles dirty_rect_count: usize = 0, }; /// Initialize a new context pub fn init(allocator: Allocator, width: u32, height: u32) !Self { return .{ .allocator = allocator, .frame_arena = try FrameArena.init(allocator), .commands = .{}, .overlay_commands = .{}, .input = Input.InputState.init(), .layout = Layout.LayoutState.init(width, height), .id_stack = .{}, .frame = 0, .width = width, .height = height, .dirty_rects = .{}, .full_redraw = true, .stats = .{}, .focus = FocusSystem.init(), }; } /// Initialize with custom arena size pub fn initWithArenaSize(allocator: Allocator, width: u32, height: u32, arena_size: usize) !Self { return .{ .allocator = allocator, .frame_arena = try FrameArena.initWithSize(allocator, arena_size), .commands = .{}, .overlay_commands = .{}, .input = Input.InputState.init(), .layout = Layout.LayoutState.init(width, height), .id_stack = .{}, .frame = 0, .width = width, .height = height, .dirty_rects = .{}, .full_redraw = true, .stats = .{}, .focus = FocusSystem.init(), }; } /// Clean up resources pub fn deinit(self: *Self) void { self.commands.deinit(self.allocator); self.overlay_commands.deinit(self.allocator); self.id_stack.deinit(self.allocator); self.dirty_rects.deinit(self.allocator); self.frame_arena.deinit(); } /// Begin a new frame pub fn beginFrame(self: *Self) void { // Update stats before reset self.stats.arena_high_water = @max(self.stats.arena_high_water, self.frame_arena.highWaterMark()); // Reset per-frame state self.commands.clearRetainingCapacity(); self.overlay_commands.clearRetainingCapacity(); self.id_stack.clearRetainingCapacity(); self.dirty_rects.clearRetainingCapacity(); self.layout.reset(self.width, self.height); self.frame_arena.reset(); // Reset frame stats self.stats.command_count = 0; self.stats.widget_count = 0; self.stats.arena_bytes = 0; self.stats.dirty_rect_count = 0; // Focus system frame start self.focus.beginFrame(); // Reset animation request (set by widgets during draw) self.needs_animation_frame = false; self.frame += 1; } /// End the current frame pub fn endFrame(self: *Self) void { // Focus system frame end (processes Tab navigation) self.focus.endFrame(); // Update last input time if there was activity this frame if (self.input.hasActivityWithMouse() and self.current_time_ms > 0) { self.last_input_time_ms = self.current_time_ms; } self.input.endFrame(); // Update final stats (includes overlay commands) self.stats.command_count = self.commands.items.len + self.overlay_commands.items.len; self.stats.arena_bytes = self.frame_arena.bytesUsed(); self.stats.dirty_rect_count = self.dirty_rects.items.len; // Reset full_redraw for next frame self.full_redraw = false; } // ========================================================================= // Focus convenience methods (delegate to self.focus) // These provide a cleaner API: ctx.hasFocus(id) instead of ctx.focus.hasFocus(id) // ========================================================================= /// Register a widget as focusable in the active group pub fn registerFocusable(self: *Self, widget_id: u64) void { self.focus.register(widget_id); } /// Check if widget has focus pub fn hasFocus(self: *Self, widget_id: u64) bool { return self.focus.hasFocus(widget_id); } /// Request focus for a widget pub fn requestFocus(self: *Self, widget_id: u64) void { self.focus.request(widget_id); } /// Handle Tab key (call this when Tab is pressed) pub fn handleTabKey(self: *Self, shift: bool) void { self.focus.handleTab(shift); } /// Create a new focus group pub fn createFocusGroup(self: *Self, group_id: u64) ?*FocusGroup { return self.focus.createGroup(group_id); } /// Set the active focus group (the group that receives keyboard input) /// Use this when focus changes between panels (F6, click on panel, etc.) pub fn setActiveFocusGroup(self: *Self, group_id: u64) void { self.focus.setActiveGroup(group_id); } /// Set the registration group (for widget registration during draw) /// Use this before drawing each panel to register its widgets in the correct group. /// This does NOT change which group has keyboard focus. pub fn setRegistrationGroup(self: *Self, group_id: u64) void { self.focus.setRegistrationGroup(group_id); } /// Get the active focus group ID pub fn getActiveFocusGroup(self: *Self) u64 { return self.focus.getActiveGroup(); } /// Check if a group is active pub fn isGroupActive(self: *Self, group_id: u64) bool { return self.focus.isGroupActive(group_id); } /// Focus next group (for F6-style navigation) pub fn focusNextGroup(self: *Self) void { self.focus.focusNextGroup(); } // ========================================================================= // Timing // ========================================================================= /// Set the current frame time (call once per frame, before beginFrame or after) /// This enables animations, cursor blinking, and other time-based effects. pub fn setFrameTime(self: *Self, time_ms: u64) void { if (self.current_time_ms > 0) { const delta = time_ms -| self.current_time_ms; self.frame_delta_ms = @intCast(@min(delta, std.math.maxInt(u32))); } self.current_time_ms = time_ms; } // ========================================================================= // Text Metrics // ========================================================================= /// Measure text width in pixels. /// Uses TTF font metrics if text_measure_fn is set, otherwise falls back to char_width * len. pub fn measureText(self: *const Self, text: []const u8) u32 { if (self.text_measure_fn) |measure_fn| { return measure_fn(text); } // Fallback: fixed-width calculation return @as(u32, @intCast(text.len)) * self.char_width; } /// Measure text width up to cursor position (for cursor placement). /// text: the full text /// cursor_pos: character position (byte index for ASCII, needs UTF-8 handling for unicode) pub fn measureTextToCursor(self: *const Self, text: []const u8, cursor_pos: usize) u32 { const end = @min(cursor_pos, text.len); return self.measureText(text[0..end]); } /// Set the text measurement function (typically from TTF font) pub fn setTextMeasureFn(self: *Self, measure_fn: ?*const fn ([]const u8) u32) void { self.text_measure_fn = measure_fn; } /// Set character width for fallback measurement (bitmap fonts) pub fn setCharWidth(self: *Self, width: u32) void { self.char_width = width; } /// Set character height for vertical centering (TTF fonts) pub fn setCharHeight(self: *Self, height: u32) void { self.char_height = height; } /// Get current time in milliseconds pub fn getTime(self: Self) u64 { return self.current_time_ms; } /// Get time delta since last frame in milliseconds pub fn getDeltaTime(self: Self) u32 { return self.frame_delta_ms; } /// Check if cursor animation is needed (for event loop timeout decisions). /// Returns true if we're within the active period where cursor should blink. /// The application should use a short timeout (e.g., 500ms) when this returns true, /// and can use infinite timeout when false. pub fn needsCursorAnimation(self: Self) bool { if (self.current_time_ms == 0) return false; const idle_time = self.current_time_ms -| self.last_input_time_ms; return idle_time < CURSOR_IDLE_TIMEOUT_MS; } /// Get recommended event loop timeout in milliseconds. /// Returns the time until next cursor blink toggle, or null for infinite wait. pub fn getAnimationTimeout(self: Self) ?u32 { if (!self.needsCursorAnimation()) return null; // Calculate time until next blink toggle const time_in_period = self.current_time_ms % CURSOR_BLINK_PERIOD_MS; const time_until_toggle = CURSOR_BLINK_PERIOD_MS - time_in_period; return @intCast(@max(time_until_toggle, 16)); // Minimum 16ms to avoid busy loop } /// Request another animation frame (for color transitions, etc.). /// Widgets call this during draw when they have ongoing animations. /// Main loop should check needsAnimationFrame() after draw and schedule redraw. pub fn requestAnimationFrame(self: *Self) void { self.needs_animation_frame = true; } /// Check if any widget requested an animation frame. /// Call after draw to determine if immediate redraw is needed. pub fn needsAnimationFrame(self: Self) bool { return self.needs_animation_frame; } /// Determina si el cursor debe ser visible basado en tiempo de actividad. /// Usa parpadeo mientras hay actividad reciente, sólido cuando está idle. /// @param last_activity_time_ms: Última vez que hubo actividad (edición, input) /// @return true si el cursor debe dibujarse visible pub fn isCursorVisible(self: Self, last_activity_time_ms: u64) bool { // Si no hay timing disponible, mostrar cursor siempre if (self.current_time_ms == 0) return true; // Calcular tiempo idle desde última actividad const idle_time = self.current_time_ms -| last_activity_time_ms; if (idle_time >= CURSOR_IDLE_TIMEOUT_MS) { // Idle: cursor siempre visible (sin parpadeo, ahorra batería) return true; } else { // Activo: cursor parpadea return (self.current_time_ms / CURSOR_BLINK_PERIOD_MS) % 2 == 0; } } // ========================================================================= // ID Management // ========================================================================= /// Get the frame allocator (use for per-frame allocations) pub fn frameAllocator(self: *Self) Allocator { return self.frame_arena.allocator(); } /// Get a unique ID for a widget pub fn getId(self: *Self, label: []const u8) u32 { var hash: u32 = 0; // Include parent IDs for (self.id_stack.items) |parent_id| { hash = hashCombine(hash, parent_id); } // Hash the label hash = hashCombine(hash, hashString(label)); return hash; } /// Push an ID onto the stack (for containers) pub fn pushId(self: *Self, id: u32) void { self.id_stack.append(self.allocator, id) catch {}; } /// Pop an ID from the stack pub fn popId(self: *Self) void { _ = self.id_stack.pop(); } /// Push a draw command pub fn pushCommand(self: *Self, cmd: Command.DrawCommand) void { self.commands.append(self.allocator, cmd) catch {}; } /// Push an overlay command (drawn AFTER all regular commands) /// Use this for dropdowns, tooltips, popups that need to appear on top pub fn pushOverlayCommand(self: *Self, cmd: Command.DrawCommand) void { self.overlay_commands.append(self.allocator, cmd) catch {}; } // ========================================================================= // High-level Drawing Helpers // ========================================================================= /// Draw a rectangle with 3D bevel effect /// Creates illusion of depth with light from top-left /// - Top/Left edges: lighter (raised) /// - Bottom/Right edges: darker (shadow) pub fn drawBeveledRect(self: *Self, x: i32, y: i32, w: u32, h: u32, base_color: Style.Color) void { const light = base_color.lightenHsl(10); const dark = base_color.darkenHsl(15); // Main fill self.pushCommand(.{ .rect = .{ .x = x, .y = y, .w = w, .h = h, .color = base_color, } }); // Top edge (light) self.pushCommand(.{ .rect = .{ .x = x, .y = y, .w = w, .h = 1, .color = light, } }); // Left edge (light) self.pushCommand(.{ .rect = .{ .x = x, .y = y, .w = 1, .h = h, .color = light, } }); // Bottom edge (dark) self.pushCommand(.{ .rect = .{ .x = x, .y = y + @as(i32, @intCast(h)) - 1, .w = w, .h = 1, .color = dark, } }); // Right edge (dark) self.pushCommand(.{ .rect = .{ .x = x + @as(i32, @intCast(w)) - 1, .y = y, .w = 1, .h = h, .color = dark, } }); } /// Draw a rectangle with inverted 3D bevel effect (pressed state) /// Dark edges on top/left, light on bottom/right pub fn drawBeveledRectPressed(self: *Self, x: i32, y: i32, w: u32, h: u32, base_color: Style.Color) void { const light = base_color.lightenHsl(10); const dark = base_color.darkenHsl(15); // Main fill self.pushCommand(.{ .rect = .{ .x = x, .y = y, .w = w, .h = h, .color = base_color, } }); // Top edge (dark - inverted) self.pushCommand(.{ .rect = .{ .x = x, .y = y, .w = w, .h = 1, .color = dark, } }); // Left edge (dark - inverted) self.pushCommand(.{ .rect = .{ .x = x, .y = y, .w = 1, .h = h, .color = dark, } }); // Bottom edge (light - inverted) self.pushCommand(.{ .rect = .{ .x = x, .y = y + @as(i32, @intCast(h)) - 1, .w = w, .h = 1, .color = light, } }); // Right edge (light - inverted) self.pushCommand(.{ .rect = .{ .x = x + @as(i32, @intCast(w)) - 1, .y = y, .w = 1, .h = h, .color = light, } }); } /// Draw a complete panel frame with focus-dependent styling. /// Encapsulates the common pattern: transition -> shadow -> bevel -> border. /// /// ## Clipping (Design Decision 2025-12-31) /// Automatic clipping is OMITTED for performance and full team control /// over widget coordinates. The team ensures widgets stay within bounds. /// /// MUST be implemented if the library becomes Open Source to guarantee /// visual safety for third-party users. /// /// ## Usage Modes /// /// ### Mode 1: Explicit (full control) /// ```zig /// ctx.drawPanelFrame(rect, &self.bg_transition, .{ /// .has_focus = panel_has_focus, /// .focus_bg = colors.fondo_con_focus, /// .unfocus_bg = colors.fondo_sin_focus, /// .border_color = border_color, /// }); /// ``` /// /// ### Mode 2: Z-Design (automatic derivation) /// ```zig /// ctx.drawPanelFrame(rect, &self.bg_transition, .{ /// .has_focus = panel_has_focus, /// .base_color = Color.laravel_blue, // Derives all colors /// .title = "[1] Clientes", // Optional title /// }); /// ``` pub const PanelFrameConfig = struct { /// Whether the panel currently has focus has_focus: bool = false, // === Mode 1: Explicit colors (backwards compatible) === /// Background color when focused (used if base_color is null) focus_bg: ?Style.Color = null, /// Background color when not focused (used if base_color is null) unfocus_bg: ?Style.Color = null, /// Border color (used if base_color is null) border_color: ?Style.Color = null, // === Mode 2: Z-Design automatic derivation === /// Base color for Z-Design derivation. If set, derives all colors automatically. /// Uses generic luminance formula: blend inversely proportional to perceived brightness. base_color: ?Style.Color = null, // === Title (optional, works in both modes) === /// Panel title (drawn at top-left if provided) title: ?[]const u8 = null, /// Title color (if null, uses border color or derived title_color) title_color: ?Style.Color = null, // === Behavior === /// Draw shadow when focused (default true) draw_shadow: bool = true, /// Draw bevel effect (default true) draw_bevel: bool = true, }; /// Draw a complete panel frame with focus transition and 3D effects. /// Returns true if the transition is still animating (need more frames). /// /// Supports two modes: /// - **Explicit**: Provide focus_bg, unfocus_bg, border_color directly /// - **Z-Design**: Provide base_color, all colors derived automatically pub fn drawPanelFrame( self: *Self, rect: Layout.Rect, transition: *ColorTransition, config: PanelFrameConfig, ) bool { // Determine colors: Z-Design derivation or explicit const focus_bg: Style.Color = blk: { if (config.base_color) |base| { const derived = Style.derivePanelFrameColors(base); break :blk derived.focus_bg; } break :blk config.focus_bg orelse Style.Color.rgb(40, 40, 50); }; const unfocus_bg: Style.Color = blk: { if (config.base_color) |base| { const derived = Style.derivePanelFrameColors(base); break :blk derived.unfocus_bg; } break :blk config.unfocus_bg orelse Style.Color.rgb(30, 30, 40); }; const border_color: ?Style.Color = blk: { if (config.base_color) |base| { const derived = Style.derivePanelFrameColors(base); break :blk if (config.has_focus) derived.border_focus else derived.border_unfocus; } break :blk config.border_color; }; // Título adaptativo: siempre alta legibilidad (blanco teñido sobre fondo oscuro) const title_color: ?Style.Color = blk: { if (config.title_color) |tc| break :blk tc; if (config.base_color) |base| { const derived = Style.derivePanelFrameColors(base); break :blk derived.title_color; // Siempre legible, focus o no } break :blk border_color; }; // 1. Calculate target color and update transition const target_bg = if (config.has_focus) focus_bg else unfocus_bg; const animating = transition.update(target_bg, self.frame_delta_ms); // Request animation frame if still transitioning if (animating) { self.requestAnimationFrame(); } // 2. Draw shadow when focused if (config.draw_shadow and config.has_focus) { self.pushCommand(Command.shadowDrop(rect.x, rect.y, rect.w, rect.h, 0)); } // 3. Draw background (beveled or flat) if (config.draw_bevel) { self.drawBeveledRect(rect.x, rect.y, rect.w, rect.h, transition.current); } else { self.pushCommand(.{ .rect = .{ .x = rect.x, .y = rect.y, .w = rect.w, .h = rect.h, .color = transition.current, } }); } // 4. Draw border if specified if (border_color) |border| { self.pushCommand(Command.rectOutline(rect.x, rect.y, rect.w, rect.h, border)); } // 5. Draw title if specified (margen 10,5 para aire visual) if (config.title) |title| { if (title_color) |tc| { self.pushCommand(.{ .text = .{ .x = rect.x + 10, .y = rect.y + 5, .text = title, .color = tc, } }); } } return animating; } /// Resize the context pub fn resize(self: *Self, width: u32, height: u32) void { self.width = width; self.height = height; self.invalidateAll(); } // ========================================================================= // Dirty Rectangle Management // ========================================================================= /// Mark a rectangle as dirty (needs redraw) pub fn invalidateRect(self: *Self, rect: Layout.Rect) void { if (self.full_redraw) return; // Try to merge with existing dirty rect for (self.dirty_rects.items) |*existing| { if (rectsOverlap(existing.*, rect)) { existing.* = mergeRects(existing.*, rect); return; } } // Add new dirty rect self.dirty_rects.append(self.allocator, rect) catch { // If we can't track, just do full redraw self.full_redraw = true; }; // If too many dirty rects, switch to full redraw if (self.dirty_rects.items.len > 32) { self.full_redraw = true; self.dirty_rects.clearRetainingCapacity(); } } /// Mark entire screen as dirty pub fn invalidateAll(self: *Self) void { self.full_redraw = true; self.dirty_rects.clearRetainingCapacity(); } /// Check if a rectangle needs redraw pub fn needsRedraw(self: *Self, rect: Layout.Rect) bool { if (self.full_redraw) return true; for (self.dirty_rects.items) |dirty| { if (rectsOverlap(dirty, rect)) return true; } return false; } /// Get dirty rectangles for rendering pub fn getDirtyRects(self: *Self) []const Layout.Rect { if (self.full_redraw) { // Return single rect covering entire screen const full = Layout.Rect{ .x = 0, .y = 0, .w = self.width, .h = self.height, }; // Use frame arena for temporary allocation const result = self.frame_arena.alloc_slice(Layout.Rect, 1) orelse return &.{}; result[0] = full; return result; } return self.dirty_rects.items; } // ========================================================================= // Statistics // ========================================================================= /// Get current frame statistics pub fn getStats(self: Self) FrameStats { return self.stats; } /// Increment widget count (called by widgets) pub fn countWidget(self: *Self) void { self.stats.widget_count += 1; } // ========================================================================= // Helper functions // ========================================================================= fn hashString(s: []const u8) u32 { var h: u32 = 0; for (s) |c| { h = h *% 31 +% c; } return h; } fn hashCombine(a: u32, b: u32) u32 { return a ^ (b +% 0x9e3779b9 +% (a << 6) +% (a >> 2)); } fn rectsOverlap(a: Layout.Rect, b: Layout.Rect) bool { const a_right = a.x + @as(i32, @intCast(a.w)); const a_bottom = a.y + @as(i32, @intCast(a.h)); const b_right = b.x + @as(i32, @intCast(b.w)); const b_bottom = b.y + @as(i32, @intCast(b.h)); return a.x < b_right and a_right > b.x and a.y < b_bottom and a_bottom > b.y; } fn mergeRects(a: Layout.Rect, b: Layout.Rect) Layout.Rect { const min_x = @min(a.x, b.x); const min_y = @min(a.y, b.y); const a_right = a.x + @as(i32, @intCast(a.w)); const a_bottom = a.y + @as(i32, @intCast(a.h)); const b_right = b.x + @as(i32, @intCast(b.w)); const b_bottom = b.y + @as(i32, @intCast(b.h)); const max_x = @max(a_right, b_right); const max_y = @max(a_bottom, b_bottom); return .{ .x = min_x, .y = min_y, .w = @intCast(max_x - min_x), .h = @intCast(max_y - min_y), }; } }; // ============================================================================= // Tests // ============================================================================= test "Context basic" { var ctx = try Context.init(std.testing.allocator, 800, 600); defer ctx.deinit(); ctx.beginFrame(); const id1 = ctx.getId("button1"); const id2 = ctx.getId("button2"); try std.testing.expect(id1 != id2); ctx.endFrame(); } test "Context ID with parent" { var ctx = try Context.init(std.testing.allocator, 800, 600); defer ctx.deinit(); ctx.beginFrame(); const id_no_parent = ctx.getId("button"); ctx.pushId(ctx.getId("panel1")); const id_with_parent = ctx.getId("button"); ctx.popId(); try std.testing.expect(id_no_parent != id_with_parent); ctx.endFrame(); } test "Context frame arena" { var ctx = try Context.init(std.testing.allocator, 800, 600); defer ctx.deinit(); ctx.beginFrame(); // Allocate from frame arena const alloc = ctx.frameAllocator(); const slice = try alloc.alloc(u8, 1000); try std.testing.expectEqual(@as(usize, 1000), slice.len); // Verify arena is being used try std.testing.expect(ctx.frame_arena.bytesUsed() >= 1000); ctx.endFrame(); // Start new frame - arena should be reset ctx.beginFrame(); try std.testing.expectEqual(@as(usize, 0), ctx.frame_arena.bytesUsed()); ctx.endFrame(); } test "Context dirty rectangles" { var ctx = try Context.init(std.testing.allocator, 800, 600); defer ctx.deinit(); ctx.beginFrame(); ctx.full_redraw = false; // Mark a rect as dirty ctx.invalidateRect(.{ .x = 10, .y = 10, .w = 50, .h = 50 }); try std.testing.expectEqual(@as(usize, 1), ctx.dirty_rects.items.len); // Check if overlapping rect needs redraw try std.testing.expect(ctx.needsRedraw(.{ .x = 20, .y = 20, .w = 30, .h = 30 })); // Check if non-overlapping rect doesn't need redraw try std.testing.expect(!ctx.needsRedraw(.{ .x = 200, .y = 200, .w = 30, .h = 30 })); ctx.endFrame(); } test "Context dirty rect merging" { var ctx = try Context.init(std.testing.allocator, 800, 600); defer ctx.deinit(); ctx.beginFrame(); ctx.full_redraw = false; // Add overlapping rects - should merge ctx.invalidateRect(.{ .x = 10, .y = 10, .w = 50, .h = 50 }); ctx.invalidateRect(.{ .x = 40, .y = 40, .w = 50, .h = 50 }); // Should be merged into one try std.testing.expectEqual(@as(usize, 1), ctx.dirty_rects.items.len); ctx.endFrame(); } test "Context stats" { var ctx = try Context.init(std.testing.allocator, 800, 600); defer ctx.deinit(); ctx.beginFrame(); // Push some commands ctx.pushCommand(.{ .rect = .{ .x = 0, .y = 0, .w = 100, .h = 100, .color = .{ .r = 255, .g = 0, .b = 0, .a = 255 } } }); ctx.pushCommand(.{ .rect = .{ .x = 10, .y = 10, .w = 80, .h = 80, .color = .{ .r = 0, .g = 255, .b = 0, .a = 255 } } }); ctx.countWidget(); ctx.countWidget(); ctx.countWidget(); ctx.endFrame(); const stats = ctx.getStats(); try std.testing.expectEqual(@as(usize, 2), stats.command_count); try std.testing.expectEqual(@as(usize, 3), stats.widget_count); } test "Context focus integration" { var ctx = try Context.init(std.testing.allocator, 800, 600); defer ctx.deinit(); ctx.beginFrame(); // Register focusable widgets ctx.registerFocusable(100); ctx.registerFocusable(200); ctx.registerFocusable(300); // First widget has implicit focus immediately try std.testing.expect(ctx.hasFocus(100)); // Request focus changes it ctx.requestFocus(200); try std.testing.expect(ctx.hasFocus(200)); try std.testing.expect(!ctx.hasFocus(100)); ctx.endFrame(); } test "Context focus groups" { var ctx = try Context.init(std.testing.allocator, 800, 600); defer ctx.deinit(); // Create groups _ = ctx.createFocusGroup(1); _ = ctx.createFocusGroup(2); // Set group 1 as active (has keyboard focus) ctx.setActiveFocusGroup(1); ctx.beginFrame(); // Register widgets in group 1 (use setRegistrationGroup, NOT setActiveFocusGroup) ctx.setRegistrationGroup(1); ctx.registerFocusable(100); ctx.registerFocusable(101); // Register widgets in group 2 ctx.setRegistrationGroup(2); ctx.registerFocusable(200); ctx.registerFocusable(201); // Group 1 is still active (keyboard focus unchanged by registration) try std.testing.expectEqual(@as(u64, 1), ctx.getActiveFocusGroup()); // Request focus on widget in group 2 - this DOES change active group ctx.requestFocus(200); try std.testing.expect(ctx.hasFocus(200)); try std.testing.expectEqual(@as(u64, 2), ctx.getActiveFocusGroup()); ctx.endFrame(); } test "Context timing" { var ctx = try Context.init(std.testing.allocator, 800, 600); defer ctx.deinit(); // Initially zero try std.testing.expectEqual(@as(u64, 0), ctx.getTime()); try std.testing.expectEqual(@as(u32, 0), ctx.getDeltaTime()); // Set first frame time ctx.setFrameTime(1000); try std.testing.expectEqual(@as(u64, 1000), ctx.getTime()); try std.testing.expectEqual(@as(u32, 0), ctx.getDeltaTime()); // No delta on first frame // Set second frame time ctx.setFrameTime(1016); // ~60 FPS = 16ms per frame try std.testing.expectEqual(@as(u64, 1016), ctx.getTime()); try std.testing.expectEqual(@as(u32, 16), ctx.getDeltaTime()); // Set third frame time with larger gap ctx.setFrameTime(1116); // 100ms later try std.testing.expectEqual(@as(u64, 1116), ctx.getTime()); try std.testing.expectEqual(@as(u32, 100), ctx.getDeltaTime()); }