feat(style): Motor HSL para derivación de colores
Añadido espacio de color HSL para transformaciones perceptualmente uniformes: - Struct Hsl con campos h (0-360), s (0-1), l (0-1) - Funciones rgbToHsl() y hslToRgb() con algoritmo estándar - Métodos Hsl: lighten, darken, saturate, desaturate, rotate - Métodos Color: toHsl, lightenHsl, darkenHsl, saturate, desaturate - Color.rotateHue, Color.complementary para teoría del color - 15 tests unitarios para validar conversiones Esto permite derivar paletas armónicas desde un color base (Z-Design). 🤖 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
b2a4081493
commit
c330492022
1 changed files with 293 additions and 0 deletions
|
|
@ -134,6 +134,46 @@ pub const Color = struct {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 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
|
// Predefined colors
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
@ -179,6 +219,148 @@ pub const Color = struct {
|
||||||
pub const soft_white = Color.rgb(250, 250, 252); // Not pure white
|
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
|
/// Visual style for widgets
|
||||||
pub const Style = struct {
|
pub const Style = struct {
|
||||||
foreground: Color = Color.foreground,
|
foreground: Color = Color.foreground,
|
||||||
|
|
@ -664,6 +846,117 @@ test "ThemeManager setTheme" {
|
||||||
try std.testing.expect(std.mem.eql(u8, tm.current.name, "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
|
// Z-DESIGN: Panel Color Derivation System
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue