Implements "Active Request" pattern for cursor animation: - Add requested_cursor_blink flag to Context (reset each frame) - Add requestCursorBlink() function for widgets to call - needsCursorAnimation() now returns false if no widget requested it - TextInput calls requestCursorBlink() when focused and editable - CellEditor calls requestCursorBlink() when editing This eliminates unnecessary redraws when no cursor is visible (e.g., in Config tab with tables but no active text input). Also raised CURSOR_BLINK_PERIOD_MS from 300ms to 600ms (GTK/Linux standard). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1332 lines
47 KiB
Zig
1332 lines
47 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,
|
|
|
|
// =========================================================================
|
|
// Dirty Panel System (granularity per named panel)
|
|
// =========================================================================
|
|
|
|
/// Registered panel areas by name (e.g., "who_list", "doc_detail")
|
|
panel_areas: std.StringHashMapUnmanaged(Layout.Rect),
|
|
|
|
/// Dirty flags per panel (true = needs redraw)
|
|
dirty_panels: std.StringHashMapUnmanaged(bool),
|
|
|
|
/// "Ghost Drawing" mode: when true, pushCommand does nothing.
|
|
/// Used to process widget input (via draw) without generating render commands.
|
|
/// Set by application for non-dirty panels to save CPU while keeping input working.
|
|
suppress_commands: bool = false,
|
|
|
|
/// Burst detection: timestamp of last navigation event (selection change)
|
|
/// Used by drawPanelFrame to auto-suppress burst_sensitive panels during rapid navigation
|
|
last_navigation_time: i64 = 0,
|
|
|
|
/// 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,
|
|
|
|
/// Flag set by widgets that have an active cursor (TextInput, CellEditor).
|
|
/// Reset each frame in beginFrame(). Only when true does needsCursorAnimation() return true.
|
|
/// This prevents unnecessary redraws when no text field is being edited.
|
|
requested_cursor_blink: 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.
|
|
/// 600ms = ~1.7 blinks/sec (GTK/Linux standard, reduces unnecessary redraws)
|
|
pub const CURSOR_BLINK_PERIOD_MS: u64 = 600;
|
|
|
|
const Self = @This();
|
|
|
|
/// Frame statistics for performance monitoring
|
|
pub const FrameStats = struct {
|
|
/// Number of commands generated this frame
|
|
command_count: usize = 0,
|
|
/// Number of commands actually executed (set by renderer)
|
|
executed_cmds: 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,
|
|
.panel_areas = .{},
|
|
.dirty_panels = .{},
|
|
.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,
|
|
.panel_areas = .{},
|
|
.dirty_panels = .{},
|
|
.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.panel_areas.deinit(self.allocator);
|
|
self.dirty_panels.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.executed_cmds = 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;
|
|
|
|
// Reset cursor blink request (set by TextInput/CellEditor during draw)
|
|
self.requested_cursor_blink = 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;
|
|
|
|
// Clear dirty panel flags AFTER rendering
|
|
// (renderer uses getDirtyPanelRects during frame)
|
|
self.clearDirtyPanels();
|
|
}
|
|
|
|
// =========================================================================
|
|
// 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.
|
|
/// Only returns true if a widget (TextInput/CellEditor) requested cursor blink this frame.
|
|
/// The application should use a short timeout (e.g., 600ms) 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;
|
|
// Smart cursor: only blink if a widget actually requested it this frame
|
|
if (!self.requested_cursor_blink) return false;
|
|
const idle_time = self.current_time_ms -| self.last_input_time_ms;
|
|
return idle_time < CURSOR_IDLE_TIMEOUT_MS;
|
|
}
|
|
|
|
/// Request cursor blink animation for this frame.
|
|
/// Called by widgets with active text cursors (TextInput, CellEditor).
|
|
/// Must be called each frame while cursor is active (immediate-mode pattern).
|
|
pub fn requestCursorBlink(self: *Self) void {
|
|
self.requested_cursor_blink = true;
|
|
}
|
|
|
|
/// 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
|
|
/// If suppress_commands is true (Ghost Drawing mode), does nothing.
|
|
pub fn pushCommand(self: *Self, cmd: Command.DrawCommand) void {
|
|
if (self.suppress_commands) return;
|
|
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
|
|
/// If suppress_commands is true (Ghost Drawing mode), does nothing.
|
|
pub fn pushOverlayCommand(self: *Self, cmd: Command.DrawCommand) void {
|
|
if (self.suppress_commands) return;
|
|
self.overlay_commands.append(self.allocator, cmd) catch {};
|
|
}
|
|
|
|
// =========================================================================
|
|
// High-level Drawing Helpers - Bevel System
|
|
// =========================================================================
|
|
|
|
/// Configuración de estilo de bisel 3D
|
|
/// Permite control fino sobre el efecto visual
|
|
pub const BevelStyle = struct {
|
|
/// Tipo de efecto 3D
|
|
effect: Effect = .raised,
|
|
|
|
/// Offset del bisel en pixels (0 = borde exterior, 1 = interior)
|
|
inset: u8 = 1,
|
|
|
|
/// Intensidad de la luz (0-100, típico 10-20)
|
|
light_top: u8 = 10,
|
|
light_left: u8 = 10,
|
|
|
|
/// Intensidad de la sombra (0-100, típico 10-20)
|
|
dark_bottom: u8 = 15,
|
|
dark_right: u8 = 15,
|
|
|
|
/// Usar HSL (true) o RGB (false) para calcular colores
|
|
use_hsl: bool = true,
|
|
|
|
pub const Effect = enum {
|
|
raised, // Elevado: luz arriba/izq, sombra abajo/der
|
|
sunken, // Hundido: sombra arriba/izq, luz abajo/der
|
|
};
|
|
|
|
// === PRESETS COMUNES ===
|
|
|
|
/// Bisel elevado interior (default, para botones)
|
|
pub const raised_inset = BevelStyle{};
|
|
|
|
/// Bisel hundido interior (para botones presionados)
|
|
pub const sunken_inset = BevelStyle{ .effect = .sunken };
|
|
|
|
/// Bisel elevado exterior (estilo Windows 95/StatusLine)
|
|
pub const raised_outer = BevelStyle{
|
|
.inset = 0,
|
|
.use_hsl = false,
|
|
.light_top = 15,
|
|
.light_left = 12,
|
|
.dark_bottom = 15,
|
|
.dark_right = 12,
|
|
};
|
|
|
|
/// Bisel hundido exterior (estilo Windows 95/StatusLine)
|
|
pub const sunken_outer = BevelStyle{
|
|
.effect = .sunken,
|
|
.inset = 0,
|
|
.use_hsl = false,
|
|
.light_top = 10,
|
|
.light_left = 8,
|
|
.dark_bottom = 20,
|
|
.dark_right = 15,
|
|
};
|
|
};
|
|
|
|
/// Dibuja un rectángulo con efecto bisel 3D configurable
|
|
///
|
|
/// Ejemplo de uso:
|
|
/// ```zig
|
|
/// // Bisel elevado (default)
|
|
/// ctx.drawBevel(x, y, w, h, color, .{});
|
|
///
|
|
/// // Bisel hundido exterior (estilo StatusLine)
|
|
/// ctx.drawBevel(x, y, w, h, color, BevelStyle.sunken_outer);
|
|
///
|
|
/// // Personalizado
|
|
/// ctx.drawBevel(x, y, w, h, color, .{ .effect = .raised, .inset = 0, .light_top = 20 });
|
|
/// ```
|
|
pub fn drawBevel(self: *Self, x: i32, y: i32, w: u32, h: u32, base_color: Style.Color, style: BevelStyle) void {
|
|
// Calcular colores de luz y sombra
|
|
const light_top = if (style.use_hsl)
|
|
base_color.lightenHsl(@floatFromInt(style.light_top))
|
|
else
|
|
base_color.lighten(style.light_top);
|
|
|
|
const light_left = if (style.use_hsl)
|
|
base_color.lightenHsl(@floatFromInt(style.light_left))
|
|
else
|
|
base_color.lighten(style.light_left);
|
|
|
|
const dark_bottom = if (style.use_hsl)
|
|
base_color.darkenHsl(@floatFromInt(style.dark_bottom))
|
|
else
|
|
base_color.darken(style.dark_bottom);
|
|
|
|
const dark_right = if (style.use_hsl)
|
|
base_color.darkenHsl(@floatFromInt(style.dark_right))
|
|
else
|
|
base_color.darken(style.dark_right);
|
|
|
|
// Determinar colores según efecto (raised vs sunken)
|
|
const top_color = if (style.effect == .raised) light_top else dark_bottom;
|
|
const left_color = if (style.effect == .raised) light_left else dark_right;
|
|
const bottom_color = if (style.effect == .raised) dark_bottom else light_top;
|
|
const right_color = if (style.effect == .raised) dark_right else light_left;
|
|
|
|
// Main fill
|
|
self.pushCommand(.{ .rect = .{
|
|
.x = x,
|
|
.y = y,
|
|
.w = w,
|
|
.h = h,
|
|
.color = base_color,
|
|
} });
|
|
|
|
// Calcular dimensiones según inset
|
|
const offset: i32 = @intCast(style.inset);
|
|
const size_reduction: u32 = @as(u32, style.inset) * 2;
|
|
const inner_w = if (w > size_reduction) w - size_reduction else 1;
|
|
const inner_h = if (h > size_reduction) h - size_reduction else 1;
|
|
|
|
// Top edge
|
|
self.pushCommand(.{ .rect = .{
|
|
.x = x + offset,
|
|
.y = y + offset,
|
|
.w = inner_w,
|
|
.h = 1,
|
|
.color = top_color,
|
|
} });
|
|
|
|
// Left edge
|
|
self.pushCommand(.{ .rect = .{
|
|
.x = x + offset,
|
|
.y = y + offset + 1,
|
|
.w = 1,
|
|
.h = if (inner_h > 2) inner_h - 2 else 1,
|
|
.color = left_color,
|
|
} });
|
|
|
|
// Bottom edge
|
|
self.pushCommand(.{ .rect = .{
|
|
.x = x + offset,
|
|
.y = y + @as(i32, @intCast(h)) - 1 - offset,
|
|
.w = inner_w,
|
|
.h = 1,
|
|
.color = bottom_color,
|
|
} });
|
|
|
|
// Right edge
|
|
self.pushCommand(.{ .rect = .{
|
|
.x = x + @as(i32, @intCast(w)) - 1 - offset,
|
|
.y = y + offset + 1,
|
|
.w = 1,
|
|
.h = if (inner_h > 2) inner_h - 2 else 1,
|
|
.color = right_color,
|
|
} });
|
|
}
|
|
|
|
/// Wrapper simple: bisel elevado interior (compatibilidad)
|
|
pub fn drawBeveledRect(self: *Self, x: i32, y: i32, w: u32, h: u32, base_color: Style.Color) void {
|
|
self.drawBevel(x, y, w, h, base_color, BevelStyle.raised_inset);
|
|
}
|
|
|
|
/// Wrapper simple: bisel hundido interior (compatibilidad)
|
|
pub fn drawBeveledRectPressed(self: *Self, x: i32, y: i32, w: u32, h: u32, base_color: Style.Color) void {
|
|
self.drawBevel(x, y, w, h, base_color, BevelStyle.sunken_inset);
|
|
}
|
|
|
|
/// 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,
|
|
|
|
// === Performance ===
|
|
/// Si true, el panel se auto-suprime durante ráfagas de navegación.
|
|
/// Paneles principales (listas, fichas) deben ser false.
|
|
/// Paneles secundarios (documentos) pueden ser true para mejor rendimiento.
|
|
burst_sensitive: bool = true,
|
|
};
|
|
|
|
/// Result of drawPanelFrame for LEGO-style control flow
|
|
pub const PanelFrameResult = struct {
|
|
/// True if color transition is still animating
|
|
animating: bool,
|
|
/// False if panel was suppressed - caller should return immediately
|
|
should_draw: bool,
|
|
/// The actual background color being drawn (for table mimetism)
|
|
/// Tables should use this as row_normal to blend seamlessly with panel
|
|
derived_bg: Style.Color,
|
|
};
|
|
|
|
/// Draw a complete panel frame with focus transition and 3D effects.
|
|
/// Returns PanelFrameResult: check should_draw to know if panel content should be rendered.
|
|
///
|
|
/// 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,
|
|
) PanelFrameResult {
|
|
// Auto-supresión LEGO: detectar si el panel debe suprimir operaciones costosas
|
|
// El frame SIEMPRE se dibuja, pero should_draw indica si continuar con widgets/BD
|
|
const burst_suppressed = config.burst_sensitive and self.isSelectionBurstActive();
|
|
|
|
// 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,
|
|
} });
|
|
}
|
|
}
|
|
|
|
// should_draw = false indica que el panel debe saltar operaciones costosas (BD, widgets)
|
|
// derived_bg = color actual del fondo (para mimetismo de tablas)
|
|
return .{
|
|
.animating = animating,
|
|
.should_draw = !burst_suppressed,
|
|
.derived_bg = transition.current,
|
|
};
|
|
}
|
|
|
|
/// Resize the context
|
|
pub fn resize(self: *Self, width: u32, height: u32) void {
|
|
self.width = width;
|
|
self.height = height;
|
|
self.invalidateAll();
|
|
self.markAllPanelsDirty(); // Resize requires all panels to redraw
|
|
}
|
|
|
|
// =========================================================================
|
|
// Dirty Panel System (granularity per named panel)
|
|
//
|
|
// The application registers panel areas at startup:
|
|
// ctx.registerPanelArea("who_list", rect);
|
|
//
|
|
// When data changes, the application marks panels dirty:
|
|
// ctx.invalidatePanel("who_list");
|
|
//
|
|
// The renderer checks which panels are dirty:
|
|
// const dirty_rects = ctx.getDirtyPanelRects();
|
|
// renderer.clearDirtyRegions(dirty_rects);
|
|
//
|
|
// At endFrame(), dirty flags are cleared automatically.
|
|
// =========================================================================
|
|
|
|
/// Register a named panel area. Call at startup or when layout changes.
|
|
/// The ID should be descriptive (e.g., "who_list", "doc_detail").
|
|
pub fn registerPanelArea(self: *Self, id: []const u8, rect: Layout.Rect) void {
|
|
self.panel_areas.put(self.allocator, id, rect) catch {
|
|
// If we can't register, fall back to full redraw
|
|
self.full_redraw = true;
|
|
};
|
|
// New panels start dirty (need initial draw)
|
|
self.dirty_panels.put(self.allocator, id, true) catch {};
|
|
}
|
|
|
|
/// Update a panel's rect (e.g., after window resize).
|
|
/// Does NOT mark the panel dirty - call invalidatePanel separately if needed.
|
|
pub fn updatePanelArea(self: *Self, id: []const u8, rect: Layout.Rect) void {
|
|
if (self.panel_areas.getPtr(id)) |ptr| {
|
|
ptr.* = rect;
|
|
}
|
|
}
|
|
|
|
/// Mark a panel as needing redraw.
|
|
/// Call this when data changes that affects the panel's content.
|
|
pub fn invalidatePanel(self: *Self, id: []const u8) void {
|
|
if (self.dirty_panels.getPtr(id)) |ptr| {
|
|
ptr.* = true;
|
|
} else {
|
|
// Panel not registered - this is probably a bug
|
|
// For safety, do full redraw
|
|
self.full_redraw = true;
|
|
}
|
|
}
|
|
|
|
/// Check if a panel is marked dirty.
|
|
pub fn isPanelDirty(self: *Self, id: []const u8) bool {
|
|
if (self.full_redraw) return true;
|
|
return self.dirty_panels.get(id) orelse false;
|
|
}
|
|
|
|
/// Mark all registered panels as dirty.
|
|
/// Use for resize, tab change, or other global invalidation.
|
|
pub fn markAllPanelsDirty(self: *Self) void {
|
|
var iter = self.dirty_panels.iterator();
|
|
while (iter.next()) |entry| {
|
|
entry.value_ptr.* = true;
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// BURST DETECTION: Para auto-supresión de paneles durante navegación rápida
|
|
// =========================================================================
|
|
|
|
/// Marca que ocurrió un evento de navegación (cambio de selección).
|
|
/// Llamar desde DataManager cuando notifica cambios de selección.
|
|
pub fn markNavigationEvent(self: *Self) void {
|
|
self.last_navigation_time = std.time.milliTimestamp();
|
|
}
|
|
|
|
/// Devuelve true si estamos en medio de una ráfaga de navegación.
|
|
/// Se considera ráfaga si pasaron menos de 100ms desde el último evento.
|
|
pub fn isSelectionBurstActive(self: *Self) bool {
|
|
const now = std.time.milliTimestamp();
|
|
return (now - self.last_navigation_time) < 100;
|
|
}
|
|
|
|
/// Get rectangles of all dirty panels (for renderer).
|
|
/// Returns slice allocated from frame arena (valid until next beginFrame).
|
|
pub fn getDirtyPanelRects(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,
|
|
};
|
|
const result = self.frame_arena.alloc_slice(Layout.Rect, 1) orelse return &.{};
|
|
result[0] = full;
|
|
return result;
|
|
}
|
|
|
|
// Count dirty panels
|
|
var dirty_count: usize = 0;
|
|
var iter = self.dirty_panels.iterator();
|
|
while (iter.next()) |entry| {
|
|
if (entry.value_ptr.*) dirty_count += 1;
|
|
}
|
|
|
|
if (dirty_count == 0) return &.{};
|
|
|
|
// Allocate result array from frame arena
|
|
const result = self.frame_arena.alloc_slice(Layout.Rect, dirty_count) orelse return &.{};
|
|
|
|
// Fill with dirty panel rects
|
|
var i: usize = 0;
|
|
var iter2 = self.dirty_panels.iterator();
|
|
while (iter2.next()) |entry| {
|
|
if (entry.value_ptr.*) {
|
|
if (self.panel_areas.get(entry.key_ptr.*)) |rect| {
|
|
result[i] = rect;
|
|
i += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
return result[0..i];
|
|
}
|
|
|
|
/// Check if any panel is dirty (useful for skip-redraw optimization).
|
|
pub fn hasAnyDirtyPanel(self: *Self) bool {
|
|
if (self.full_redraw) return true;
|
|
|
|
var iter = self.dirty_panels.iterator();
|
|
while (iter.next()) |entry| {
|
|
if (entry.value_ptr.*) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Clear all dirty panel flags.
|
|
/// Called automatically at endFrame(), but can be called manually if needed.
|
|
pub fn clearDirtyPanels(self: *Self) void {
|
|
var iter = self.dirty_panels.iterator();
|
|
while (iter.next()) |entry| {
|
|
entry.value_ptr.* = false;
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// 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());
|
|
}
|