1use std::fmt;
7
8use gpui_kit::Hsla;
9use serde::{Deserialize, Deserializer, Serialize, Serializer};
10
11use super::color::{contrast, neutrals, rgb_hsla};
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
16pub struct Rgb(pub u32);
17
18impl Rgb {
19 pub const SUGGESTED: [Rgb; 8] = [
21 Rgb(0xc6f26b), Rgb(0xd6457f), Rgb(0x2b7f86), Rgb(0x3d65a8), Rgb(0x7451b8), Rgb(0xe0a030), Rgb(0xe5484d), Rgb(0x8a8f98), ];
30
31 pub fn parse(text: &str) -> Option<Rgb> {
33 let hex = text.trim().trim_start_matches('#');
34 let full = match hex.len() {
35 3 => hex.chars().flat_map(|c| [c, c]).collect(),
36 6 => hex.to_string(),
37 _ => return None,
38 };
39 u32::from_str_radix(&full, 16).ok().map(Rgb)
40 }
41
42 pub fn hsla(self) -> Hsla {
44 rgb_hsla(self.0)
45 }
46}
47
48impl From<Hsla> for Rgb {
49 fn from(c: Hsla) -> Self {
50 Rgb(hsla_rgb(c))
51 }
52}
53
54impl fmt::Display for Rgb {
55 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56 write!(f, "#{:06x}", self.0 & 0xffffff)
57 }
58}
59
60impl Serialize for Rgb {
61 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
62 s.serialize_str(&self.to_string())
63 }
64}
65
66impl<'de> Deserialize<'de> for Rgb {
67 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
68 let text = String::deserialize(d)?;
69 Rgb::parse(&text).ok_or_else(|| serde::de::Error::custom(format!("{text:?} is not a colour (#rrggbb)")))
70 }
71}
72
73#[derive(Clone, Copy, Debug, PartialEq, Eq)]
75pub struct Accent {
76 pub fill: u32,
78 pub fill_hover: u32,
80 pub on_fill: u32,
82 pub mark: u32,
85}
86
87#[derive(Clone, Copy, Debug, PartialEq, Eq)]
89pub struct Palette {
90 pub name: &'static str,
92 pub dark: Accent,
94 pub light: Accent,
96}
97
98impl Palette {
99 pub const LIME: Palette = Palette {
103 name: "Lime",
104 dark: Accent { fill: 0xc6f26b, fill_hover: 0xe2faa8, on_fill: 0x0a0e17, mark: 0xc6f26b },
105 light: Accent { fill: 0xc6f26b, fill_hover: 0xb4e350, on_fill: 0x0a0e17, mark: 0x3f6212 },
106 };
107
108 pub const EOSIN: Palette = Palette {
111 name: "Eosin",
112 dark: Accent { fill: 0xf28ab3, fill_hover: 0xf6a8c7, on_fill: 0x0a0e17, mark: 0xf28ab3 },
113 light: Accent { fill: 0xb0285f, fill_hover: 0x93214f, on_fill: 0xffffff, mark: 0xb0285f },
114 };
115
116 pub const APPS: [Palette; 2] = [Palette::LIME, Palette::EOSIN];
118
119 pub fn accent(&self, dark: bool) -> Accent {
121 if dark { self.dark } else { self.light }
122 }
123
124 pub fn custom(rgb: Rgb) -> Palette {
126 Palette::from_rgb("Custom", rgb.0)
127 }
128
129 pub fn from_rgb(name: &'static str, rgb: u32) -> Palette {
139 let base = rgb_hsla(rgb);
140 Palette { name, dark: derive(base, true), light: derive(base, false) }
141 }
142}
143
144fn derive(base: Hsla, dark: bool) -> Accent {
146 let pick = |pair: (u32, u32)| rgb_hsla(if dark { pair.0 } else { pair.1 });
147 let (panel, raised, control, hover) = (pick(neutrals::PANEL), pick(neutrals::RAISED), pick(neutrals::CONTROL), pick(neutrals::HOVER));
148 let step = if dark { 0.01 } else { -0.01 };
150 let start = Hsla { s: if dark { base.s * 0.75 } else { base.s }, a: 1.0, ..base };
151 let stored = |c: Hsla| rgb_hsla(hsla_rgb(c));
153 let reach = |ok: &dyn Fn(Hsla) -> bool| {
154 let mut c = start;
155 for _ in 0..100 {
156 if ok(stored(c)) {
157 break;
158 }
159 c.l = (c.l + step).clamp(0.0, 1.0);
160 }
161 stored(c)
162 };
163 let fill = reach(&|c| contrast(c, panel) >= 2.0 && contrast(best_text_on(c), c) >= 4.5);
164 let on_fill = best_text_on(fill);
165 let fill_hover = Hsla { l: (fill.l + if on_fill.l > 0.5 { -0.06 } else { 0.06 }).clamp(0.0, 1.0), ..fill };
166 let mark = reach(&|c| [panel, raised].iter().all(|b| contrast(c, *b) >= 4.5) && [control, hover].iter().all(|b| contrast(c, *b) >= 3.0));
167 Accent { fill: hsla_rgb(fill), fill_hover: hsla_rgb(fill_hover), on_fill: hsla_rgb(on_fill), mark: hsla_rgb(mark) }
168}
169
170pub fn best_text_on(c: Hsla) -> Hsla {
172 let (white, ink) = (rgb_hsla(0xffffff), rgb_hsla(0x0a0e17));
173 if contrast(white, c) >= contrast(ink, c) { white } else { ink }
174}
175
176pub fn hsla_rgb(c: Hsla) -> u32 {
178 let r = c.to_rgb();
179 let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u32;
180 (b(r.r) << 16) | (b(r.g) << 8) | b(r.b)
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 #[test]
188 fn rgb_reads_and_writes_hex() {
189 assert_eq!(Rgb::parse("#c6f26b"), Some(Rgb(0xc6f26b)));
190 assert_eq!(Rgb::parse(" C6F26B "), Some(Rgb(0xc6f26b)));
191 assert_eq!(Rgb::parse("#fa0"), Some(Rgb(0xffaa00)));
192 assert_eq!(Rgb::parse("#12345"), None);
193 assert_eq!(Rgb::parse("#zzzzzz"), None);
194 assert_eq!(serde_json::to_string(&Rgb(0x0a1030)).unwrap(), "\"#0a1030\"");
195 assert_eq!(serde_json::from_str::<Option<Rgb>>("\"#b0285f\"").unwrap(), Some(Rgb(0xb0285f)));
196 assert!(serde_json::from_str::<Rgb>("\"rose\"").is_err());
197 }
198
199 #[test]
202 fn arbitrary_colours_pass_in_both_themes() {
203 let picks = [("pale yellow", 0xfffacd), ("near-black navy", 0x0a1030), ("pure red", 0xff0000), ("white", 0xffffff), ("black", 0x000000), ("lime", 0xc6f26b), ("mid grey", 0x808080)];
204 for (name, rgb) in picks {
205 let p = Palette::from_rgb(name, rgb);
206 for dark in [true, false] {
207 let a = p.accent(dark);
208 let panel = rgb_hsla(if dark { neutrals::PANEL.0 } else { neutrals::PANEL.1 });
209 let (fill, mark) = (rgb_hsla(a.fill), rgb_hsla(a.mark));
210 assert!(contrast(fill, panel) >= 2.0, "{name} dark={dark}: fill on panel {:.2}:1", contrast(fill, panel));
211 assert!(contrast(rgb_hsla(a.on_fill), fill) >= 4.5, "{name} dark={dark}: text on fill");
212 assert!(contrast(rgb_hsla(a.on_fill), rgb_hsla(a.fill_hover)) >= 4.5, "{name} dark={dark}: text on hover");
213 assert!(contrast(mark, panel) >= 4.5, "{name} dark={dark}: mark on panel {:.2}:1", contrast(mark, panel));
214 let src = rgb_hsla(rgb);
216 if src.s > 0.2 && (0.1..0.9).contains(&fill.l) {
217 let d = (fill.h - src.h).abs().min(1.0 - (fill.h - src.h).abs());
218 assert!(d < 0.02, "{name} dark={dark}: hue {} → {}", src.h, fill.h);
219 }
220 }
221 }
222 }
223
224 #[test]
225 fn dark_accents_are_less_colourful() {
226 let p = Palette::from_rgb("red", 0xff0000);
227 let (dark, light) = (rgb_hsla(p.dark.fill), rgb_hsla(p.light.fill));
228 assert!(dark.s < light.s, "dark {} vs light {}", dark.s, light.s);
229 }
230}