Skip to main content

gpui_kit_material_color/dynamic/
temperature.rs

1// Copyright 2023 Google LLC
2// SPDX-License-Identifier: Apache-2.0
3// Ported to Rust from material-color-utilities (typescript/temperature), modified
4// for this crate: temperatures are stored beside each color instead of in a map
5// keyed by object identity. See licenses/material-color-utilities.txt.
6
7//! Color temperature: analogous and complementary colors.
8use super::hct::Hct;
9use super::utils::{lab_from_argb, sanitize_degrees_double, sanitize_degrees_int};
10use std::f64::consts::PI;
11
12/// The input color with its hue circle, sorted by temperature, computed once.
13#[derive(Clone, Debug)]
14pub struct TemperatureCache {
15    input: Hct,
16    /// One color per degree, 0 to 360 inclusive, at the input's chroma and tone.
17    by_hue: Vec<Hct>,
18    coldest: (Hct, f64),
19    warmest: (Hct, f64),
20}
21
22impl TemperatureCache {
23    /// Build the hue circle of `input`: 361 solves.
24    pub fn new(input: Hct) -> Self {
25        let by_hue: Vec<Hct> = (0..=360)
26            .map(|hue| Hct::from_hct(f64::from(hue), input.chroma(), input.tone()))
27            .collect();
28        // Upstream sorts stably by temperature with the input appended last:
29        // on ties the coldest is the first in that order and the warmest the
30        // last.
31        let mut coldest = (input, f64::INFINITY);
32        let mut warmest = (input, f64::NEG_INFINITY);
33        for hct in by_hue.iter().copied().chain([input]) {
34            let temperature = raw_temperature(hct);
35            if temperature < coldest.1 {
36                coldest = (hct, temperature);
37            }
38            if temperature >= warmest.1 {
39                warmest = (hct, temperature);
40            }
41        }
42        Self {
43            input,
44            by_hue,
45            coldest,
46            warmest,
47        }
48    }
49
50    /// The warmest color at the input's chroma and tone.
51    pub fn warmest(&self) -> Hct {
52        self.warmest.0
53    }
54
55    /// The coldest color at the input's chroma and tone.
56    pub fn coldest(&self) -> Hct {
57        self.coldest.0
58    }
59
60    /// `count` colors of differing hue, equidistant in temperature over a
61    /// wheel of `divisions` steps, with the input in the middle. Art usually
62    /// describes this as 5 colors on a wheel of 12. `count` and `divisions`
63    /// of 0 return only the input.
64    pub fn analogous(&self, count: usize, divisions: usize) -> Vec<Hct> {
65        if count == 0 || divisions == 0 {
66            return vec![self.input];
67        }
68        // JavaScript's Math.round on a non-negative hue; at most 360.
69        let start_hue = self.input.hue().round() as i32;
70        let start = self.by_hue[start_hue as usize];
71        let mut last_temp = self.relative_temperature(start);
72        let mut all = vec![start];
73
74        let mut total_delta = 0.;
75        for i in 0..360 {
76            let hct = self.by_hue[sanitize_degrees_int(start_hue + i) as usize];
77            let temp = self.relative_temperature(hct);
78            total_delta += (temp - last_temp).abs();
79            last_temp = temp;
80        }
81        let mut hue_addend = 1;
82        let step = total_delta / divisions as f64;
83        let mut delta_so_far = 0.;
84        last_temp = self.relative_temperature(start);
85        while all.len() < divisions {
86            let hct = self.by_hue[sanitize_degrees_int(start_hue + hue_addend) as usize];
87            let temp = self.relative_temperature(hct);
88            delta_so_far += (temp - last_temp).abs();
89            let mut satisfied = delta_so_far >= all.len() as f64 * step;
90            let mut index_addend = 1;
91            // Keep adding this hue until its temperature is insufficient, so
92            // colors without enough discrete steps (white, black) still fill
93            // the answer.
94            while satisfied && all.len() < divisions {
95                all.push(hct);
96                satisfied = delta_so_far >= (all.len() + index_addend) as f64 * step;
97                index_addend += 1;
98            }
99            last_temp = temp;
100            hue_addend += 1;
101            if hue_addend > 360 {
102                while all.len() < divisions {
103                    all.push(hct);
104                }
105                break;
106            }
107        }
108
109        let mut answers = vec![self.input];
110        let increase = (count - 1) / 2;
111        for i in 1..=increase {
112            answers.insert(0, all[(all.len() - i % all.len()) % all.len()]);
113        }
114        for i in 1..=count - increase - 1 {
115            answers.push(all[i % all.len()]);
116        }
117        answers
118    }
119
120    /// The color as far from the input in temperature as the input is from
121    /// the other end: art's color across the wheel.
122    pub fn complement(&self) -> Hct {
123        let (coldest_hue, coldest_temp) = (self.coldest.0.hue(), self.coldest.1);
124        let (warmest_hue, warmest_temp) = (self.warmest.0.hue(), self.warmest.1);
125        let range = warmest_temp - coldest_temp;
126        let cold_to_warm = is_between(self.input.hue(), coldest_hue, warmest_hue);
127        let (start_hue, end_hue) = if cold_to_warm {
128            (warmest_hue, coldest_hue)
129        } else {
130            (coldest_hue, warmest_hue)
131        };
132        let mut smallest_error = 1000.;
133        let mut answer = self.by_hue[self.input.hue().round() as usize];
134        let complement_relative = 1. - self.input_relative_temperature();
135        // The color in the other section closest to the inverse percentile.
136        for addend in 0..=360 {
137            let hue = sanitize_degrees_double(start_hue + f64::from(addend));
138            if !is_between(hue, start_hue, end_hue) {
139                continue;
140            }
141            let possible = self.by_hue[hue.round() as usize];
142            let relative = (raw_temperature(possible) - coldest_temp) / range;
143            let error = (complement_relative - relative).abs();
144            if error < smallest_error {
145                smallest_error = error;
146                answer = possible;
147            }
148        }
149        answer
150    }
151
152    /// Temperature relative to all colors of the same chroma and tone, 0 to 1.
153    /// 0.5 when they do not differ, as at T100 where only white exists.
154    pub fn relative_temperature(&self, hct: Hct) -> f64 {
155        let range = self.warmest.1 - self.coldest.1;
156        if range == 0. {
157            return 0.5;
158        }
159        (raw_temperature(hct) - self.coldest.1) / range
160    }
161
162    /// [`Self::relative_temperature`] of the input.
163    pub fn input_relative_temperature(&self) -> f64 {
164        self.relative_temperature(self.input)
165    }
166}
167
168/// Whether `angle` lies between two others, rotating clockwise.
169fn is_between(angle: f64, a: f64, b: f64) -> bool {
170    if a < b {
171        a <= angle && angle <= b
172    } else {
173        a <= angle || angle <= b
174    }
175}
176
177/// The cool-warm factor of a color (Ou, Woodcock and Wright, in L*a*b*):
178/// below 0 is cool, above 0 warm.
179pub fn raw_temperature(color: Hct) -> f64 {
180    let [_, a, b] = lab_from_argb(color.argb());
181    let hue = sanitize_degrees_double(b.atan2(a) * 180. / PI);
182    let chroma = (a * a + b * b).sqrt();
183    -0.5 + 0.02 * chroma.powf(1.07) * (sanitize_degrees_double(hue - 50.) * PI / 180.).cos()
184}