waterui_color/
lib.rs

1//! # Color Module
2//!
3//! This module provides types for working with colors in different color spaces.
4//! It supports sRGB, Display P3, and OKLCH color spaces, with utilities for
5//! conversion and manipulation of color values.
6//!
7//! The OKLCH color space is perceptually uniform and is therefore recommended
8//! for most user interface work. By expressing colors in OKLCH you can adjust
9//! lightness, chroma, and hue independently while maintaining predictable
10//! contrast relationships across themes.
11//!
12//! The primary type is `Color`, which can represent colors in sRGB, Display P3,
13//! or OKLCH color spaces, with conversion methods from various tuple formats.
14
15mod oklch;
16pub use oklch::Oklch;
17mod p3;
18pub use p3::P3;
19mod srgb;
20use core::{
21    fmt::{self, Debug, Display},
22    ops::{Deref, DerefMut},
23};
24use pastey::paste;
25pub use srgb::Srgb;
26
27use nami::{Computed, Signal, SignalExt, impl_constant};
28
29use waterui_core::{
30    Environment,
31    layout::StretchAxis,
32    raw_view,
33    resolve::{self, AnyResolvable, Resolvable},
34};
35
36/// A color value that can be resolved in different color spaces.
37///
38/// This is the main color type that wraps a resolvable color value.
39/// Colors can be created from sRGB, P3, OKLCH, or custom color spaces.
40///
41/// # Layout Behavior
42///
43/// Color is a **greedy view** that expands to fill all available space in both
44/// directions. Use `.frame()` to constrain its size, or use it as a background.
45///
46/// # Examples
47///
48/// ```ignore
49/// // Fills entire container
50/// Color::blue()
51///
52/// // Constrained to specific size
53/// Color::red().frame().width(100.0).height(50.0)
54///
55/// // As a background
56/// text("Hello").background(Color::yellow())
57/// ```
58//
59// ═══════════════════════════════════════════════════════════════════════════
60// INTERNAL: Layout Contract for Backend Implementers
61// ═══════════════════════════════════════════════════════════════════════════
62//
63
64// With constraints: Returns the full proposal size
65// Without constraints: Returns a small fallback (e.g., 10pt × 10pt)
66//
67// ═══════════════════════════════════════════════════════════════════════════
68//
69#[derive(Debug, Clone)]
70pub struct Color(AnyResolvable<ResolvedColor>);
71
72impl Default for Color {
73    fn default() -> Self {
74        Self::srgb(0, 0, 0)
75    }
76}
77
78impl_constant!(ResolvedColor);
79
80impl<T: Resolvable<Resolved = ResolvedColor> + 'static> From<T> for Color {
81    fn from(value: T) -> Self {
82        Self::new(value)
83    }
84}
85
86/// Represents a color with an opacity/alpha value applied.
87///
88/// This wrapper type allows applying a specific opacity to any color type.
89#[derive(Debug, Clone)]
90pub struct WithOpacity<T> {
91    color: T,
92    opacity: f32,
93}
94
95impl<T> WithOpacity<T> {
96    /// Creates a new color with the specified opacity applied.
97    ///
98    /// # Arguments
99    /// * `color` - The base color
100    /// * `opacity` - Opacity value (0.0 = transparent, 1.0 = opaque)
101    #[must_use]
102    pub const fn new(color: T, opacity: f32) -> Self {
103        Self { color, opacity }
104    }
105}
106
107impl<T> Resolvable for WithOpacity<T>
108where
109    T: Resolvable<Resolved = ResolvedColor> + 'static,
110{
111    type Resolved = ResolvedColor;
112    fn resolve(&self, env: &Environment) -> impl Signal<Output = Self::Resolved> {
113        let opacity = self.opacity;
114        self.color.resolve(env).map(move |mut resolved| {
115            resolved.opacity = opacity;
116            resolved
117        })
118    }
119}
120
121impl<T> Deref for WithOpacity<T> {
122    type Target = T;
123    fn deref(&self) -> &Self::Target {
124        &self.color
125    }
126}
127
128impl<T> DerefMut for WithOpacity<T> {
129    fn deref_mut(&mut self) -> &mut Self::Target {
130        &mut self.color
131    }
132}
133
134/// Errors that can occur when parsing hexadecimal color strings.
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub enum HexColorError {
137    /// The provided string does not have the expected 6 hexadecimal digits.
138    InvalidLength,
139    /// A non-hexadecimal character was encountered at the provided index.
140    InvalidDigit(usize),
141}
142
143impl Display for HexColorError {
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        match self {
146            Self::InvalidLength => f.write_str("expected exactly 6 hexadecimal digits"),
147            Self::InvalidDigit(index) => {
148                write!(f, "invalid hexadecimal digit at byte index {index}")
149            }
150        }
151    }
152}
153
154mod parse;
155
156/// Represents a resolved color in linear sRGB color space with extended range support.
157///
158/// This struct stores color components in linear RGB values (0.0-1.0 for standard sRGB,
159/// values outside this range represent colors in extended color spaces like P3).
160#[derive(Debug, Clone, Copy)]
161pub struct ResolvedColor {
162    /// Red component in linear RGB (0.0-1.0 for sRGB, <0 or >1 for P3)
163    pub red: f32,
164    /// Green component in linear RGB (0.0-1.0 for sRGB, <0 or >1 for P3)
165    pub green: f32,
166    /// Blue component in linear RGB (0.0-1.0 for sRGB, <0 or >1 for P3)
167    pub blue: f32,
168    /// Extended color range headroom value (positive values allow for HDR colors)
169    pub headroom: f32,
170    /// Opacity/alpha channel (0.0 = transparent, 1.0 = opaque)
171    pub opacity: f32,
172}
173
174impl ResolvedColor {
175    /// Creates a resolved color from an sRGB color with default metadata.
176    #[must_use]
177    pub fn from_srgb(color: Srgb) -> Self {
178        color.resolve()
179    }
180
181    /// Converts this resolved color back into sRGB space (with gamma correction).
182    #[must_use]
183    pub fn to_srgb(&self) -> Srgb {
184        Srgb::new(
185            linear_to_srgb(self.red),
186            linear_to_srgb(self.green),
187            linear_to_srgb(self.blue),
188        )
189    }
190
191    /// Converts this resolved color into the OKLCH color space.
192    #[must_use]
193    pub fn to_oklch(&self) -> Oklch {
194        linear_srgb_to_oklch(self.red, self.green, self.blue)
195    }
196
197    /// Creates a resolved color from an OKLCH color with the provided metadata.
198    #[must_use]
199    pub fn from_oklch(oklch: Oklch, headroom: f32, opacity: f32) -> Self {
200        let [red, green, blue] = oklch_to_linear_srgb(oklch.lightness, oklch.chroma, oklch.hue);
201        Self {
202            red,
203            green,
204            blue,
205            headroom,
206            opacity,
207        }
208    }
209
210    /// Returns a copy of this color with the provided opacity.
211    #[must_use]
212    pub const fn with_opacity(mut self, opacity: f32) -> Self {
213        self.opacity = opacity;
214        self
215    }
216
217    /// Returns a copy of this color with the provided headroom value.
218    #[must_use]
219    pub const fn with_headroom(mut self, headroom: f32) -> Self {
220        self.headroom = headroom;
221        self
222    }
223
224    /// Linearly interpolates between this color and another color.
225    #[must_use]
226    pub fn lerp(self, other: Self, factor: f32) -> Self {
227        let t = factor.clamp(0.0, 1.0);
228        Self {
229            red: lerp(self.red, other.red, t),
230            green: lerp(self.green, other.green, t),
231            blue: lerp(self.blue, other.blue, t),
232            headroom: lerp(self.headroom, other.headroom, t),
233            opacity: lerp(self.opacity, other.opacity, t),
234        }
235    }
236}
237
238impl From<Srgb> for ResolvedColor {
239    fn from(value: Srgb) -> Self {
240        value.resolve()
241    }
242}
243
244impl From<P3> for ResolvedColor {
245    fn from(value: P3) -> Self {
246        let linear_p3 = [
247            srgb_to_linear(value.red),
248            srgb_to_linear(value.green),
249            srgb_to_linear(value.blue),
250        ];
251        let linear_srgb = p3_to_linear_srgb(linear_p3);
252        Self {
253            red: linear_srgb[0],
254            green: linear_srgb[1],
255            blue: linear_srgb[2],
256            headroom: 0.0,
257            opacity: 1.0,
258        }
259    }
260}
261
262impl From<Oklch> for ResolvedColor {
263    fn from(value: Oklch) -> Self {
264        let [red, green, blue] = oklch_to_linear_srgb(value.lightness, value.chroma, value.hue);
265        Self {
266            red,
267            green,
268            blue,
269            headroom: 0.0,
270            opacity: 1.0,
271        }
272    }
273}
274
275#[derive(Debug, Default, Clone, Copy, PartialEq, PartialOrd, Hash, Eq, Ord)]
276#[non_exhaustive]
277/// Represents the supported color spaces for color representation.
278pub enum Colorspace {
279    /// Standard RGB color space (sRGB) with values typically in the range 0-255.
280    #[default]
281    Srgb,
282    /// Display P3 color space with extended color gamut, using floating-point values 0.0-1.0.
283    P3,
284    /// Perceptually uniform OKLCH color space (recommended for UI work).
285    Oklch,
286}
287
288impl_constant!(Color);
289
290impl Color {
291    /// Creates a new color from a custom resolvable color value.
292    ///
293    /// # Arguments
294    /// * `custom` - A resolvable color implementation
295    pub fn new(custom: impl Resolvable<Resolved = ResolvedColor> + 'static) -> Self {
296        Self(AnyResolvable::new(custom))
297    }
298
299    fn map_resolved(self, func: impl Fn(ResolvedColor) -> ResolvedColor + Clone + 'static) -> Self {
300        Self::new(resolve::Map::new(self.0, func))
301    }
302
303    fn map_oklch(self, func: impl Fn(Oklch) -> Oklch + Clone + 'static) -> Self {
304        self.map_resolved(move |resolved| {
305            let base = resolved.to_oklch();
306            let mut mapped = func(base);
307
308            if !mapped.lightness.is_finite() {
309                mapped.lightness = base.lightness;
310            }
311            mapped.lightness = clamp_unit(mapped.lightness);
312
313            if !mapped.chroma.is_finite() {
314                mapped.chroma = base.chroma;
315            }
316            mapped.chroma = clamp_non_negative(mapped.chroma);
317
318            if !mapped.hue.is_finite() {
319                mapped.hue = base.hue;
320            }
321            mapped.hue = normalize_hue(mapped.hue);
322
323            ResolvedColor::from_oklch(mapped, resolved.headroom, resolved.opacity)
324        })
325    }
326
327    fn adjust_lightness(self, delta: f32) -> Self {
328        self.map_oklch(move |mut color| {
329            color.lightness = clamp_unit(color.lightness + delta);
330            color
331        })
332    }
333
334    fn adjust_chroma(self, scale: f32) -> Self {
335        self.map_oklch(move |mut color| {
336            let factor = scale.max(0.0);
337            color.chroma = clamp_non_negative(color.chroma * factor);
338            color
339        })
340    }
341
342    /// Creates an sRGB color from 8-bit color components.
343    ///
344    /// # Arguments
345    /// * `red` - Red component (0-255)
346    /// * `green` - Green component (0-255)
347    /// * `blue` - Blue component (0-255)
348    #[must_use]
349    pub fn srgb(red: u8, green: u8, blue: u8) -> Self {
350        Self::new(Srgb::new(
351            f32::from(red) / 255.0,
352            f32::from(green) / 255.0,
353            f32::from(blue) / 255.0,
354        ))
355    }
356
357    /// Creates an sRGB color from floating-point color components.
358    ///
359    /// # Arguments
360    /// * `red` - Red component (0.0 to 1.0)
361    /// * `green` - Green component (0.0 to 1.0)
362    /// * `blue` - Blue component (0.0 to 1.0)
363    #[must_use]
364    pub fn srgb_f32(red: f32, green: f32, blue: f32) -> Self {
365        Self::new(Srgb::new(red, green, blue))
366    }
367
368    /// Creates a P3 color from floating-point color components.
369    ///
370    /// # Arguments
371    /// * `red` - Red component (0.0 to 1.0)
372    /// * `green` - Green component (0.0 to 1.0)
373    /// * `blue` - Blue component (0.0 to 1.0)
374    #[must_use]
375    pub fn p3(red: f32, green: f32, blue: f32) -> Self {
376        Self::new(P3::new(red, green, blue))
377    }
378
379    /// Creates an OKLCH color from perceptual lightness, chroma, and hue values.
380    ///
381    /// OKLCH is recommended for authoring UI colors due to its perceptual
382    /// uniformity.
383    #[must_use]
384    pub fn oklch(lightness: f32, chroma: f32, hue: f32) -> Self {
385        Self::new(Oklch::new(lightness, chroma, hue))
386    }
387
388    /// Creates an sRGB color from a hexadecimal color string.
389    ///
390    /// Panics if the string does not contain exactly six hexadecimal digits.
391    #[must_use]
392    pub fn srgb_hex(hex: &str) -> Self {
393        Self::new(Srgb::from_hex(hex))
394    }
395
396    /// Tries to create an sRGB color from a hexadecimal color string.
397    ///
398    /// # Errors
399    ///
400    /// Returns [`HexColorError`] if the provided string is not a valid six-digit
401    /// hexadecimal color.
402    pub fn try_srgb_hex(hex: &str) -> Result<Self, HexColorError> {
403        Srgb::try_from_hex(hex).map(Self::from)
404    }
405
406    /// Creates an sRGB color from a packed 0xRRGGBB value.
407    #[must_use]
408    pub fn srgb_u32(rgb: u32) -> Self {
409        Self::from(Srgb::from_u32(rgb))
410    }
411
412    /// Returns a fully transparent color.
413    #[must_use]
414    pub fn transparent() -> Self {
415        Self::srgb(0, 0, 0).with_opacity(0.0)
416    }
417
418    /// Creates a new color with the specified opacity applied.
419    ///
420    /// # Arguments
421    /// * `opacity` - Opacity value (0.0 = transparent, 1.0 = opaque)
422    #[must_use]
423    pub fn with_opacity(self, opacity: f32) -> Self {
424        let clamped = clamp_unit(opacity);
425        self.map_resolved(move |resolved| resolved.with_opacity(clamped))
426    }
427
428    /// Alias for [`with_opacity`].
429    #[must_use]
430    pub fn with_alpha(self, opacity: f32) -> Self {
431        self.with_opacity(opacity)
432    }
433
434    /// Creates a new color with extended headroom for HDR content.
435    ///
436    /// # Arguments
437    /// * `headroom` - Additional headroom value for extended range
438    #[must_use]
439    pub fn with_headroom(self, headroom: f32) -> Self {
440        let clamped = clamp_non_negative(headroom);
441        self.map_resolved(move |resolved| resolved.with_headroom(clamped))
442    }
443
444    /// Lightens the color by increasing its OKLCH lightness component.
445    #[must_use]
446    pub fn lighten(self, amount: f32) -> Self {
447        self.adjust_lightness(clamp_unit(amount.max(0.0)))
448    }
449
450    /// Darkens the color by decreasing its OKLCH lightness component.
451    #[must_use]
452    pub fn darken(self, amount: f32) -> Self {
453        self.adjust_lightness(-clamp_unit(amount.max(0.0)))
454    }
455
456    /// Adjusts the color saturation by scaling the OKLCH chroma component.
457    #[must_use]
458    pub fn saturate(self, amount: f32) -> Self {
459        self.adjust_chroma(1.0 + amount)
460    }
461
462    /// Decreases the color saturation by scaling the OKLCH chroma component down.
463    #[must_use]
464    pub fn desaturate(self, amount: f32) -> Self {
465        self.adjust_chroma(1.0 - clamp_unit(amount.max(0.0)))
466    }
467
468    /// Rotates the color's hue by the provided number of degrees.
469    #[must_use]
470    pub fn hue_rotate(self, degrees: f32) -> Self {
471        self.map_oklch(move |mut color| {
472            color.hue = normalize_hue(color.hue + degrees);
473            color
474        })
475    }
476
477    /// Mixes this color with another color using linear interpolation.
478    #[must_use]
479    pub fn mix(self, other: impl Into<Self>, factor: f32) -> Self {
480        let other = other.into();
481        Self::new(Mix {
482            first: self.0,
483            second: other.0,
484            factor: clamp_unit(factor),
485        })
486    }
487
488    /// Resolves this color to a concrete color value in the given environment.
489    ///
490    /// # Arguments
491    /// * `env` - The environment to resolve the color in
492    #[must_use]
493    pub fn resolve(&self, env: &Environment) -> Computed<ResolvedColor> {
494        self.0.resolve(env)
495    }
496}
497
498#[derive(Debug, Clone)]
499struct Mix {
500    first: AnyResolvable<ResolvedColor>,
501    second: AnyResolvable<ResolvedColor>,
502    factor: f32,
503}
504
505impl Resolvable for Mix {
506    type Resolved = ResolvedColor;
507
508    fn resolve(&self, env: &Environment) -> impl Signal<Output = Self::Resolved> {
509        let factor = self.factor;
510        self.first
511            .resolve(env)
512            .zip(self.second.resolve(env))
513            .map(move |(a, b)| a.lerp(b, factor))
514    }
515}
516
517macro_rules! color_const {
518    ($name:ident,$doc:expr) => {
519        paste! {
520            #[derive(Debug, Clone, Copy)]
521            #[doc=$doc]
522            pub struct $name;
523
524            impl Resolvable for $name {
525                type Resolved = ResolvedColor;
526                fn resolve(&self, env: &Environment) -> impl Signal<Output = Self::Resolved> {
527                    let default_color = Srgb::[<$name:snake:upper>] ;
528                    env.query::<Self, ResolvedColor>()
529                        .copied()
530                        .unwrap_or_else(|| default_color.resolve())
531                }
532            }
533
534            impl Color{
535                #[doc=$doc]
536                pub fn [<$name:snake>]()->Self{
537                    Self::new($name)
538                }
539            }
540
541            impl waterui_core::View for $name {
542                fn body(self, _env: &waterui_core::Environment) -> impl waterui_core::View {
543                    Color::new(self)
544                }
545            }
546        }
547    };
548}
549
550color_const!(Red, "Red color.");
551color_const!(Pink, "Pink color.");
552color_const!(Purple, "Purple color.");
553color_const!(DeepPurple, "Deep purple color.");
554color_const!(Indigo, "Indigo color.");
555color_const!(Blue, "Blue color.");
556color_const!(LightBlue, "Light blue color.");
557color_const!(Cyan, "Cyan color.");
558color_const!(Teal, "Teal color.");
559color_const!(Green, "Green color.");
560color_const!(LightGreen, "Light green color.");
561color_const!(Lime, "Lime color.");
562color_const!(Yellow, "Yellow color.");
563color_const!(Amber, "Amber color.");
564color_const!(Orange, "Orange color.");
565color_const!(DeepOrange, "Deep orange color.");
566color_const!(Brown, "Brown color.");
567
568color_const!(Grey, "Grey color.");
569color_const!(BlueGrey, "Blue grey color.");
570raw_view!(Color, StretchAxis::Both);
571
572// https://www.w3.org/TR/css-color-4/#color-conversion-code
573fn srgb_to_linear(c: f32) -> f32 {
574    if c <= 0.04045 {
575        c / 12.92
576    } else {
577        ((c + 0.055) / 1.055).powf(2.4)
578    }
579}
580
581fn linear_to_srgb(c: f32) -> f32 {
582    if c <= 0.003_130_8 {
583        c * 12.92
584    } else {
585        1.055_f32.mul_add(c.powf(1.0 / 2.4), -0.055)
586    }
587}
588
589// Conversion matrix from P3 to sRGB
590// https://www.w3.org/TR/css-color-4/#color-conversion-code
591fn p3_to_linear_srgb(p3: [f32; 3]) -> [f32; 3] {
592    [
593        1.224_940_1_f32.mul_add(p3[0], -0.224_940_1 * p3[1]),
594        (-0.042_030_1_f32).mul_add(p3[0], 1.042_030_1 * p3[1]),
595        (-0.019_721_1_f32).mul_add(
596            p3[0],
597            (-0.078_636_1_f32).mul_add(p3[1], 1.098_357_2 * p3[2]),
598        ),
599    ]
600}
601
602// Conversion matrix from sRGB to P3 (inverse of p3_to_linear_srgb)
603// https://www.w3.org/TR/css-color-4/#color-conversion-code
604fn linear_srgb_to_p3(srgb: [f32; 3]) -> [f32; 3] {
605    [
606        0.822_461_9_f32.mul_add(srgb[0], 0.177_538_1 * srgb[1]),
607        0.033_194_2_f32.mul_add(srgb[0], 0.966_805_8 * srgb[1]),
608        0.017_082_6_f32.mul_add(
609            srgb[0],
610            0.072_397_4_f32.mul_add(srgb[1], 0.910_519_9 * srgb[2]),
611        ),
612    ]
613}
614
615#[allow(
616    clippy::excessive_precision,
617    clippy::many_single_char_names,
618    clippy::suboptimal_flops
619)]
620fn linear_srgb_to_oklab(red: f32, green: f32, blue: f32) -> [f32; 3] {
621    let l = 0.412_221_470_8_f32.mul_add(red, 0.536_332_536_3 * green) + 0.051_445_992_9 * blue;
622    let m = 0.211_903_498_2_f32.mul_add(red, 0.680_699_545_1 * green) + 0.107_396_956_6 * blue;
623    let s = 0.088_302_461_9_f32.mul_add(red, 0.281_718_837_6 * green) + 0.629_978_700_5 * blue;
624
625    let l_ = l.cbrt();
626    let m_ = m.cbrt();
627    let s_ = s.cbrt();
628
629    [
630        0.210_454_255_3_f32.mul_add(l_, 0.793_617_785 * m_) - 0.004_072_046_8 * s_,
631        1.977_998_495_1_f32.mul_add(l_, (-2.428_592_205_f32).mul_add(m_, 0.450_593_709_9 * s_)),
632        0.025_904_037_1_f32.mul_add(l_, 0.782_771_766_2 * m_) - 0.808_675_766 * s_,
633    ]
634}
635
636#[allow(
637    clippy::excessive_precision,
638    clippy::many_single_char_names,
639    clippy::suboptimal_flops
640)]
641fn linear_srgb_to_oklch(red: f32, green: f32, blue: f32) -> Oklch {
642    let [lightness, a, b] = linear_srgb_to_oklab(red, green, blue);
643    let chroma = a.hypot(b);
644    let mut hue = b.atan2(a).to_degrees();
645    if hue < 0.0 {
646        hue += 360.0;
647    }
648
649    Oklch::new(lightness, chroma, hue)
650}
651
652fn lerp(a: f32, b: f32, t: f32) -> f32 {
653    (b - a).mul_add(t, a)
654}
655
656const fn clamp_unit(value: f32) -> f32 {
657    value.clamp(0.0, 1.0)
658}
659
660const fn clamp_non_negative(value: f32) -> f32 {
661    value.max(0.0)
662}
663
664fn normalize_hue(mut hue: f32) -> f32 {
665    hue %= 360.0;
666    if hue < 0.0 {
667        hue += 360.0;
668    }
669    hue
670}
671
672#[allow(
673    clippy::excessive_precision,
674    clippy::many_single_char_names,
675    clippy::suboptimal_flops
676)]
677fn oklch_to_linear_srgb(lightness: f32, chroma: f32, hue_degrees: f32) -> [f32; 3] {
678    let hue_radians = hue_degrees.to_radians();
679    let (sin_hue, cos_hue) = hue_radians.sin_cos();
680    let a = chroma * cos_hue;
681    let b = chroma * sin_hue;
682
683    let l_ = lightness + 0.396_337_777_4_f32.mul_add(a, 0.215_803_757_3 * b);
684    let m_ = lightness - 0.105_561_345_8_f32.mul_add(a, 0.063_854_172_8 * b);
685    let s_ = lightness - 0.089_484_177_5_f32.mul_add(a, 1.291_485_548 * b);
686
687    let l = l_.powi(3);
688    let m = m_.powi(3);
689    let s = s_.powi(3);
690
691    [
692        4.076_741_662_1_f32.mul_add(l, (-3.307_711_591_3_f32).mul_add(m, 0.230_969_929_2 * s)),
693        (-1.268_438_004_6_f32).mul_add(l, 2.609_757_401_1_f32.mul_add(m, -0.341_319_396_5 * s)),
694        (-0.004_196_086_3_f32).mul_add(l, (-0.703_418_614_7_f32).mul_add(m, 1.707_614_701 * s)),
695    ]
696}
697
698#[cfg(test)]
699mod tests {
700    use super::*;
701
702    const EPSILON: f32 = 1e-5;
703    const EPSILON_WIDE: f32 = 1e-3;
704
705    fn approx_eq(a: f32, b: f32, tol: f32) -> bool {
706        (a - b).abs() <= tol
707    }
708
709    #[test]
710    fn srgb_linear_roundtrip() {
711        let samples = [-0.25_f32, 0.0, 0.001, 0.02, 0.25, 0.5, 1.0, 1.25];
712
713        for value in samples {
714            let linear = srgb_to_linear(value);
715            let recon = linear_to_srgb(linear);
716            assert!(
717                approx_eq(value, recon, EPSILON),
718                "value {value} recon {recon}"
719            );
720        }
721    }
722
723    #[test]
724    fn srgb_to_p3_and_back() {
725        let samples = [
726            Srgb::new(0.0, 0.0, 0.0),
727            Srgb::new(0.25, 0.5, 0.75),
728            Srgb::new(0.9, 0.2, 0.1),
729            Srgb::new(0.6, 0.8, 0.1),
730        ];
731
732        for color in samples {
733            let roundtrip = color.to_p3().to_srgb();
734            assert!(approx_eq(color.red, roundtrip.red, EPSILON_WIDE));
735            assert!(approx_eq(color.green, roundtrip.green, EPSILON_WIDE));
736            assert!(approx_eq(color.blue, roundtrip.blue, EPSILON_WIDE));
737        }
738    }
739
740    #[test]
741    fn p3_to_srgb_and_back() {
742        let samples = [
743            P3::new(0.0, 0.0, 0.0),
744            P3::new(0.3, 0.5, 0.7),
745            P3::new(1.0, 0.0, 0.0),
746            P3::new(0.2, 0.9, 0.3),
747        ];
748
749        for color in samples {
750            let roundtrip = color.to_srgb().to_p3();
751            assert!(approx_eq(color.red, roundtrip.red, EPSILON_WIDE));
752            assert!(approx_eq(color.green, roundtrip.green, EPSILON_WIDE));
753            assert!(approx_eq(color.blue, roundtrip.blue, EPSILON_WIDE));
754        }
755    }
756
757    #[test]
758    fn srgb_resolve_matches_linear_components() {
759        let color = Srgb::from_hex("#4CAF50");
760        let resolved = color.resolve();
761
762        assert!(approx_eq(resolved.red, srgb_to_linear(color.red), EPSILON));
763        assert!(approx_eq(
764            resolved.green,
765            srgb_to_linear(color.green),
766            EPSILON
767        ));
768        assert!(approx_eq(
769            resolved.blue,
770            srgb_to_linear(color.blue),
771            EPSILON
772        ));
773        assert!(approx_eq(resolved.headroom, 0.0, EPSILON));
774        assert!(approx_eq(resolved.opacity, 1.0, EPSILON));
775    }
776
777    #[test]
778    fn color_with_opacity_and_headroom_resolves() {
779        let env = Environment::new();
780        let base = Color::srgb(32, 64, 128)
781            .with_opacity(0.4)
782            .with_headroom(0.6);
783
784        let resolved = base.resolve(&env).get();
785
786        assert!(approx_eq(resolved.opacity, 0.4, EPSILON));
787        assert!(approx_eq(resolved.headroom, 0.6, EPSILON));
788    }
789
790    #[test]
791    fn p3_resolution_matches_conversion() {
792        let env = Environment::new();
793        let color = Color::p3(0.3, 0.6, 0.9);
794        let resolved = color.resolve(&env).get();
795        let srgb = P3::new(0.3, 0.6, 0.9).to_srgb().resolve();
796
797        assert!(approx_eq(resolved.red, srgb.red, EPSILON_WIDE));
798        assert!(approx_eq(resolved.green, srgb.green, EPSILON_WIDE));
799        assert!(approx_eq(resolved.blue, srgb.blue, EPSILON_WIDE));
800    }
801
802    #[test]
803    fn oklch_resolves_consistently() {
804        let env = Environment::new();
805        let samples = [
806            Oklch::new(0.5, 0.1, 45.0),
807            Oklch::new(0.75, 0.2, 200.0),
808            Oklch::new(0.65, 0.05, 320.0),
809        ];
810
811        for sample in samples {
812            let resolved_oklch = Color::from(sample).resolve(&env).get();
813            let resolved_srgb = sample.to_srgb().resolve();
814
815            assert!(approx_eq(
816                resolved_oklch.red,
817                resolved_srgb.red,
818                EPSILON_WIDE
819            ));
820            assert!(approx_eq(
821                resolved_oklch.green,
822                resolved_srgb.green,
823                EPSILON_WIDE
824            ));
825            assert!(approx_eq(
826                resolved_oklch.blue,
827                resolved_srgb.blue,
828                EPSILON_WIDE
829            ));
830        }
831    }
832
833    #[test]
834    fn hex_parsing_accepts_prefixes() {
835        let direct = Srgb::from_hex("#1A2B3C");
836        let prefixed = Srgb::from_hex("0x1A2B3C");
837        let bare = Srgb::from_hex("1A2B3C");
838
839        assert!(approx_eq(direct.red, prefixed.red, EPSILON));
840        assert!(approx_eq(direct.green, prefixed.green, EPSILON));
841        assert!(approx_eq(direct.blue, prefixed.blue, EPSILON));
842
843        assert!(approx_eq(direct.red, bare.red, EPSILON));
844        assert!(approx_eq(direct.green, bare.green, EPSILON));
845        assert!(approx_eq(direct.blue, bare.blue, EPSILON));
846    }
847
848    #[test]
849    fn try_hex_reports_errors() {
850        assert!(matches!(
851            Srgb::try_from_hex("#GGGGGG"),
852            Err(HexColorError::InvalidDigit(1))
853        ));
854
855        assert!(matches!(
856            Srgb::try_from_hex("#123"),
857            Err(HexColorError::InvalidLength)
858        ));
859    }
860
861    #[test]
862    fn transparent_color_has_zero_opacity() {
863        let env = Environment::new();
864        let transparent = Color::transparent().resolve(&env).get();
865        assert!(approx_eq(transparent.opacity, 0.0, EPSILON));
866    }
867
868    #[test]
869    fn lighten_and_darken_adjust_lightness() {
870        let env = Environment::new();
871        let base = Color::oklch(0.4, 0.12, 90.0);
872        let base_lch = base.resolve(&env).get().to_oklch();
873        let lighter = base.clone().lighten(0.2).resolve(&env).get().to_oklch();
874        let darker = base.darken(0.2).resolve(&env).get().to_oklch();
875
876        assert!(lighter.lightness > base_lch.lightness);
877        assert!(darker.lightness < base_lch.lightness);
878    }
879
880    #[test]
881    fn saturate_and_desaturate_adjust_chroma() {
882        let env = Environment::new();
883        let base = Color::oklch(0.5, 0.2, 45.0);
884        let base_chroma = base.resolve(&env).get().to_oklch().chroma;
885        let saturated = base.clone().saturate(0.5).resolve(&env).get().to_oklch();
886        let desaturated = base.desaturate(0.5).resolve(&env).get().to_oklch();
887
888        assert!(saturated.chroma > base_chroma);
889        assert!(desaturated.chroma < base_chroma);
890    }
891
892    #[test]
893    fn hue_rotation_wraps_within_range() {
894        let env = Environment::new();
895        let rotated = Color::oklch(0.6, 0.18, 350.0)
896            .hue_rotate(40.0)
897            .resolve(&env)
898            .get()
899            .to_oklch();
900
901        assert!(approx_eq(rotated.hue, 30.0, EPSILON_WIDE));
902    }
903
904    #[test]
905    fn color_mixing_linearly_interpolates() {
906        let env = Environment::new();
907        let black = Color::srgb(0, 0, 0);
908        let white = Color::srgb(255, 255, 255);
909        let mid = black.mix(white, 0.5).resolve(&env).get();
910
911        assert!(approx_eq(mid.red, 0.5, EPSILON));
912        assert!(approx_eq(mid.green, 0.5, EPSILON));
913        assert!(approx_eq(mid.blue, 0.5, EPSILON));
914    }
915}