zcatgui/src/core/context.zig
R.Eugenio 092671adda chore: Eliminar debug print en drawPanelFrame
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-31 20:51:16 +01:00

1066 lines
36 KiB
Zig

//! 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)
/// Note: Bevel is drawn INSIDE the rect (inset by 1px) to not overlap border
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,
} });
// Bevel inset by 1px to stay inside border
const inner_w = if (w > 2) w - 2 else 1;
const inner_h = if (h > 2) h - 2 else 1;
// Top edge (light) - inset
self.pushCommand(.{ .rect = .{
.x = x + 1,
.y = y + 1,
.w = inner_w,
.h = 1,
.color = light,
} });
// Left edge (light) - inset
self.pushCommand(.{ .rect = .{
.x = x + 1,
.y = y + 1,
.w = 1,
.h = inner_h,
.color = light,
} });
// Bottom edge (dark) - inset
self.pushCommand(.{ .rect = .{
.x = x + 1,
.y = y + @as(i32, @intCast(h)) - 2,
.w = inner_w,
.h = 1,
.color = dark,
} });
// Right edge (dark) - inset
self.pushCommand(.{ .rect = .{
.x = x + @as(i32, @intCast(w)) - 2,
.y = y + 1,
.w = 1,
.h = inner_h,
.color = dark,
} });
}
/// Draw a rectangle with inverted 3D bevel effect (pressed state)
/// Dark edges on top/left, light on bottom/right
/// Note: Bevel is drawn INSIDE the rect (inset by 1px) to not overlap border
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,
} });
// Bevel inset by 1px to stay inside border
const inner_w = if (w > 2) w - 2 else 1;
const inner_h = if (h > 2) h - 2 else 1;
// Top edge (dark - inverted) - inset
self.pushCommand(.{ .rect = .{
.x = x + 1,
.y = y + 1,
.w = inner_w,
.h = 1,
.color = dark,
} });
// Left edge (dark - inverted) - inset
self.pushCommand(.{ .rect = .{
.x = x + 1,
.y = y + 1,
.w = 1,
.h = inner_h,
.color = dark,
} });
// Bottom edge (light - inverted) - inset
self.pushCommand(.{ .rect = .{
.x = x + 1,
.y = y + @as(i32, @intCast(h)) - 2,
.w = inner_w,
.h = 1,
.color = light,
} });
// Right edge (light - inverted) - inset
self.pushCommand(.{ .rect = .{
.x = x + @as(i32, @intCast(w)) - 2,
.y = y + 1,
.w = 1,
.h = inner_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);
const bc = if (config.has_focus) derived.border_focus else derived.border_unfocus;
break :blk bc;
}
break :blk config.border_color;
};
// Título adaptativo: siempre alta legibilidad
// - Si hay title_color explícito: usarlo
// - Si hay base_color: derivar con tinte sutil
// - Si no hay ninguno: contrastTextColor sobre focus_bg (blanco/negro según fondo)
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
}
// FIX: usar contraste sobre fondo, NO border_color (que es oscuro)
break :blk Style.contrastTextColor(focus_bg);
};
// 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 28,5 para dejar espacio al semáforo de estado)
// El semáforo de DetailPanelBase se dibuja en (x+8, y+4) con 12x12px
if (config.title) |title| {
if (title_color) |tc| {
self.pushCommand(.{ .text = .{
.x = rect.x + 28,
.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());
}