🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1066 lines
36 KiB
Zig
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());
|
|
}
|