Skip to main content

flowsurface_data/config/
theme.rs

1/// Most of the stuff here is exact copy of some of the code from
2/// <https://github.com/iced-rs/iced/blob/master/core/src/theme/palette.rs> &
3/// <https://github.com/squidowl/halloy/blob/main/data/src/appearance/theme.rs>
4/// All credits and thanks to the authors of [`Halloy`] and [`iced_core`]
5use iced_core::{
6    Color,
7    theme::palette::Extended,
8    theme::{Custom, Palette},
9};
10use palette::{
11    Clamp, FromColor, Hsva, IsWithinBounds, OklabHue, Oklch, Srgb,
12    convert::FromColorUnclamped,
13    rgb::{Rgb, Rgba},
14};
15use serde::{Deserialize, Serialize};
16use std::cmp::Ordering;
17
18/// Amount of accent colors, one per [`ThemeAccent`].
19pub const ACCENT_COUNT: usize = ThemeAccent::ALL.len();
20/// Every accent but the last one takes a hue; the last one is the endpoint of the
21/// accent lightness scale.
22const CHROMATIC_ACCENT_COUNT: usize = ACCENT_COUNT - 1;
23/// Smallest hue distance two accents of one theme may have.
24const MIN_ACCENT_HUE_SEPARATION: f32 = 42.0;
25/// Smallest hue distance an accent may have to one of the theme's signal hues.
26const MIN_SIGNAL_HUE_SEPARATION: f32 = 12.0;
27/// Chroma below which a hue is not recognizable, so the color cannot claim one.
28const MIN_HUE_REFERENCE_CHROMA: f32 = 0.04;
29/// Largest hue distance possible, half of the wheel.
30const MAX_HUE_DISTANCE: f32 = 180.0;
31/// Placement resolution on the hue wheel.
32const HUE_CANDIDATE_STEP: f32 = 0.5;
33const HUE_CANDIDATE_COUNT: usize = (360.0 / HUE_CANDIDATE_STEP) as usize;
34const TARGET_ACCENT_CHROMA: f32 = 0.14;
35const ENDPOINT_ACCENT_CHROMA: f32 = 0.02;
36
37#[derive(Debug, Clone)]
38pub struct Theme(pub iced_core::Theme);
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41pub enum ThemeAccent {
42    One,
43    Two,
44    Three,
45    Four,
46    Five,
47    Six,
48    Seven,
49    Eight,
50}
51
52impl ThemeAccent {
53    pub const ALL: [Self; 8] = [
54        Self::One,
55        Self::Two,
56        Self::Three,
57        Self::Four,
58        Self::Five,
59        Self::Six,
60        Self::Seven,
61        Self::Eight,
62    ];
63
64    pub const fn index(self) -> usize {
65        match self {
66            Self::One => 0,
67            Self::Two => 1,
68            Self::Three => 2,
69            Self::Four => 3,
70            Self::Five => 4,
71            Self::Six => 5,
72            Self::Seven => 6,
73            Self::Eight => 7,
74        }
75    }
76}
77
78impl std::fmt::Display for ThemeAccent {
79    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        write!(formatter, "Accent {}", self.index() + 1)
81    }
82}
83
84/// Derives the accent palette of `palette`.
85///
86/// Every chromatic accent has the same lightness, so no series color is louder than
87/// another and they are told apart by hue alone. The last accent is the endpoint of
88/// the lightness scale instead of a hue.
89pub fn generated_theme_accents(palette: &Extended) -> [Color; ACCENT_COUNT] {
90    let lightness = if palette.is_dark { 0.7 } else { 0.5 };
91    let hues = AccentHues::new(palette).place_all();
92
93    std::array::from_fn(|index| {
94        hues.get(index).map_or_else(
95            || endpoint_accent(palette),
96            |hue| accent_color(*hue, lightness, TARGET_ACCENT_CHROMA),
97        )
98    })
99}
100
101/// Places the chromatic accents on the hue wheel.
102///
103/// Every accent keeps [`MIN_ACCENT_HUE_SEPARATION`] from the accents placed before
104/// it, and prefers positions that keep [`MIN_SIGNAL_HUE_SEPARATION`] from the theme's
105/// signal hues, so a series color never reads as a candle or status color.
106struct AccentHues {
107    signal_hues: Vec<f32>,
108    identity_hue: Option<f32>,
109    placed: Vec<f32>,
110}
111
112impl AccentHues {
113    fn new(palette: &Extended) -> Self {
114        Self {
115            signal_hues: [
116                palette.primary.base.color,
117                palette.success.base.color,
118                palette.danger.base.color,
119                palette.warning.base.color,
120            ]
121            .into_iter()
122            .filter_map(chromatic_hue)
123            .collect(),
124            identity_hue: chromatic_hue(palette.primary.base.color),
125            placed: Vec::with_capacity(CHROMATIC_ACCENT_COUNT),
126        }
127    }
128
129    fn place_all(mut self) -> Vec<f32> {
130        self.placed.push(self.initial_hue());
131
132        while self.placed.len() < CHROMATIC_ACCENT_COUNT {
133            self.placed.push(self.next_hue());
134        }
135
136        self.placed
137    }
138
139    /// The theme's own hue, or the hue furthest from every signal hue when the theme
140    /// has no chromatic primary to derive the palette from.
141    fn initial_hue(&self) -> f32 {
142        match self.identity_hue {
143            Some(identity_hue) => self.best_hue(|hue| self.identity_rank(hue, identity_hue)),
144            None => self.best_hue(|hue| self.accent_rank(hue)),
145        }
146    }
147
148    /// The hue in the widest gap left on the wheel.
149    fn next_hue(&self) -> f32 {
150        self.best_hue(|hue| self.accent_rank(hue))
151    }
152
153    /// Ranks a position for a new accent: inside both separation budgets first, then
154    /// the widest gap between the placed accents, then the furthest from the signal
155    /// hues. Accent separation thus outranks signal clearance where the wheel is too
156    /// crowded for both.
157    fn accent_rank(&self, hue: f32) -> (bool, f32, f32) {
158        let accent_clearance = self.accent_clearance(hue);
159        let signal_clearance = self.signal_clearance(hue);
160
161        (
162            accent_clearance >= MIN_ACCENT_HUE_SEPARATION
163                && signal_clearance >= MIN_SIGNAL_HUE_SEPARATION,
164            accent_clearance,
165            signal_clearance,
166        )
167    }
168
169    /// Ranks a position for the first accent of a theme that has a hue of its own:
170    /// inside both separation budgets first, then the closest to that hue.
171    fn identity_rank(&self, hue: f32, identity_hue: f32) -> (bool, f32, f32) {
172        let (within_budget, _, signal_clearance) = self.accent_rank(hue);
173
174        (
175            within_budget,
176            -hue_distance(hue, identity_hue),
177            signal_clearance,
178        )
179    }
180
181    /// The hue with the highest rank, ties going to the lower hue.
182    fn best_hue(&self, rank: impl Fn(f32) -> (bool, f32, f32)) -> f32 {
183        let mut best_hue = 0.0;
184        let mut best_rank = (false, f32::NEG_INFINITY, f32::NEG_INFINITY);
185
186        for hue in candidate_hues() {
187            let candidate = rank(hue);
188            if candidate.partial_cmp(&best_rank) == Some(Ordering::Greater) {
189                best_rank = candidate;
190                best_hue = hue;
191            }
192        }
193
194        best_hue
195    }
196
197    /// Distance from `hue` to the nearest placed accent.
198    fn accent_clearance(&self, hue: f32) -> f32 {
199        self.placed
200            .iter()
201            .map(|placed| hue_distance(hue, *placed))
202            .fold(MAX_HUE_DISTANCE, f32::min)
203    }
204
205    /// Distance from `hue` to the nearest signal hue.
206    fn signal_clearance(&self, hue: f32) -> f32 {
207        self.signal_hues
208            .iter()
209            .map(|signal_hue| hue_distance(hue, *signal_hue))
210            .fold(MAX_HUE_DISTANCE, f32::min)
211    }
212}
213
214/// The hue of `color`, when it has enough chroma for a hue to be recognizable.
215fn chromatic_hue(color: Color) -> Option<f32> {
216    let oklch = to_oklch(color);
217
218    (oklch.chroma >= MIN_HUE_REFERENCE_CHROMA).then(|| oklch.hue.into_positive_degrees())
219}
220
221/// The endpoint of the accent lightness scale: near white on dark themes and near
222/// black on light themes, so the accent stays usable on any palette. It has no place
223/// in the hue placement, because at this chroma its hue cannot be told apart.
224fn endpoint_accent(palette: &Extended) -> Color {
225    let lightness = if palette.is_dark { 0.96 } else { 0.04 };
226
227    match chromatic_hue(palette.primary.base.color) {
228        // A trace of the theme's hue keeps the endpoint from looking foreign.
229        Some(hue) => accent_color(hue, lightness, ENDPOINT_ACCENT_CHROMA),
230        None => accent_color(0.0, lightness, 0.0),
231    }
232}
233
234fn accent_color(hue: f32, lightness: f32, chroma: f32) -> Color {
235    let mut oklch = Oklch::from_color(Srgb::new(0.0, 0.0, 0.0));
236    oklch.l = lightness;
237    oklch.chroma = chroma;
238    oklch.hue = OklabHue::from_degrees(hue);
239
240    let rgb = gamut_mapped_srgb(oklch);
241    Color {
242        r: rgb.red,
243        g: rgb.green,
244        b: rgb.blue,
245        a: 1.0,
246    }
247}
248
249fn candidate_hues() -> impl Iterator<Item = f32> {
250    (0..HUE_CANDIDATE_COUNT).map(|step| step as f32 * HUE_CANDIDATE_STEP)
251}
252
253fn hue_distance(first: f32, second: f32) -> f32 {
254    let distance = (first - second).abs().rem_euclid(360.0);
255    distance.min(360.0 - distance)
256}
257
258fn gamut_mapped_srgb(mut oklch: Oklch) -> Srgb {
259    let mut rgb = Srgb::from_color_unclamped(oklch);
260
261    if rgb.is_within_bounds() {
262        return rgb;
263    }
264
265    let mut lower_chroma = 0.0;
266    let mut upper_chroma = oklch.chroma;
267
268    for _ in 0..12 {
269        let chroma = (lower_chroma + upper_chroma) / 2.0;
270        oklch.chroma = chroma;
271        rgb = Srgb::from_color_unclamped(oklch);
272
273        if rgb.is_within_bounds() {
274            lower_chroma = chroma;
275        } else {
276            upper_chroma = chroma;
277        }
278    }
279
280    oklch.chroma = lower_chroma;
281    Srgb::from_color_unclamped(oklch).clamp()
282}
283
284fn to_oklch(color: Color) -> Oklch {
285    Oklch::from_color(Srgb::new(color.r, color.g, color.b))
286}
287
288#[derive(Serialize, Deserialize)]
289struct SerTheme {
290    name: String,
291    #[serde(skip_serializing_if = "Option::is_none")]
292    palette: Option<Palette>,
293}
294
295impl Default for Theme {
296    fn default() -> Self {
297        Self(iced_core::Theme::Custom(default_theme().into()))
298    }
299}
300
301impl From<Theme> for iced_core::Theme {
302    fn from(val: Theme) -> Self {
303        val.0
304    }
305}
306
307pub fn default_theme() -> Custom {
308    Custom::new(
309        "Flowsurface".to_string(),
310        Palette {
311            background: Color::from_rgb8(24, 22, 22),
312            text: Color::from_rgb8(197, 201, 197),
313            primary: Color::from_rgb8(169, 174, 191),
314            success: Color::from_rgb8(81, 205, 160),
315            danger: Color::from_rgb8(192, 80, 77),
316            warning: Color::from_rgb8(238, 216, 139),
317        },
318    )
319}
320
321impl Serialize for Theme {
322    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
323    where
324        S: serde::Serializer,
325    {
326        if let iced_core::Theme::Custom(custom) = &self.0 {
327            let is_default_theme = custom.to_string() == "Flowsurface";
328            let ser_theme = SerTheme {
329                name: if is_default_theme {
330                    "flowsurface"
331                } else {
332                    "custom"
333                }
334                .to_string(),
335                palette: if is_default_theme {
336                    None
337                } else {
338                    Some(self.0.palette())
339                },
340            };
341            ser_theme.serialize(serializer)
342        } else {
343            let theme_str = match self.0 {
344                iced_core::Theme::Ferra => "ferra",
345                iced_core::Theme::Dark => "dark",
346                iced_core::Theme::Light => "light",
347                iced_core::Theme::Dracula => "dracula",
348                iced_core::Theme::Nord => "nord",
349                iced_core::Theme::SolarizedLight => "solarized_light",
350                iced_core::Theme::SolarizedDark => "solarized_dark",
351                iced_core::Theme::GruvboxLight => "gruvbox_light",
352                iced_core::Theme::GruvboxDark => "gruvbox_dark",
353                iced_core::Theme::CatppuccinLatte => "catppuccino_latte",
354                iced_core::Theme::CatppuccinFrappe => "catppuccino_frappe",
355                iced_core::Theme::CatppuccinMacchiato => "catppuccino_macchiato",
356                iced_core::Theme::CatppuccinMocha => "catppuccino_mocha",
357                iced_core::Theme::TokyoNight => "tokyo_night",
358                iced_core::Theme::TokyoNightStorm => "tokyo_night_storm",
359                iced_core::Theme::TokyoNightLight => "tokyo_night_light",
360                iced_core::Theme::KanagawaWave => "kanagawa_wave",
361                iced_core::Theme::KanagawaDragon => "kanagawa_dragon",
362                iced_core::Theme::KanagawaLotus => "kanagawa_lotus",
363                iced_core::Theme::Moonfly => "moonfly",
364                iced_core::Theme::Nightfly => "nightfly",
365                iced_core::Theme::Oxocarbon => "oxocarbon",
366                _ => unreachable!(),
367            };
368            theme_str.serialize(serializer)
369        }
370    }
371}
372
373impl<'de> Deserialize<'de> for Theme {
374    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
375    where
376        D: serde::Deserializer<'de>,
377    {
378        let value =
379            serde_json::Value::deserialize(deserializer).map_err(serde::de::Error::custom)?;
380
381        if let Some(s) = value.as_str() {
382            let theme = match s {
383                "ferra" => iced_core::Theme::Ferra,
384                "dark" => iced_core::Theme::Dark,
385                "light" => iced_core::Theme::Light,
386                "dracula" => iced_core::Theme::Dracula,
387                "nord" => iced_core::Theme::Nord,
388                "solarized_light" => iced_core::Theme::SolarizedLight,
389                "solarized_dark" => iced_core::Theme::SolarizedDark,
390                "gruvbox_light" => iced_core::Theme::GruvboxLight,
391                "gruvbox_dark" => iced_core::Theme::GruvboxDark,
392                "catppuccino_latte" => iced_core::Theme::CatppuccinLatte,
393                "catppuccino_frappe" => iced_core::Theme::CatppuccinFrappe,
394                "catppuccino_macchiato" => iced_core::Theme::CatppuccinMacchiato,
395                "catppuccino_mocha" => iced_core::Theme::CatppuccinMocha,
396                "tokyo_night" => iced_core::Theme::TokyoNight,
397                "tokyo_night_storm" => iced_core::Theme::TokyoNightStorm,
398                "tokyo_night_light" => iced_core::Theme::TokyoNightLight,
399                "kanagawa_wave" => iced_core::Theme::KanagawaWave,
400                "kanagawa_dragon" => iced_core::Theme::KanagawaDragon,
401                "kanagawa_lotus" => iced_core::Theme::KanagawaLotus,
402                "moonfly" => iced_core::Theme::Moonfly,
403                "nightfly" => iced_core::Theme::Nightfly,
404                "oxocarbon" => iced_core::Theme::Oxocarbon,
405                "flowsurface" => Theme::default().0,
406                _ => {
407                    return Err(serde::de::Error::custom(format!("Invalid theme: {}", s)));
408                }
409            };
410            return Ok(Theme(theme));
411        }
412
413        let serialized = SerTheme::deserialize(value).map_err(serde::de::Error::custom)?;
414
415        let theme = match serialized.name.as_str() {
416            "flowsurface" => Theme::default().0,
417            "custom" => {
418                if let Some(palette) = serialized.palette {
419                    iced_core::Theme::Custom(Custom::new("Custom".to_string(), palette).into())
420                } else {
421                    return Err(serde::de::Error::custom(
422                        "Custom theme missing palette data",
423                    ));
424                }
425            }
426            _ => return Err(serde::de::Error::custom("Invalid theme")),
427        };
428
429        Ok(Theme(theme))
430    }
431}
432
433#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
434pub enum ThemeColorRole {
435    Neutral,
436    Secondary,
437    Primary,
438    Success,
439    Danger,
440    Warning,
441}
442
443impl ThemeColorRole {
444    pub const ALL: &'static [Self] = &[
445        Self::Neutral,
446        Self::Secondary,
447        Self::Primary,
448        Self::Success,
449        Self::Danger,
450        Self::Warning,
451    ];
452}
453
454impl std::str::FromStr for ThemeColorRole {
455    type Err = UnknownThemeColor;
456
457    fn from_str(value: &str) -> Result<Self, Self::Err> {
458        Self::ALL
459            .iter()
460            .copied()
461            .find(|role| role.to_string().eq_ignore_ascii_case(value))
462            .ok_or_else(|| UnknownThemeColor::Role(value.to_owned()))
463    }
464}
465
466impl std::fmt::Display for ThemeColorRole {
467    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
468        let label = match self {
469            Self::Neutral => "Neutral",
470            Self::Secondary => "Secondary",
471            Self::Primary => "Primary",
472            Self::Success => "Success",
473            Self::Danger => "Danger",
474            Self::Warning => "Warning",
475        };
476        formatter.write_str(label)
477    }
478}
479
480#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
481pub enum ThemeColorShade {
482    Faint,
483    Soft,
484    #[default]
485    Base,
486    Strong,
487    Vivid,
488}
489
490impl ThemeColorShade {
491    pub const ALL: &'static [Self] = &[
492        Self::Faint,
493        Self::Soft,
494        Self::Base,
495        Self::Strong,
496        Self::Vivid,
497    ];
498}
499
500impl std::fmt::Display for ThemeColorShade {
501    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
502        let label = match self {
503            Self::Faint => "Faint",
504            Self::Soft => "Soft",
505            Self::Base => "Base",
506            Self::Strong => "Strong",
507            Self::Vivid => "Vivid",
508        };
509        formatter.write_str(label)
510    }
511}
512
513impl std::str::FromStr for ThemeColorShade {
514    type Err = UnknownThemeColor;
515
516    fn from_str(value: &str) -> Result<Self, Self::Err> {
517        Self::ALL
518            .iter()
519            .copied()
520            .find(|shade| shade.to_string().eq_ignore_ascii_case(value))
521            .ok_or_else(|| UnknownThemeColor::Shade(value.to_owned()))
522    }
523}
524
525/// A theme colour name that does not match any known role or shade.
526#[derive(Debug, Clone, PartialEq, Eq)]
527pub enum UnknownThemeColor {
528    Role(String),
529    Shade(String),
530}
531
532impl std::fmt::Display for UnknownThemeColor {
533    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
534        match self {
535            Self::Role(value) => write!(formatter, "unknown theme role '{value}'"),
536            Self::Shade(value) => write!(formatter, "unknown theme shade '{value}'"),
537        }
538    }
539}
540
541impl std::error::Error for UnknownThemeColor {}
542
543#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
544pub struct ThemeColor {
545    pub role: ThemeColorRole,
546    pub shade: ThemeColorShade,
547    #[serde(default)]
548    pub accent: Option<ThemeAccent>,
549}
550
551impl Default for ThemeColor {
552    fn default() -> Self {
553        Self::new(ThemeColorRole::Neutral, ThemeColorShade::Base)
554    }
555}
556
557impl ThemeColor {
558    pub const fn new(role: ThemeColorRole, shade: ThemeColorShade) -> Self {
559        Self {
560            role,
561            shade,
562            accent: None,
563        }
564    }
565
566    pub const fn with_accent(
567        role: ThemeColorRole,
568        accent: Option<ThemeAccent>,
569        shade: ThemeColorShade,
570    ) -> Self {
571        Self {
572            role,
573            shade,
574            accent,
575        }
576    }
577}
578
579pub fn hex_to_color(hex: &str) -> Option<Color> {
580    if hex.len() == 7 || hex.len() == 9 {
581        let hash = &hex[0..1];
582        let r = u8::from_str_radix(&hex[1..3], 16);
583        let g = u8::from_str_radix(&hex[3..5], 16);
584        let b = u8::from_str_radix(&hex[5..7], 16);
585        let a = (hex.len() == 9)
586            .then(|| u8::from_str_radix(&hex[7..9], 16).ok())
587            .flatten();
588
589        return match (hash, r, g, b, a) {
590            ("#", Ok(r), Ok(g), Ok(b), None) => Some(Color {
591                r: f32::from(r) / 255.0,
592                g: f32::from(g) / 255.0,
593                b: f32::from(b) / 255.0,
594                a: 1.0,
595            }),
596            ("#", Ok(r), Ok(g), Ok(b), Some(a)) => Some(Color {
597                r: f32::from(r) / 255.0,
598                g: f32::from(g) / 255.0,
599                b: f32::from(b) / 255.0,
600                a: f32::from(a) / 255.0,
601            }),
602            _ => None,
603        };
604    }
605
606    None
607}
608
609pub fn color_to_hex(color: Color) -> String {
610    use std::fmt::Write;
611
612    let mut hex = String::with_capacity(9);
613
614    let [r, g, b, a] = color.into_rgba8();
615
616    let _ = write!(&mut hex, "#");
617    let _ = write!(&mut hex, "{r:02X}");
618    let _ = write!(&mut hex, "{g:02X}");
619    let _ = write!(&mut hex, "{b:02X}");
620
621    if a < u8::MAX {
622        let _ = write!(&mut hex, "{a:02X}");
623    }
624
625    hex
626}
627
628pub fn from_hsva(color: Hsva) -> Color {
629    to_color(palette::Srgba::from_color(color))
630}
631
632fn to_color(rgba: Rgba) -> Color {
633    Color {
634        r: rgba.color.red,
635        g: rgba.color.green,
636        b: rgba.color.blue,
637        a: rgba.alpha,
638    }
639}
640
641pub fn to_hsva(color: Color) -> Hsva {
642    Hsva::from_color(to_rgba(color))
643}
644
645fn to_rgb(color: Color) -> Rgb {
646    Rgb {
647        red: color.r,
648        green: color.g,
649        blue: color.b,
650        ..Rgb::default()
651    }
652}
653
654fn to_rgba(color: Color) -> Rgba {
655    Rgba {
656        alpha: color.a,
657        color: to_rgb(color),
658    }
659}
660
661pub fn darken(color: Color, amount: f32) -> Color {
662    let mut hsl = to_hsl(color);
663
664    hsl.l = if hsl.l - amount < 0.0 {
665        0.0
666    } else {
667        hsl.l - amount
668    };
669
670    from_hsl(hsl)
671}
672
673pub fn lighten(color: Color, amount: f32) -> Color {
674    let mut hsl = to_hsl(color);
675
676    hsl.l = if hsl.l + amount > 1.0 {
677        1.0
678    } else {
679        hsl.l + amount
680    };
681
682    from_hsl(hsl)
683}
684
685fn to_hsl(color: Color) -> Hsl {
686    let x_max = color.r.max(color.g).max(color.b);
687    let x_min = color.r.min(color.g).min(color.b);
688    let c = x_max - x_min;
689    let l = x_max.midpoint(x_min);
690
691    let h = if c == 0.0 {
692        0.0
693    } else if x_max == color.r {
694        60.0 * ((color.g - color.b) / c).rem_euclid(6.0)
695    } else if x_max == color.g {
696        60.0 * (((color.b - color.r) / c) + 2.0)
697    } else {
698        // x_max == color.b
699        60.0 * (((color.r - color.g) / c) + 4.0)
700    };
701
702    let s = if l == 0.0 || l == 1.0 {
703        0.0
704    } else {
705        (x_max - l) / l.min(1.0 - l)
706    };
707
708    Hsl {
709        h,
710        s,
711        l,
712        a: color.a,
713    }
714}
715
716pub fn is_dark(color: Color) -> bool {
717    let brightness = (color.r * 299.0 + color.g * 587.0 + color.b * 114.0) / 1000.0;
718    brightness < 0.5
719}
720
721struct Hsl {
722    h: f32,
723    s: f32,
724    l: f32,
725    a: f32,
726}
727
728// https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_RGB
729fn from_hsl(hsl: Hsl) -> Color {
730    let c = (1.0 - (2.0 * hsl.l - 1.0).abs()) * hsl.s;
731    let h = hsl.h / 60.0;
732    let x = c * (1.0 - (h.rem_euclid(2.0) - 1.0).abs());
733
734    let (r1, g1, b1) = if h < 1.0 {
735        (c, x, 0.0)
736    } else if h < 2.0 {
737        (x, c, 0.0)
738    } else if h < 3.0 {
739        (0.0, c, x)
740    } else if h < 4.0 {
741        (0.0, x, c)
742    } else if h < 5.0 {
743        (x, 0.0, c)
744    } else {
745        // h < 6.0
746        (c, 0.0, x)
747    };
748
749    let m = hsl.l - (c / 2.0);
750
751    Color {
752        r: r1 + m,
753        g: g1 + m,
754        b: b1 + m,
755        a: hsl.a,
756    }
757}
758
759/// Alpha-composite a foreground color over a background.
760pub fn composite_color(foreground: Color, background: Color) -> Color {
761    let alpha = foreground.a.clamp(0.0, 1.0);
762    Color {
763        r: foreground.r.mul_add(alpha, background.r * (1.0 - alpha)),
764        g: foreground.g.mul_add(alpha, background.g * (1.0 - alpha)),
765        b: foreground.b.mul_add(alpha, background.b * (1.0 - alpha)),
766        a: 1.0,
767    }
768}
769
770/// Compute the WCAG contrast ratio between two colors.
771pub fn contrast_ratio(a: Color, b: Color) -> f32 {
772    let l1 = relative_luminance(a);
773    let l2 = relative_luminance(b);
774    let lighter = l1.max(l2);
775    let darker = l1.min(l2);
776
777    (lighter + 0.05) / (darker + 0.05)
778}
779
780/// Compute the relative luminance of a color per the WCAG specification.
781pub fn relative_luminance(color: Color) -> f32 {
782    let channel = |value: f32| {
783        if value <= 0.03928 {
784            value / 12.92
785        } else {
786            ((value + 0.055) / 1.055).powf(2.4)
787        }
788    };
789
790    (0.2126 * channel(color.r)) + (0.7152 * channel(color.g)) + (0.0722 * channel(color.b))
791}
792
793/// Linearly interpolate (mix) two colors by a foreground-weight factor.
794pub fn mix_color(foreground: Color, background: Color, foreground_weight: f32) -> Color {
795    let foreground_weight = foreground_weight.clamp(0.0, 1.0);
796    let background_weight = 1.0 - foreground_weight;
797
798    Color {
799        r: foreground
800            .r
801            .mul_add(foreground_weight, background.r * background_weight),
802        g: foreground
803            .g
804            .mul_add(foreground_weight, background.g * background_weight),
805        b: foreground
806            .b
807            .mul_add(foreground_weight, background.b * background_weight),
808        a: foreground
809            .a
810            .mul_add(foreground_weight, background.a * background_weight),
811    }
812}