feat(context): Smart Panel V2 - base_color derivación genérica + título

- PanelFrameConfig: añadir base_color, title, title_color
- drawPanelFrame: modo híbrido (explícito vs Z-Design)
- derivePanelFrameColors: fórmula genérica luminosidad (L inversamente proporcional)
- Documentar decisión de omitir clipping (performance + control)

Consensuado: Claude + Gemini + R.Eugenio (2025-12-31)
This commit is contained in:
R.Eugenio 2025-12-31 00:19:18 +01:00
parent 5ba0cc9f25
commit 797cca736c
2 changed files with 190 additions and 13 deletions

View file

@ -563,28 +563,57 @@ pub const Context = struct {
/// Draw a complete panel frame with focus-dependent styling.
/// Encapsulates the common pattern: transition -> shadow -> bevel -> border.
///
/// Usage:
/// ```zig
/// // In panel state:
/// bg_transition: zcatgui.Context.ColorTransition = .{},
/// ## 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.
///
/// // In draw:
/// 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 = if (panel_has_focus) colors.borde_con_focus else colors.borde_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,
/// Background color when focused
focus_bg: Style.Color = Style.Color.rgb(40, 40, 50),
/// Background color when not focused
unfocus_bg: Style.Color = Style.Color.rgb(30, 30, 40),
/// Border color (typically borde_con_focus or borde_sin_focus)
// === 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)
@ -593,14 +622,52 @@ pub const Context = struct {
/// Draw a complete panel frame with focus transition and 3D effects.
/// Returns true if the transition is still animating (need more frames).
///
/// Supports two modes:
/// - **Explicit**: Provide focus_bg, unfocus_bg, border_color directly
/// - **Z-Design**: Provide base_color, all colors derived automatically
pub fn drawPanelFrame(
self: *Self,
rect: Layout.Rect,
transition: *ColorTransition,
config: PanelFrameConfig,
) bool {
// Determine colors: Z-Design derivation or explicit
const focus_bg: Style.Color = blk: {
if (config.base_color) |base| {
const derived = Style.derivePanelFrameColors(base);
break :blk derived.focus_bg;
}
break :blk config.focus_bg orelse Style.Color.rgb(40, 40, 50);
};
const unfocus_bg: Style.Color = blk: {
if (config.base_color) |base| {
const derived = Style.derivePanelFrameColors(base);
break :blk derived.unfocus_bg;
}
break :blk config.unfocus_bg orelse Style.Color.rgb(30, 30, 40);
};
const border_color: ?Style.Color = blk: {
if (config.base_color) |base| {
const derived = Style.derivePanelFrameColors(base);
break :blk if (config.has_focus) derived.border_focus else derived.border_unfocus;
}
break :blk config.border_color;
};
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 if (config.has_focus) derived.title_color else derived.border_unfocus;
}
break :blk border_color;
};
// 1. Calculate target color and update transition
const target_bg = if (config.has_focus) config.focus_bg else config.unfocus_bg;
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
@ -627,10 +694,22 @@ pub const Context = struct {
}
// 4. Draw border if specified
if (config.border_color) |border| {
if (border_color) |border| {
self.pushCommand(Command.rectOutline(rect.x, rect.y, rect.w, rect.h, border));
}
// 5. Draw title if specified
if (config.title) |title| {
if (title_color) |tc| {
self.pushCommand(.{ .text = .{
.x = rect.x + 8,
.y = rect.y + 4,
.text = title,
.color = tc,
} });
}
}
return animating;
}

View file

@ -1342,3 +1342,101 @@ test "contrastTextColor" {
const text_on_mid = contrastTextColor(mid_gray);
try std.testing.expect(text_on_mid.r < 50); // Dark text (L = 0.502 > 0.5)
}
// =============================================================================
// SMART PANEL V2: Derivación Genérica para drawPanelFrame
// =============================================================================
//
// Fórmula matemática genérica (consensuada 2025-12-31):
// L = 0.2126*R + 0.7152*G + 0.0722*B (luminosidad percibida)
// blend_factor = base_blend + (max_blend - base_blend) * (1.0 - L)
//
// El blend es INVERSAMENTE proporcional a la luminosidad:
// - Colores oscuros (L baja, ej: azul 0.07) más color visible (blend alto ~20%)
// - Colores brillantes (L alta, ej: amarillo 0.93) menos color (blend bajo ~10%)
//
// Esto es puramente matemático, funciona para CUALQUIER color del círculo cromático.
// =============================================================================
/// Colors derived from a single base color for panel frame rendering.
/// Used by Context.drawPanelFrame() when base_color is provided.
pub const DerivedPanelColors = struct {
/// Background when focused
focus_bg: Color,
/// Background when unfocused
unfocus_bg: Color,
/// Border when focused (full base color)
border_focus: Color,
/// Border when unfocused (darkened)
border_unfocus: Color,
/// Title color (lightened base)
title_color: Color,
};
/// Derive panel frame colors from a single base color using generic luminance formula.
///
/// The derivation is purely mathematical and works for ANY color:
/// - Luminance L = 0.2126*R + 0.7152*G + 0.0722*B
/// - Blend factor inversely proportional to L
///
/// Example values:
/// - Blue (L=0.07): blend ~19% more blue visible in dark background
/// - Red (L=0.21): blend ~18% moderate visibility
/// - Green (L=0.72): blend ~13% less green (already bright)
/// - Yellow (L=0.93): blend ~11% minimal (very bright)
pub fn derivePanelFrameColors(base: Color) DerivedPanelColors {
const L = base.perceptualLuminance();
// Generic formula: blend inversely proportional to luminance
// Base blend: 10% (bright colors), Max blend: 20% (dark colors)
const base_blend: f32 = 0.10;
const max_blend: f32 = 0.20;
const blend_factor = base_blend + (max_blend - base_blend) * (1.0 - L);
// Convert to percentage for blendTowards (inverted: 100% = all black)
const focus_pct: u8 = @intFromFloat((1.0 - blend_factor) * 100.0);
const unfocus_pct: u8 = @intFromFloat((1.0 - blend_factor * 0.5) * 100.0);
const black = Color.soft_black;
return .{
.focus_bg = base.blendTowards(black, focus_pct),
.unfocus_bg = base.blendTowards(black, unfocus_pct),
.border_focus = base,
.border_unfocus = base.darken(30),
.title_color = base.lightenHsl(20),
};
}
test "derivePanelFrameColors blue" {
const blue = Color.rgb(59, 130, 246); // laravel_blue
const derived = derivePanelFrameColors(blue);
// Blue has low luminance, should get more visible background
// L ~= 0.07 * 0.23 + 0.51 * 0.72 + 0.96 * 0.07 = 0.016 + 0.37 + 0.067 = 0.45
// Actually for laravel_blue: L = 59/255*0.2126 + 130/255*0.7152 + 246/255*0.0722
// = 0.049 + 0.365 + 0.070 = 0.484
// Border focus should be the original color
try std.testing.expectEqual(blue.r, derived.border_focus.r);
try std.testing.expectEqual(blue.g, derived.border_focus.g);
try std.testing.expectEqual(blue.b, derived.border_focus.b);
// Focus bg should be darker than unfocus bg (more blend towards black)
// Actually focus has MORE color (less blend to black)
try std.testing.expect(derived.focus_bg.r >= derived.unfocus_bg.r);
}
test "derivePanelFrameColors generic formula" {
// Test that brighter colors get less visibility (lower blend)
const dark_blue = Color.rgb(0, 0, 200); // L ~= 0.057
const bright_yellow = Color.rgb(255, 255, 0); // L ~= 0.93
const blue_derived = derivePanelFrameColors(dark_blue);
const yellow_derived = derivePanelFrameColors(bright_yellow);
// Blue background should have more color visible (less black blend)
// This means blue's focus_bg should be brighter/more colorful than yellow's relative to base
// We check that the formula produces different results
try std.testing.expect(blue_derived.focus_bg.b != yellow_derived.focus_bg.b);
}