1mod 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#[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#[derive(Debug, Clone)]
90pub struct WithOpacity<T> {
91 color: T,
92 opacity: f32,
93}
94
95impl<T> WithOpacity<T> {
96 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub enum HexColorError {
137 InvalidLength,
139 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#[derive(Debug, Clone, Copy)]
161pub struct ResolvedColor {
162 pub red: f32,
164 pub green: f32,
166 pub blue: f32,
168 pub headroom: f32,
170 pub opacity: f32,
172}
173
174impl ResolvedColor {
175 #[must_use]
177 pub fn from_srgb(color: Srgb) -> Self {
178 color.resolve()
179 }
180
181 #[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 #[must_use]
193 pub fn to_oklch(&self) -> Oklch {
194 linear_srgb_to_oklch(self.red, self.green, self.blue)
195 }
196
197 #[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 #[must_use]
212 pub const fn with_opacity(mut self, opacity: f32) -> Self {
213 self.opacity = opacity;
214 self
215 }
216
217 #[must_use]
219 pub const fn with_headroom(mut self, headroom: f32) -> Self {
220 self.headroom = headroom;
221 self
222 }
223
224 #[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]
277pub enum Colorspace {
279 #[default]
281 Srgb,
282 P3,
284 Oklch,
286}
287
288impl_constant!(Color);
289
290impl Color {
291 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 #[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 #[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 #[must_use]
375 pub fn p3(red: f32, green: f32, blue: f32) -> Self {
376 Self::new(P3::new(red, green, blue))
377 }
378
379 #[must_use]
384 pub fn oklch(lightness: f32, chroma: f32, hue: f32) -> Self {
385 Self::new(Oklch::new(lightness, chroma, hue))
386 }
387
388 #[must_use]
392 pub fn srgb_hex(hex: &str) -> Self {
393 Self::new(Srgb::from_hex(hex))
394 }
395
396 pub fn try_srgb_hex(hex: &str) -> Result<Self, HexColorError> {
403 Srgb::try_from_hex(hex).map(Self::from)
404 }
405
406 #[must_use]
408 pub fn srgb_u32(rgb: u32) -> Self {
409 Self::from(Srgb::from_u32(rgb))
410 }
411
412 #[must_use]
414 pub fn transparent() -> Self {
415 Self::srgb(0, 0, 0).with_opacity(0.0)
416 }
417
418 #[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 #[must_use]
430 pub fn with_alpha(self, opacity: f32) -> Self {
431 self.with_opacity(opacity)
432 }
433
434 #[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 #[must_use]
446 pub fn lighten(self, amount: f32) -> Self {
447 self.adjust_lightness(clamp_unit(amount.max(0.0)))
448 }
449
450 #[must_use]
452 pub fn darken(self, amount: f32) -> Self {
453 self.adjust_lightness(-clamp_unit(amount.max(0.0)))
454 }
455
456 #[must_use]
458 pub fn saturate(self, amount: f32) -> Self {
459 self.adjust_chroma(1.0 + amount)
460 }
461
462 #[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 #[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 #[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 #[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
572fn 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
589fn 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
602fn 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}