//! Style - Colors and visual styling //! //! Based on zcatui's style system, adapted for GUI with RGBA colors. const std = @import("std"); // ============================================================================= // Render Mode - Simple vs Fancy // ============================================================================= /// Render mode controls visual quality vs performance tradeoff pub const RenderMode = enum { /// Fast rendering: rectangles, no AA on shapes, no shadows /// Best for: low-end hardware, SSH, WASM with limited resources simple, /// Pretty rendering: rounded corners, edge-fade AA, shadows /// Best for: desktop with good CPU, visual polish needed fancy, }; /// Global render mode - widgets check this to decide how to render var global_render_mode: RenderMode = .fancy; /// Get current render mode pub fn getRenderMode() RenderMode { return global_render_mode; } /// Set render mode pub fn setRenderMode(mode: RenderMode) void { global_render_mode = mode; } /// Check if fancy rendering is enabled pub fn isFancy() bool { return global_render_mode == .fancy; } // ============================================================================= // Perceptual Color Correction - Sistema de Compensación de Colores // ============================================================================= // // PROBLEMA A RESOLVER: // -------------------- // Cuando se oscurecen colores para fondos de paneles (deriveDarkPalette), // algunos colores pierden su identidad y "van a negro" más rápido que otros: // - Panel AZUL: al oscurecerse, parece casi negro (pierde identidad) // - Panel ROJO: al oscurecerse, sigue viéndose "rojo oscuro" (mantiene identidad) // // CAUSA TÉCNICA: // -------------- // La luminosidad percibida por el ojo humano sigue la fórmula ITU-R BT.709: // L = 0.2126*R + 0.7152*G + 0.0722*B // // Valores para colores puros: // - Azul puro (0,0,255): L = 0.0722 (MUY baja) // - Rojo puro (255,0,0): L = 0.2126 (baja pero ~3x más que azul) // - Verde puro (0,255,0): L = 0.7152 (alta) // // El azul tiene la menor contribución a la luminosidad percibida, por eso // al oscurecerlo pierde rápidamente su identidad visual. // // SOLUCIÓN IMPLEMENTADA: // ---------------------- // Umbral de corrección: 0.15 // - Colores con L < 0.15 (como azul ~0.07): reciben boost // - Colores con L >= 0.15 (como rojo ~0.21): NO se modifican // // El "boost" consiste en REDUCIR el porcentaje de blend hacia negro, // dejando más del color base visible en el fondo oscurecido. // // Factor de corrección = max(0.75, L / 0.15) // - Azul: max(0.75, 0.07/0.15) = max(0.75, 0.47) = 0.75 // - Rojo: L=0.21 >= 0.15, no aplica corrección (factor = 1.0) // // Aplicación en deriveDarkPalette: // - focus_blend = 80% * factor (sin corrección: 80%, con: 60%) // - unfocus_blend = 96% * factor (sin corrección: 96%, con: 72%) // // ESTADO ACTUAL (2025-12-30): // --------------------------- // - Activado por defecto (perceptual_correction_enabled = true) // - Umbral: 0.15 // - PENDIENTE DE CONSENSO: El usuario R.Eugenio considera que necesita // más opiniones antes de decidir si este algoritmo es el correcto. // Los colores son subjetivos y requieren consenso del equipo. // // CÓMO DESACTIVAR: // ---------------- // En runtime: Style.setPerceptualCorrection(false) // O cambiar el default aquí abajo a false. // // ============================================================================= /// Enable perceptual correction for panel colors. /// When enabled, colors with VERY low perceived luminance (e.g., blue ~0.07) /// get a boost to avoid going to black when darkened. /// Colors like red (~0.21) are NOT affected as they darken well naturally. /// /// PENDIENTE DE CONSENSO - ver documentación arriba. var perceptual_correction_enabled: bool = true; /// Get whether perceptual correction is enabled pub fn isPerceptualCorrectionEnabled() bool { return perceptual_correction_enabled; } /// Set perceptual correction mode pub fn setPerceptualCorrection(enabled: bool) void { perceptual_correction_enabled = enabled; } /// RGBA Color pub const Color = struct { r: u8, g: u8, b: u8, a: u8 = 255, const Self = @This(); /// Create a color from RGB values pub fn rgb(r: u8, g: u8, b: u8) Self { return .{ .r = r, .g = g, .b = b, .a = 255 }; } /// Create a color from RGBA values pub fn rgba(r: u8, g: u8, b: u8, a: u8) Self { return .{ .r = r, .g = g, .b = b, .a = a }; } /// Convert to u32 (RGBA format) pub fn toU32(self: Self) u32 { return (@as(u32, self.r) << 24) | (@as(u32, self.g) << 16) | (@as(u32, self.b) << 8) | @as(u32, self.a); } /// Convert to u32 (ABGR format for SDL) pub fn toABGR(self: Self) u32 { return (@as(u32, self.a) << 24) | (@as(u32, self.b) << 16) | (@as(u32, self.g) << 8) | @as(u32, self.r); } /// Blend this color over another pub fn blend(self: Self, bg_color: Self) Self { if (self.a == 255) return self; if (self.a == 0) return bg_color; const alpha = @as(u16, self.a); const inv_alpha = 255 - alpha; return .{ .r = @intCast((@as(u16, self.r) * alpha + @as(u16, bg_color.r) * inv_alpha) / 255), .g = @intCast((@as(u16, self.g) * alpha + @as(u16, bg_color.g) * inv_alpha) / 255), .b = @intCast((@as(u16, self.b) * alpha + @as(u16, bg_color.b) * inv_alpha) / 255), .a = 255, }; } /// Darken color by percentage (0-100) pub fn darken(self: Self, percent: u8) Self { const factor = @as(u16, 100 - @min(percent, 100)); return .{ .r = @intCast((@as(u16, self.r) * factor) / 100), .g = @intCast((@as(u16, self.g) * factor) / 100), .b = @intCast((@as(u16, self.b) * factor) / 100), .a = self.a, }; } /// Lighten color by percentage (0-100) pub fn lighten(self: Self, percent: u8) Self { const factor = @as(u16, @min(percent, 100)); return .{ .r = @intCast(@as(u16, self.r) + ((@as(u16, 255) - self.r) * factor) / 100), .g = @intCast(@as(u16, self.g) + ((@as(u16, 255) - self.g) * factor) / 100), .b = @intCast(@as(u16, self.b) + ((@as(u16, 255) - self.b) * factor) / 100), .a = self.a, }; } /// Blend this color towards a target color by percentage (0-100). /// Useful for "washing" colors: base.blendTowards(white, 95) = 95% white + 5% base /// This is the key function for Laravel-style "subtle tint" backgrounds. pub fn blendTowards(self: Self, target: Self, percent: u8) Self { const p = @as(u16, @min(percent, 100)); const inv_p = 100 - p; return .{ .r = @intCast((@as(u16, target.r) * p + @as(u16, self.r) * inv_p) / 100), .g = @intCast((@as(u16, target.g) * p + @as(u16, self.g) * inv_p) / 100), .b = @intCast((@as(u16, target.b) * p + @as(u16, self.b) * inv_p) / 100), .a = 255, }; } /// Return same color with different alpha pub fn withAlpha(self: Self, alpha: u8) Self { return .{ .r = self.r, .g = self.g, .b = self.b, .a = alpha, }; } // ========================================================================= // Perceptual luminance (ITU-R BT.709) // ========================================================================= /// Calculate perceived luminance using ITU-R BT.709 weights. /// Returns 0.0 (black) to 1.0 (white). /// Human eyes perceive green as brightest, red medium, blue darkest. /// Weights: R=0.2126, G=0.7152, B=0.0722 pub fn perceptualLuminance(self: Self) f32 { const r_norm = @as(f32, @floatFromInt(self.r)) / 255.0; const g_norm = @as(f32, @floatFromInt(self.g)) / 255.0; const b_norm = @as(f32, @floatFromInt(self.b)) / 255.0; return r_norm * 0.2126 + g_norm * 0.7152 + b_norm * 0.0722; } // ========================================================================= // HSL-based transformations (more perceptually uniform) // ========================================================================= /// Convert this color to HSL representation /// Note: Uses forward declaration, actual function defined after Hsl struct pub fn toHsl(self: Self) Hsl { return rgbToHsl(self.r, self.g, self.b); } /// Lighten using HSL (more perceptually uniform than RGB lighten) pub fn lightenHsl(self: Self, percent: f32) Self { return self.toHsl().lighten(percent).toRgb().withAlpha(self.a); } /// Darken using HSL (more perceptually uniform than RGB darken) pub fn darkenHsl(self: Self, percent: f32) Self { return self.toHsl().darken(percent).toRgb().withAlpha(self.a); } /// Increase saturation (make more vivid) pub fn saturate(self: Self, percent: f32) Self { return self.toHsl().saturate(percent).toRgb().withAlpha(self.a); } /// Decrease saturation (make more gray) pub fn desaturate(self: Self, percent: f32) Self { return self.toHsl().desaturate(percent).toRgb().withAlpha(self.a); } /// Rotate hue by degrees (color wheel shift) pub fn rotateHue(self: Self, degrees: f32) Self { return self.toHsl().rotate(degrees).toRgb().withAlpha(self.a); } /// Get complementary color (opposite on color wheel) pub fn complementary(self: Self) Self { return self.rotateHue(180); } // ========================================================================= // Predefined colors // ========================================================================= pub const transparent = Color.rgba(0, 0, 0, 0); pub const black = Color.rgb(0, 0, 0); pub const white = Color.rgb(255, 255, 255); pub const red = Color.rgb(255, 0, 0); pub const green = Color.rgb(0, 255, 0); pub const blue = Color.rgb(0, 0, 255); pub const yellow = Color.rgb(255, 255, 0); pub const cyan = Color.rgb(0, 255, 255); pub const magenta = Color.rgb(255, 0, 255); pub const gray = Color.rgb(128, 128, 128); pub const dark_gray = Color.rgb(64, 64, 64); pub const light_gray = Color.rgb(192, 192, 192); // UI colors pub const background = Color.rgb(30, 30, 30); pub const foreground = Color.rgb(220, 220, 220); pub const primary = Color.rgb(66, 135, 245); pub const secondary = Color.rgb(100, 100, 100); pub const success = Color.rgb(76, 175, 80); pub const warning = Color.rgb(255, 152, 0); pub const danger = Color.rgb(244, 67, 54); pub const border = Color.rgb(80, 80, 80); // ========================================================================= // Laravel-inspired colors (from Forge/Nova/Vapor) // Used as base colors for semantic panel derivation (Z-Design) // ========================================================================= pub const laravel_red = Color.rgb(239, 68, 68); // #EF4444 - Facturas, alertas pub const laravel_blue = Color.rgb(59, 130, 246); // #3B82F6 - Clientes, links pub const laravel_green = Color.rgb(34, 197, 94); // #22C55E - Exito, pagado pub const laravel_amber = Color.rgb(245, 158, 11); // #F59E0B - Pedidos, warning pub const laravel_cyan = Color.rgb(6, 182, 212); // #06B6D4 - Albaranes, info pub const laravel_gray = Color.rgb(107, 114, 128); // #6B7280 - Presupuestos, neutral pub const laravel_purple = Color.rgb(139, 92, 246); // #8B5CF6 - Especial pub const laravel_pink = Color.rgb(236, 72, 153); // #EC4899 - Destacado // Base colors for derivation (soft black/white for better aesthetics) pub const soft_black = Color.rgb(17, 17, 20); // Not pure black pub const soft_white = Color.rgb(250, 250, 252); // Not pure white }; // ============================================================================= // HSL Color Space // ============================================================================= /// HSL color representation /// H: Hue (0-360 degrees) /// S: Saturation (0.0-1.0) /// L: Lightness (0.0-1.0) pub const Hsl = struct { h: f32, // 0-360 s: f32, // 0-1 l: f32, // 0-1 const Self = @This(); /// Convert HSL to RGB Color pub fn toRgb(self: Self) Color { return hslToRgb(self.h, self.s, self.l); } /// Create HSL with clamped values pub fn init(h: f32, s: f32, l: f32) Self { return .{ .h = @mod(h, 360.0), .s = std.math.clamp(s, 0.0, 1.0), .l = std.math.clamp(l, 0.0, 1.0), }; } /// Increase lightness by percentage (0-100) pub fn lighten(self: Self, percent: f32) Self { const delta = percent / 100.0; return Self.init(self.h, self.s, self.l + (1.0 - self.l) * delta); } /// Decrease lightness by percentage (0-100) pub fn darken(self: Self, percent: f32) Self { const delta = percent / 100.0; return Self.init(self.h, self.s, self.l * (1.0 - delta)); } /// Increase saturation by percentage (0-100) pub fn saturate(self: Self, percent: f32) Self { const delta = percent / 100.0; return Self.init(self.h, self.s + (1.0 - self.s) * delta, self.l); } /// Decrease saturation by percentage (0-100) pub fn desaturate(self: Self, percent: f32) Self { const delta = percent / 100.0; return Self.init(self.h, self.s * (1.0 - delta), self.l); } /// Rotate hue by degrees pub fn rotate(self: Self, degrees: f32) Self { return Self.init(self.h + degrees, self.s, self.l); } }; /// Convert RGB (0-255) to HSL pub fn rgbToHsl(r: u8, g: u8, b: u8) Hsl { // Normalize to 0-1 range const rf: f32 = @as(f32, @floatFromInt(r)) / 255.0; const gf: f32 = @as(f32, @floatFromInt(g)) / 255.0; const bf: f32 = @as(f32, @floatFromInt(b)) / 255.0; const max_val = @max(@max(rf, gf), bf); const min_val = @min(@min(rf, gf), bf); const delta = max_val - min_val; // Lightness const l = (max_val + min_val) / 2.0; // Achromatic (gray) if (delta == 0) { return .{ .h = 0, .s = 0, .l = l }; } // Saturation const s = if (l > 0.5) delta / (2.0 - max_val - min_val) else delta / (max_val + min_val); // Hue var h: f32 = 0; if (max_val == rf) { h = (gf - bf) / delta; if (gf < bf) h += 6.0; } else if (max_val == gf) { h = (bf - rf) / delta + 2.0; } else { h = (rf - gf) / delta + 4.0; } h *= 60.0; return .{ .h = h, .s = s, .l = l }; } /// Convert HSL to RGB Color pub fn hslToRgb(h: f32, s: f32, l: f32) Color { // Achromatic (gray) if (s == 0) { const gray: u8 = @intFromFloat(l * 255.0); return Color.rgb(gray, gray, gray); } const q = if (l < 0.5) l * (1.0 + s) else l + s - l * s; const p = 2.0 * l - q; const hue_norm = h / 360.0; const r = hueToRgb(p, q, hue_norm + 1.0 / 3.0); const g = hueToRgb(p, q, hue_norm); const b = hueToRgb(p, q, hue_norm - 1.0 / 3.0); return Color.rgb( @intFromFloat(r * 255.0), @intFromFloat(g * 255.0), @intFromFloat(b * 255.0), ); } /// Helper for HSL to RGB conversion fn hueToRgb(p: f32, q: f32, t_in: f32) f32 { var t = t_in; if (t < 0) t += 1.0; if (t > 1) t -= 1.0; if (t < 1.0 / 6.0) return p + (q - p) * 6.0 * t; if (t < 1.0 / 2.0) return q; if (t < 2.0 / 3.0) return p + (q - p) * (2.0 / 3.0 - t) * 6.0; return p; } // ============================================================================= // Style // ============================================================================= /// Visual style for widgets pub const Style = struct { foreground: Color = Color.foreground, background: Color = Color.background, border: ?Color = null, border_radius: u8 = 0, const Self = @This(); /// Set foreground color pub fn fg(self: Self, color: Color) Self { var s = self; s.foreground = color; return s; } /// Set background color pub fn bg(self: Self, color: Color) Self { var s = self; s.background = color; return s; } /// Set border color pub fn withBorder(self: Self, color: Color) Self { var s = self; s.border = color; return s; } }; // ============================================================================= // Theme // ============================================================================= /// A theme defines colors for all UI elements pub const Theme = struct { /// Theme name name: []const u8 = "custom", // Base colors background: Color, foreground: Color, primary: Color, secondary: Color, success: Color, warning: Color, danger: Color, border: Color, // Surface colors (panels, cards) surface: Color, surface_variant: Color, // Text variants text_primary: Color, text_secondary: Color, text_disabled: Color, // Button colors button_bg: Color, button_fg: Color, button_hover: Color, button_active: Color, button_disabled_bg: Color, button_disabled_fg: Color, // Input colors input_bg: Color, input_fg: Color, input_border: Color, input_focus_border: Color, input_placeholder: Color, // Selection colors selection_bg: Color, selection_fg: Color, // Header/menu bar header_bg: Color, header_fg: Color, // Table colors table_header_bg: Color, table_row_even: Color, table_row_odd: Color, table_row_hover: Color, table_row_selected: Color, // Scrollbar scrollbar_track: Color, scrollbar_thumb: Color, scrollbar_thumb_hover: Color, // Modal/dialog modal_overlay: Color, modal_bg: Color, const Self = @This(); /// Dark theme (default) pub const dark = Self{ .name = "dark", .background = Color.rgb(30, 30, 30), .foreground = Color.rgb(220, 220, 220), .primary = Color.rgb(66, 135, 245), .secondary = Color.rgb(100, 100, 100), .success = Color.rgb(76, 175, 80), .warning = Color.rgb(255, 152, 0), .danger = Color.rgb(244, 67, 54), .border = Color.rgb(80, 80, 80), .surface = Color.rgb(40, 40, 40), .surface_variant = Color.rgb(50, 50, 50), .text_primary = Color.rgb(220, 220, 220), .text_secondary = Color.rgb(160, 160, 160), .text_disabled = Color.rgb(100, 100, 100), .button_bg = Color.rgb(60, 60, 60), .button_fg = Color.rgb(220, 220, 220), .button_hover = Color.rgb(80, 80, 80), .button_active = Color.rgb(50, 50, 50), .button_disabled_bg = Color.rgb(45, 45, 45), .button_disabled_fg = Color.rgb(100, 100, 100), .input_bg = Color.rgb(45, 45, 45), .input_fg = Color.rgb(220, 220, 220), .input_border = Color.rgb(80, 80, 80), .input_focus_border = Color.rgb(66, 135, 245), .input_placeholder = Color.rgb(120, 120, 120), .selection_bg = Color.rgb(66, 135, 245), .selection_fg = Color.rgb(255, 255, 255), .header_bg = Color.rgb(35, 35, 40), .header_fg = Color.rgb(200, 200, 200), .table_header_bg = Color.rgb(50, 50, 50), .table_row_even = Color.rgb(35, 35, 35), .table_row_odd = Color.rgb(40, 40, 40), .table_row_hover = Color.rgb(50, 50, 60), .table_row_selected = Color.rgb(66, 135, 245), .scrollbar_track = Color.rgb(40, 40, 40), .scrollbar_thumb = Color.rgb(80, 80, 80), .scrollbar_thumb_hover = Color.rgb(100, 100, 100), .modal_overlay = Color.rgba(0, 0, 0, 180), .modal_bg = Color.rgb(45, 45, 50), }; /// Light theme pub const light = Self{ .name = "light", .background = Color.rgb(245, 245, 245), .foreground = Color.rgb(30, 30, 30), .primary = Color.rgb(33, 150, 243), .secondary = Color.rgb(158, 158, 158), .success = Color.rgb(76, 175, 80), .warning = Color.rgb(255, 152, 0), .danger = Color.rgb(244, 67, 54), .border = Color.rgb(200, 200, 200), .surface = Color.rgb(255, 255, 255), .surface_variant = Color.rgb(240, 240, 240), .text_primary = Color.rgb(30, 30, 30), .text_secondary = Color.rgb(100, 100, 100), .text_disabled = Color.rgb(180, 180, 180), .button_bg = Color.rgb(230, 230, 230), .button_fg = Color.rgb(30, 30, 30), .button_hover = Color.rgb(210, 210, 210), .button_active = Color.rgb(190, 190, 190), .button_disabled_bg = Color.rgb(240, 240, 240), .button_disabled_fg = Color.rgb(180, 180, 180), .input_bg = Color.rgb(255, 255, 255), .input_fg = Color.rgb(30, 30, 30), .input_border = Color.rgb(180, 180, 180), .input_focus_border = Color.rgb(33, 150, 243), .input_placeholder = Color.rgb(160, 160, 160), .selection_bg = Color.rgb(33, 150, 243), .selection_fg = Color.rgb(255, 255, 255), .header_bg = Color.rgb(255, 255, 255), .header_fg = Color.rgb(50, 50, 50), .table_header_bg = Color.rgb(240, 240, 240), .table_row_even = Color.rgb(255, 255, 255), .table_row_odd = Color.rgb(248, 248, 248), .table_row_hover = Color.rgb(235, 245, 255), .table_row_selected = Color.rgb(33, 150, 243), .scrollbar_track = Color.rgb(240, 240, 240), .scrollbar_thumb = Color.rgb(200, 200, 200), .scrollbar_thumb_hover = Color.rgb(180, 180, 180), .modal_overlay = Color.rgba(0, 0, 0, 120), .modal_bg = Color.rgb(255, 255, 255), }; /// High contrast dark theme pub const high_contrast_dark = Self{ .name = "high_contrast_dark", .background = Color.rgb(0, 0, 0), .foreground = Color.rgb(255, 255, 255), .primary = Color.rgb(0, 200, 255), .secondary = Color.rgb(180, 180, 180), .success = Color.rgb(0, 255, 0), .warning = Color.rgb(255, 255, 0), .danger = Color.rgb(255, 0, 0), .border = Color.rgb(255, 255, 255), .surface = Color.rgb(20, 20, 20), .surface_variant = Color.rgb(40, 40, 40), .text_primary = Color.rgb(255, 255, 255), .text_secondary = Color.rgb(200, 200, 200), .text_disabled = Color.rgb(128, 128, 128), .button_bg = Color.rgb(40, 40, 40), .button_fg = Color.rgb(255, 255, 255), .button_hover = Color.rgb(60, 60, 60), .button_active = Color.rgb(20, 20, 20), .button_disabled_bg = Color.rgb(30, 30, 30), .button_disabled_fg = Color.rgb(100, 100, 100), .input_bg = Color.rgb(0, 0, 0), .input_fg = Color.rgb(255, 255, 255), .input_border = Color.rgb(255, 255, 255), .input_focus_border = Color.rgb(0, 200, 255), .input_placeholder = Color.rgb(150, 150, 150), .selection_bg = Color.rgb(0, 200, 255), .selection_fg = Color.rgb(0, 0, 0), .header_bg = Color.rgb(0, 0, 0), .header_fg = Color.rgb(255, 255, 255), .table_header_bg = Color.rgb(30, 30, 30), .table_row_even = Color.rgb(0, 0, 0), .table_row_odd = Color.rgb(20, 20, 20), .table_row_hover = Color.rgb(40, 40, 60), .table_row_selected = Color.rgb(0, 200, 255), .scrollbar_track = Color.rgb(20, 20, 20), .scrollbar_thumb = Color.rgb(150, 150, 150), .scrollbar_thumb_hover = Color.rgb(200, 200, 200), .modal_overlay = Color.rgba(0, 0, 0, 200), .modal_bg = Color.rgb(20, 20, 20), }; /// Solarized Dark theme pub const solarized_dark = Self{ .name = "solarized_dark", .background = Color.rgb(0, 43, 54), // base03 .foreground = Color.rgb(131, 148, 150), // base0 .primary = Color.rgb(38, 139, 210), // blue .secondary = Color.rgb(88, 110, 117), // base01 .success = Color.rgb(133, 153, 0), // green .warning = Color.rgb(181, 137, 0), // yellow .danger = Color.rgb(220, 50, 47), // red .border = Color.rgb(88, 110, 117), // base01 .surface = Color.rgb(7, 54, 66), // base02 .surface_variant = Color.rgb(0, 43, 54), // base03 .text_primary = Color.rgb(147, 161, 161), // base1 .text_secondary = Color.rgb(131, 148, 150), // base0 .text_disabled = Color.rgb(88, 110, 117), // base01 .button_bg = Color.rgb(7, 54, 66), .button_fg = Color.rgb(147, 161, 161), .button_hover = Color.rgb(88, 110, 117), .button_active = Color.rgb(0, 43, 54), .button_disabled_bg = Color.rgb(0, 43, 54), .button_disabled_fg = Color.rgb(88, 110, 117), .input_bg = Color.rgb(0, 43, 54), .input_fg = Color.rgb(147, 161, 161), .input_border = Color.rgb(88, 110, 117), .input_focus_border = Color.rgb(38, 139, 210), .input_placeholder = Color.rgb(88, 110, 117), .selection_bg = Color.rgb(38, 139, 210), .selection_fg = Color.rgb(253, 246, 227), .header_bg = Color.rgb(7, 54, 66), .header_fg = Color.rgb(147, 161, 161), .table_header_bg = Color.rgb(7, 54, 66), .table_row_even = Color.rgb(0, 43, 54), .table_row_odd = Color.rgb(7, 54, 66), .table_row_hover = Color.rgb(88, 110, 117), .table_row_selected = Color.rgb(38, 139, 210), .scrollbar_track = Color.rgb(0, 43, 54), .scrollbar_thumb = Color.rgb(88, 110, 117), .scrollbar_thumb_hover = Color.rgb(101, 123, 131), .modal_overlay = Color.rgba(0, 0, 0, 180), .modal_bg = Color.rgb(7, 54, 66), }; /// Solarized Light theme pub const solarized_light = Self{ .name = "solarized_light", .background = Color.rgb(253, 246, 227), // base3 .foreground = Color.rgb(101, 123, 131), // base00 .primary = Color.rgb(38, 139, 210), // blue .secondary = Color.rgb(147, 161, 161), // base1 .success = Color.rgb(133, 153, 0), // green .warning = Color.rgb(181, 137, 0), // yellow .danger = Color.rgb(220, 50, 47), // red .border = Color.rgb(147, 161, 161), // base1 .surface = Color.rgb(238, 232, 213), // base2 .surface_variant = Color.rgb(253, 246, 227), // base3 .text_primary = Color.rgb(88, 110, 117), // base01 .text_secondary = Color.rgb(101, 123, 131), // base00 .text_disabled = Color.rgb(147, 161, 161), // base1 .button_bg = Color.rgb(238, 232, 213), .button_fg = Color.rgb(88, 110, 117), .button_hover = Color.rgb(147, 161, 161), .button_active = Color.rgb(253, 246, 227), .button_disabled_bg = Color.rgb(253, 246, 227), .button_disabled_fg = Color.rgb(147, 161, 161), .input_bg = Color.rgb(253, 246, 227), .input_fg = Color.rgb(88, 110, 117), .input_border = Color.rgb(147, 161, 161), .input_focus_border = Color.rgb(38, 139, 210), .input_placeholder = Color.rgb(147, 161, 161), .selection_bg = Color.rgb(38, 139, 210), .selection_fg = Color.rgb(253, 246, 227), .header_bg = Color.rgb(238, 232, 213), .header_fg = Color.rgb(88, 110, 117), .table_header_bg = Color.rgb(238, 232, 213), .table_row_even = Color.rgb(253, 246, 227), .table_row_odd = Color.rgb(238, 232, 213), .table_row_hover = Color.rgb(147, 161, 161), .table_row_selected = Color.rgb(38, 139, 210), .scrollbar_track = Color.rgb(253, 246, 227), .scrollbar_thumb = Color.rgb(147, 161, 161), .scrollbar_thumb_hover = Color.rgb(131, 148, 150), .modal_overlay = Color.rgba(0, 0, 0, 120), .modal_bg = Color.rgb(238, 232, 213), }; }; // ============================================================================= // Theme Manager // ============================================================================= /// Global theme manager pub const ThemeManager = struct { /// Current theme current: *const Theme, const Self = @This(); /// Initialize with default dark theme pub fn init() Self { return Self{ .current = &Theme.dark, }; } /// Set current theme pub fn setTheme(self: *Self, theme: *const Theme) void { self.current = theme; } /// Get current theme pub fn getTheme(self: Self) *const Theme { return self.current; } /// Switch to dark theme pub fn setDark(self: *Self) void { self.current = &Theme.dark; } /// Switch to light theme pub fn setLight(self: *Self) void { self.current = &Theme.light; } /// Toggle between dark and light pub fn toggle(self: *Self) void { if (std.mem.eql(u8, self.current.name, "dark")) { self.current = &Theme.light; } else { self.current = &Theme.dark; } } }; /// Global theme manager instance var global_theme_manager: ?ThemeManager = null; /// Get global theme manager pub fn getThemeManager() *ThemeManager { if (global_theme_manager == null) { global_theme_manager = ThemeManager.init(); } return &global_theme_manager.?; } /// Get current theme (convenience function) pub fn currentTheme() *const Theme { return getThemeManager().current; } // ============================================================================= // Tests // ============================================================================= test "Color creation" { const c = Color.rgb(100, 150, 200); try std.testing.expectEqual(@as(u8, 100), c.r); try std.testing.expectEqual(@as(u8, 150), c.g); try std.testing.expectEqual(@as(u8, 200), c.b); try std.testing.expectEqual(@as(u8, 255), c.a); } test "Color darken" { const white = Color.white; const darkened = white.darken(50); try std.testing.expectEqual(@as(u8, 127), darkened.r); } test "Color blend" { const fg = Color.rgba(255, 0, 0, 128); const bg = Color.rgb(0, 0, 255); const blended = fg.blend(bg); // Should be purple-ish try std.testing.expect(blended.r > 100); try std.testing.expect(blended.b > 100); } test "Theme dark" { const theme = Theme.dark; try std.testing.expect(std.mem.eql(u8, theme.name, "dark")); try std.testing.expectEqual(@as(u8, 30), theme.background.r); } test "Theme light" { const theme = Theme.light; try std.testing.expect(std.mem.eql(u8, theme.name, "light")); try std.testing.expectEqual(@as(u8, 245), theme.background.r); } test "ThemeManager toggle" { var tm = ThemeManager.init(); try std.testing.expect(std.mem.eql(u8, tm.current.name, "dark")); tm.toggle(); try std.testing.expect(std.mem.eql(u8, tm.current.name, "light")); tm.toggle(); try std.testing.expect(std.mem.eql(u8, tm.current.name, "dark")); } test "ThemeManager setTheme" { var tm = ThemeManager.init(); tm.setTheme(&Theme.solarized_dark); try std.testing.expect(std.mem.eql(u8, tm.current.name, "solarized_dark")); tm.setTheme(&Theme.high_contrast_dark); try std.testing.expect(std.mem.eql(u8, tm.current.name, "high_contrast_dark")); } // ============================================================================= // HSL Tests // ============================================================================= test "rgbToHsl pure red" { const hsl = rgbToHsl(255, 0, 0); try std.testing.expectApproxEqAbs(@as(f32, 0.0), hsl.h, 0.1); try std.testing.expectApproxEqAbs(@as(f32, 1.0), hsl.s, 0.01); try std.testing.expectApproxEqAbs(@as(f32, 0.5), hsl.l, 0.01); } test "rgbToHsl pure green" { const hsl = rgbToHsl(0, 255, 0); try std.testing.expectApproxEqAbs(@as(f32, 120.0), hsl.h, 0.1); try std.testing.expectApproxEqAbs(@as(f32, 1.0), hsl.s, 0.01); try std.testing.expectApproxEqAbs(@as(f32, 0.5), hsl.l, 0.01); } test "rgbToHsl pure blue" { const hsl = rgbToHsl(0, 0, 255); try std.testing.expectApproxEqAbs(@as(f32, 240.0), hsl.h, 0.1); try std.testing.expectApproxEqAbs(@as(f32, 1.0), hsl.s, 0.01); try std.testing.expectApproxEqAbs(@as(f32, 0.5), hsl.l, 0.01); } test "rgbToHsl white" { const hsl = rgbToHsl(255, 255, 255); try std.testing.expectApproxEqAbs(@as(f32, 0.0), hsl.s, 0.01); // No saturation try std.testing.expectApproxEqAbs(@as(f32, 1.0), hsl.l, 0.01); // Max lightness } test "rgbToHsl black" { const hsl = rgbToHsl(0, 0, 0); try std.testing.expectApproxEqAbs(@as(f32, 0.0), hsl.s, 0.01); // No saturation try std.testing.expectApproxEqAbs(@as(f32, 0.0), hsl.l, 0.01); // Min lightness } test "rgbToHsl gray" { const hsl = rgbToHsl(128, 128, 128); try std.testing.expectApproxEqAbs(@as(f32, 0.0), hsl.s, 0.01); // No saturation try std.testing.expectApproxEqAbs(@as(f32, 0.5), hsl.l, 0.02); // Mid lightness } test "hslToRgb pure red" { const color = hslToRgb(0, 1.0, 0.5); try std.testing.expectEqual(@as(u8, 255), color.r); try std.testing.expectEqual(@as(u8, 0), color.g); try std.testing.expectEqual(@as(u8, 0), color.b); } test "hslToRgb roundtrip" { // Test that RGB -> HSL -> RGB preserves color (within rounding tolerance) const original = Color.rgb(200, 100, 50); const hsl = original.toHsl(); const recovered = hsl.toRgb(); // Allow 1-2 units of rounding error in each channel try std.testing.expect(@abs(@as(i16, original.r) - @as(i16, recovered.r)) <= 2); try std.testing.expect(@abs(@as(i16, original.g) - @as(i16, recovered.g)) <= 2); try std.testing.expect(@abs(@as(i16, original.b) - @as(i16, recovered.b)) <= 2); } test "Color.saturate red" { // Test saturating a color that has hue const muted_red = Color.rgb(200, 100, 100); // A muted red const saturated = muted_red.saturate(30); // Saturating should increase the difference between R and G/B const orig_diff = @as(i16, muted_red.r) - @as(i16, muted_red.g); const new_diff = @as(i16, saturated.r) - @as(i16, saturated.g); try std.testing.expect(new_diff >= orig_diff); } test "Color.desaturate" { const red = Color.rgb(255, 0, 0); const desaturated = red.desaturate(100); // Full desaturation = gray // Should become a gray (equal R, G, B) try std.testing.expectEqual(desaturated.r, desaturated.g); try std.testing.expectEqual(desaturated.g, desaturated.b); } test "Color.rotateHue" { const red = Color.rgb(255, 0, 0); // Hue = 0 const green = red.rotateHue(120); // Hue = 120 = green // Should be mostly green try std.testing.expect(green.g > green.r); try std.testing.expect(green.g > green.b); } test "Color.complementary" { const red = Color.rgb(255, 0, 0); const cyan = red.complementary(); // 180 degrees = cyan // Cyan has high G and B, low R try std.testing.expect(cyan.g > 200); try std.testing.expect(cyan.b > 200); try std.testing.expect(cyan.r < 50); } test "Hsl.lighten" { var hsl = Hsl.init(0, 1.0, 0.5); hsl = hsl.lighten(50); // Lighten 50% try std.testing.expect(hsl.l > 0.5); try std.testing.expect(hsl.l < 1.0); } test "Hsl.darken" { var hsl = Hsl.init(0, 1.0, 0.5); hsl = hsl.darken(50); // Darken 50% try std.testing.expect(hsl.l < 0.5); try std.testing.expect(hsl.l > 0.0); } // ============================================================================= // Z-DESIGN: Panel Color Derivation System // ============================================================================= // // Inspired by Laravel Forge/Nova/Vapor aesthetic. // Derives a complete 10-color panel palette from a single base color. // // Architecture: // 1. Base color (e.g., laravel_red for invoices) // 2. ThemeMode (dark or light) // 3. derivePanelPalette() generates all 10 colors mathematically // // This enables "semantic coloring" where entity type determines panel appearance. /// Theme mode for panel derivation pub const ThemeMode = enum { dark, // Dark backgrounds, light text light, // Light backgrounds, dark text }; /// Complete color scheme for a panel (10 colors) /// Designed to be derived from a single base color. pub const PanelColorScheme = struct { /// Background when panel has focus (subtle tint of base color) fondo_con_focus: Color, /// Background when panel doesn't have focus (neutral) fondo_sin_focus: Color, /// Data/content text color (high contrast) datos: Color, /// Label text color (secondary text) etiquetas: Color, /// Header/title background header: Color, /// Placeholder text color placeholder: Color, /// Selection background when focused (base color shines here) seleccion_fondo_con_focus: Color, /// Selection background when unfocused (muted) seleccion_fondo_sin_focus: Color, /// Border when focused (accent) borde_con_focus: Color, /// Border when unfocused (subtle) borde_sin_focus: Color, }; /// Derives a complete 10-color panel palette from a single base color. /// /// The base color "tints" the panel subtly, creating visual cohesion /// while maintaining readability. Selection and borders use the base /// color at full strength as the accent. /// /// Example: /// ```zig /// const invoice_palette = derivePanelPalette(Color.laravel_red, .dark); /// // invoice_palette.fondo_con_focus = subtle red-tinted dark background /// // invoice_palette.seleccion_fondo_con_focus = full laravel_red /// ``` pub fn derivePanelPalette(base: Color, mode: ThemeMode) PanelColorScheme { return switch (mode) { .dark => deriveDarkPalette(base), .light => deriveLightPalette(base), }; } /// Derive palette for dark mode (dark backgrounds, light text) /// Z-Design V5: Sincronía Atmosférica (2025-12-31) /// - SIN compensación perceptual (causaba más problemas que soluciones) /// - Blend fijo para TODOS los colores: /// - fondo_con_focus: 20% base color / 80% negro /// - fondo_sin_focus: 12% base color / 88% negro fn deriveDarkPalette(base: Color) PanelColorScheme { // Reference colors for dark mode const black = Color.soft_black; // RGB(17, 17, 20) - not pure black const white = Color.rgb(245, 245, 245); // Off-white for softer look const gray = Color.rgb(128, 128, 128); const dark_border = Color.rgb(60, 60, 65); // Z-Design V5: Blend fijo sin compensación perceptual // Focus: 20% color, Unfocus: 12% color (más visible que el anterior 4%) const focus_blend: u8 = 80; // 80% hacia negro = 20% color const unfocus_blend: u8 = 88; // 88% hacia negro = 12% color return .{ // Backgrounds: Z-Design V5 - blend fijo .fondo_con_focus = base.blendTowards(black, focus_blend), .fondo_sin_focus = base.blendTowards(black, unfocus_blend), // Text: high contrast .datos = white, .etiquetas = white.darken(30), // ~70% brightness .placeholder = gray, // Header: darkened using HSL (preserves hue better than RGB darken) .header = base.darkenHsl(50), // Selection: base color at full strength when focused .seleccion_fondo_con_focus = base, // Unfocused: desaturated and slightly lightened (HSL-based, more elegant) .seleccion_fondo_sin_focus = base.desaturate(60).lightenHsl(10), // Borders: accent on focus, subtle otherwise .borde_con_focus = base, .borde_sin_focus = dark_border, }; } /// Derive palette for light mode (light backgrounds, dark text) /// Z-Design V2 + Liquid UI: Mayor contraste para transiciones perceptibles /// - fondo_sin_focus: 1% base (casi blanco, punto de partida) /// - fondo_con_focus: 6% base (brilla al ganar foco, destino) /// Z-Design V3: Perceptual correction boosts low-luminance colors (red/magenta) fn deriveLightPalette(base: Color) PanelColorScheme { // Reference colors for light mode const white = Color.soft_white; // RGB(250, 250, 252) - slight cool tint const black = Color.rgb(20, 20, 25); const gray = Color.rgb(128, 128, 128); const light_border = Color.rgb(220, 220, 225); // Perceptual correction: only for colors with VERY low luminance (like pure blue ~0.07) // Red (~0.21) is above threshold and won't be affected const base_lum = base.perceptualLuminance(); const threshold: f32 = 0.15; // Only affect colors below this (blue=0.07, red=0.21) const correction: f32 = if (perceptual_correction_enabled and base_lum < threshold) @max(0.75, base_lum / threshold) // Subtle boost for very dark colors else 1.0; // Apply correction (lower = more color visible on light background) const focus_blend: u8 = @intFromFloat(94.0 * correction); const unfocus_blend: u8 = @intFromFloat(99.0 * correction); return .{ // Backgrounds: Liquid UI V2 - mayor recorrido para transición perceptible // Focus: 6% base, 94% white (destino más tintado) - adjusted by correction .fondo_con_focus = base.blendTowards(white, focus_blend), // Sin focus: 1% base, 99% white (punto de partida neutro) - adjusted .fondo_sin_focus = base.blendTowards(white, unfocus_blend), // Text: high contrast .datos = black, .etiquetas = black.lighten(40), // ~60% darkness .placeholder = gray, // Header: lightened using HSL (preserves hue better) .header = base.lightenHsl(40), // Selection: base color at full strength when focused .seleccion_fondo_con_focus = base, // Unfocused: desaturated and lightened (HSL-based) .seleccion_fondo_sin_focus = base.desaturate(50).lightenHsl(30), // Borders: accent on focus, subtle otherwise .borde_con_focus = base, .borde_sin_focus = light_border, }; } /// Get appropriate text color (black or white) based on background luminosity. /// Uses the HSL lightness value to determine contrast. pub fn contrastTextColor(background: Color) Color { const hsl = background.toHsl(); // If background is light (L > 0.5), use dark text; otherwise use light text return if (hsl.l > 0.5) Color.rgb(20, 20, 25) // Dark text for light backgrounds else Color.rgb(245, 245, 245); // Light text for dark backgrounds } // ============================================================================= // Z-Design Tests // ============================================================================= test "blendTowards basic" { const red = Color.rgb(255, 0, 0); const white = Color.rgb(255, 255, 255); // 50% blend towards white const result = red.blendTowards(white, 50); try std.testing.expectEqual(@as(u8, 255), result.r); // Red stays 255 try std.testing.expectEqual(@as(u8, 127), result.g); // 0 -> 127 try std.testing.expectEqual(@as(u8, 127), result.b); // 0 -> 127 } test "blendTowards extremes" { const base = Color.rgb(100, 100, 100); const target = Color.rgb(200, 200, 200); // 0% = pure base const zero = base.blendTowards(target, 0); try std.testing.expectEqual(@as(u8, 100), zero.r); // 100% = pure target const hundred = base.blendTowards(target, 100); try std.testing.expectEqual(@as(u8, 200), hundred.r); } test "derivePanelPalette dark mode" { const palette = derivePanelPalette(Color.laravel_red, .dark); // Selection should be the full base color try std.testing.expectEqual(Color.laravel_red.r, palette.seleccion_fondo_con_focus.r); try std.testing.expectEqual(Color.laravel_red.g, palette.seleccion_fondo_con_focus.g); try std.testing.expectEqual(Color.laravel_red.b, palette.seleccion_fondo_con_focus.b); // Background should be dark with visible tint // Z-Design V3: With perceptual correction, red gets boosted (less blend towards black) // so the red component can be higher than before (~80 instead of ~60) try std.testing.expect(palette.fondo_con_focus.r < 100); // Still dark try std.testing.expect(palette.fondo_con_focus.g < 45); try std.testing.expect(palette.fondo_con_focus.b < 45); // The red component should be higher than G/B (tint visible) try std.testing.expect(palette.fondo_con_focus.r >= palette.fondo_con_focus.g); } test "perceptualLuminance" { // Pure red: 0.2126 const red = Color.rgb(255, 0, 0); try std.testing.expect(red.perceptualLuminance() > 0.2); try std.testing.expect(red.perceptualLuminance() < 0.22); // Pure green: 0.7152 const green = Color.rgb(0, 255, 0); try std.testing.expect(green.perceptualLuminance() > 0.71); try std.testing.expect(green.perceptualLuminance() < 0.72); // Pure blue: 0.0722 const blue = Color.rgb(0, 0, 255); try std.testing.expect(blue.perceptualLuminance() > 0.07); try std.testing.expect(blue.perceptualLuminance() < 0.08); // White: 1.0 const white = Color.rgb(255, 255, 255); try std.testing.expect(white.perceptualLuminance() > 0.99); // Black: 0.0 const black = Color.rgb(0, 0, 0); try std.testing.expect(black.perceptualLuminance() < 0.01); // Blue has higher perceived luminance than red (at same saturation) const laravel_red_lum = Color.laravel_red.perceptualLuminance(); const laravel_blue_lum = Color.laravel_blue.perceptualLuminance(); try std.testing.expect(laravel_blue_lum > laravel_red_lum); } test "derivePanelPalette light mode" { const palette = derivePanelPalette(Color.laravel_blue, .light); // Selection should be the full base color try std.testing.expectEqual(Color.laravel_blue.r, palette.seleccion_fondo_con_focus.r); // Background should be light with visible tint (Liquid UI V2: 6% base color) // 94% blend towards white = light with noticeable tint for transitions try std.testing.expect(palette.fondo_con_focus.r > 230); try std.testing.expect(palette.fondo_con_focus.g > 235); try std.testing.expect(palette.fondo_con_focus.b > 240); } test "contrastTextColor" { // Dark background should get light text const dark_bg = Color.rgb(30, 30, 30); const text_on_dark = contrastTextColor(dark_bg); try std.testing.expect(text_on_dark.r > 200); // Light text // Light background should get dark text const light_bg = Color.rgb(240, 240, 240); const text_on_light = contrastTextColor(light_bg); try std.testing.expect(text_on_light.r < 50); // Dark text // Mid-gray (128/255 = 0.502 > 0.5) gets dark text const mid_gray = Color.rgb(128, 128, 128); 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. /// /// Z-Design V5: Sincronía Atmosférica (2025-12-31) /// - SIN compensación perceptual /// - Blend fijo para TODOS los colores: /// - Fondo con focus: 20% base / 80% negro /// - Fondo sin focus: 12% base / 88% negro /// /// Los widgets usan bg_transition.current DIRECTAMENTE (mismo fondo que panel) /// con bisel de 1px para verse como "huecos" o "relieves" integrados. pub fn derivePanelFrameColors(base: Color) DerivedPanelColors { const black = Color.soft_black; // Blend fijo: 20% color con focus, 12% sin focus return .{ .focus_bg = base.blendTowards(black, 80), // 20% color .unfocus_bg = base.blendTowards(black, 88), // 12% color .border_focus = base, .border_unfocus = base.darken(30), .title_color = base.lightenHsl(20), }; } test "derivePanelFrameColors fixed blend" { // Z-Design V5: All colors use same fixed blend (20% focus, 12% unfocus) const blue = Color.rgb(0, 0, 255); const derived = derivePanelFrameColors(blue); // 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: 20% blue + 80% soft_black(17,17,20) // 0.20 * 255 + 0.80 * 20 = 51 + 16 = 67 try std.testing.expect(derived.focus_bg.b > 55); try std.testing.expect(derived.focus_bg.b < 85); } test "derivePanelFrameColors same blend for all colors" { // V5: No perceptual correction - same blend for red and blue const blue = Color.rgb(0, 0, 255); const red = Color.rgb(255, 0, 0); const blue_derived = derivePanelFrameColors(blue); const red_derived = derivePanelFrameColors(red); // Both should have ~20% of their primary color channel const blue_intensity = blue_derived.focus_bg.b; const red_intensity = red_derived.focus_bg.r; // Should be approximately equal (within tolerance for soft_black blend) const diff = if (blue_intensity > red_intensity) blue_intensity - red_intensity else red_intensity - blue_intensity; try std.testing.expect(diff < 10); // Close enough }