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