refactor(style): Dividir style.zig (1437 LOC) en módulos
style.zig monolítico → módulos especializados: - color.zig (~350 LOC): Color, Hsl, conversiones RGB/HSL - theme.zig (~330 LOC): Theme (5 temas), ThemeManager - panel_colors.zig (~300 LOC): Z-Design panel color derivation - style.zig (~140 LOC): Re-exports + RenderMode + Style struct Total: ~1120 LOC (vs 1437 original, -22% por eliminación de duplicados) Mantenibilidad mejorada: cada módulo tiene responsabilidad clara. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
31362b6d48
commit
4b7069b076
4 changed files with 1457 additions and 1355 deletions
540
src/core/color.zig
Normal file
540
src/core/color.zig
Normal file
|
|
@ -0,0 +1,540 @@
|
|||
//! Color - RGBA and HSL color representations
|
||||
//!
|
||||
//! Core color types for the GUI system:
|
||||
//! - Color: RGBA color with blending, transformations
|
||||
//! - Hsl: HSL color space for perceptually uniform operations
|
||||
//!
|
||||
//! Part of zcatgui style system (refactored from style.zig)
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
// =============================================================================
|
||||
// RGBA Color
|
||||
// =============================================================================
|
||||
|
||||
/// 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
|
||||
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_val: u8 = @intFromFloat(l * 255.0);
|
||||
return Color.rgb(gray_val, gray_val, gray_val);
|
||||
}
|
||||
|
||||
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_val = hueToRgb(p, q, hue_norm + 1.0 / 3.0);
|
||||
const g_val = hueToRgb(p, q, hue_norm);
|
||||
const b_val = hueToRgb(p, q, hue_norm - 1.0 / 3.0);
|
||||
|
||||
return Color.rgb(
|
||||
@intFromFloat(r_val * 255.0),
|
||||
@intFromFloat(g_val * 255.0),
|
||||
@intFromFloat(b_val * 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;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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 = Color.white;
|
||||
const darkened = white_color.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 "blendTowards basic" {
|
||||
const red_color = Color.rgb(255, 0, 0);
|
||||
const white_color = Color.rgb(255, 255, 255);
|
||||
|
||||
// 50% blend towards white
|
||||
const result = red_color.blendTowards(white_color, 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 "perceptualLuminance" {
|
||||
// Pure red: 0.2126
|
||||
const red_color = Color.rgb(255, 0, 0);
|
||||
try std.testing.expect(red_color.perceptualLuminance() > 0.2);
|
||||
try std.testing.expect(red_color.perceptualLuminance() < 0.22);
|
||||
|
||||
// Pure green: 0.7152
|
||||
const green_color = Color.rgb(0, 255, 0);
|
||||
try std.testing.expect(green_color.perceptualLuminance() > 0.71);
|
||||
try std.testing.expect(green_color.perceptualLuminance() < 0.72);
|
||||
|
||||
// Pure blue: 0.0722
|
||||
const blue_color = Color.rgb(0, 0, 255);
|
||||
try std.testing.expect(blue_color.perceptualLuminance() > 0.07);
|
||||
try std.testing.expect(blue_color.perceptualLuminance() < 0.08);
|
||||
|
||||
// White: 1.0
|
||||
const white_color = Color.rgb(255, 255, 255);
|
||||
try std.testing.expect(white_color.perceptualLuminance() > 0.99);
|
||||
|
||||
// Black: 0.0
|
||||
const black_color = Color.rgb(0, 0, 0);
|
||||
try std.testing.expect(black_color.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);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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 = Color.rgb(255, 0, 0);
|
||||
const desaturated = red_color.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 = Color.rgb(255, 0, 0); // Hue = 0
|
||||
const green_color = red_color.rotateHue(120); // Hue = 120 = green
|
||||
// Should be mostly green
|
||||
try std.testing.expect(green_color.g > green_color.r);
|
||||
try std.testing.expect(green_color.g > green_color.b);
|
||||
}
|
||||
|
||||
test "Color.complementary" {
|
||||
const red_color = Color.rgb(255, 0, 0);
|
||||
const cyan_color = red_color.complementary(); // 180 degrees = cyan
|
||||
// Cyan has high G and B, low R
|
||||
try std.testing.expect(cyan_color.g > 200);
|
||||
try std.testing.expect(cyan_color.b > 200);
|
||||
try std.testing.expect(cyan_color.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);
|
||||
}
|
||||
416
src/core/panel_colors.zig
Normal file
416
src/core/panel_colors.zig
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
//! Panel Colors - Z-Design Color Derivation System
|
||||
//!
|
||||
//! Derives complete panel color palettes from a single base color.
|
||||
//! Inspired by Laravel Forge/Nova/Vapor aesthetic.
|
||||
//!
|
||||
//! 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.
|
||||
//!
|
||||
//! Part of zcatgui style system (refactored from style.zig)
|
||||
|
||||
const std = @import("std");
|
||||
const Color = @import("color.zig").Color;
|
||||
|
||||
// =============================================================================
|
||||
// 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: 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;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Z-DESIGN: Panel Color Derivation System
|
||||
// =============================================================================
|
||||
|
||||
/// 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 V6: Más color visible para mejor identificación (2026-01-01)
|
||||
// Focus: 30% color, Unfocus: 20% color
|
||||
const focus_blend: u8 = 70; // 70% hacia negro = 30% color
|
||||
const unfocus_blend: u8 = 80; // 80% hacia negro = 20% color
|
||||
|
||||
return .{
|
||||
// Backgrounds: Z-Design V6 - blend fijo con más color
|
||||
.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
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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
|
||||
///
|
||||
/// Títulos Adaptativos (2025-12-31):
|
||||
/// - El title_color se calcula para máximo contraste
|
||||
/// - Fondo oscuro → blanco teñido (lightenHsl 90)
|
||||
/// - Fondo claro → negro teñido (darkenHsl 90)
|
||||
///
|
||||
/// 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;
|
||||
|
||||
// Z-Design V6: 30% color con focus, 20% sin focus (2026-01-01)
|
||||
const focus_bg = base.blendTowards(black, 70); // 30% color
|
||||
|
||||
// Título: BLANCO con tinte sutil del color base para identidad
|
||||
// El contraste viene del blanco, el tinte da coherencia visual
|
||||
// Fondos oscuros → blanco teñido, claros → negro teñido
|
||||
const bg_luminance = focus_bg.perceptualLuminance();
|
||||
const title_color = if (bg_luminance < 0.5)
|
||||
Color.soft_white.blendTowards(base, 15) // 85% blanco + 15% tinte del panel
|
||||
else
|
||||
Color.soft_black.blendTowards(base, 15); // 85% negro + 15% tinte
|
||||
|
||||
return .{
|
||||
.focus_bg = focus_bg,
|
||||
.unfocus_bg = base.blendTowards(black, 80), // 20% color
|
||||
.border_focus = base,
|
||||
.border_unfocus = base.darken(30),
|
||||
.title_color = title_color,
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
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 "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)
|
||||
}
|
||||
|
||||
test "derivePanelFrameColors fixed blend" {
|
||||
// Z-Design V6: All colors use same fixed blend (30% focus, 20% 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: 30% blue + 70% soft_black(17,17,20)
|
||||
// 0.30 * 255 + 0.70 * 20 = 76.5 + 14 = ~90
|
||||
try std.testing.expect(derived.focus_bg.b > 75);
|
||||
try std.testing.expect(derived.focus_bg.b < 105);
|
||||
}
|
||||
|
||||
test "derivePanelFrameColors same blend for all colors" {
|
||||
// V6: 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 ~30% 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
|
||||
}
|
||||
1413
src/core/style.zig
1413
src/core/style.zig
File diff suppressed because it is too large
Load diff
443
src/core/theme.zig
Normal file
443
src/core/theme.zig
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
//! Theme - Application-wide color themes
|
||||
//!
|
||||
//! Predefined themes for consistent UI styling:
|
||||
//! - dark (default)
|
||||
//! - light
|
||||
//! - high_contrast_dark
|
||||
//! - solarized_dark
|
||||
//! - solarized_light
|
||||
//!
|
||||
//! Part of zcatgui style system (refactored from style.zig)
|
||||
|
||||
const std = @import("std");
|
||||
const Color = @import("color.zig").Color;
|
||||
|
||||
// =============================================================================
|
||||
// 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 "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"));
|
||||
}
|
||||
Loading…
Reference in a new issue