use std::fmt;
use gpui_kit::Hsla;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use super::color::{contrast, neutrals, rgb_hsla};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct Rgb(pub u32);
impl Rgb {
pub const SUGGESTED: [Rgb; 8] = [
Rgb(0xc6f26b), Rgb(0xd6457f), Rgb(0x2b7f86), Rgb(0x3d65a8), Rgb(0x7451b8), Rgb(0xe0a030), Rgb(0xe5484d), Rgb(0x8a8f98), ];
pub fn parse(text: &str) -> Option<Rgb> {
let hex = text.trim().trim_start_matches('#');
let full = match hex.len() {
3 => hex.chars().flat_map(|c| [c, c]).collect(),
6 => hex.to_string(),
_ => return None,
};
u32::from_str_radix(&full, 16).ok().map(Rgb)
}
pub fn hsla(self) -> Hsla {
rgb_hsla(self.0)
}
}
impl From<Hsla> for Rgb {
fn from(c: Hsla) -> Self {
Rgb(hsla_rgb(c))
}
}
impl fmt::Display for Rgb {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "#{:06x}", self.0 & 0xffffff)
}
}
impl Serialize for Rgb {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for Rgb {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let text = String::deserialize(d)?;
Rgb::parse(&text).ok_or_else(|| serde::de::Error::custom(format!("{text:?} is not a colour (#rrggbb)")))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Accent {
pub fill: u32,
pub fill_hover: u32,
pub on_fill: u32,
pub mark: u32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Palette {
pub name: &'static str,
pub dark: Accent,
pub light: Accent,
}
impl Palette {
pub const LIME: Palette = Palette {
name: "Lime",
dark: Accent { fill: 0xc6f26b, fill_hover: 0xe2faa8, on_fill: 0x0a0e17, mark: 0xc6f26b },
light: Accent { fill: 0xc6f26b, fill_hover: 0xb4e350, on_fill: 0x0a0e17, mark: 0x3f6212 },
};
pub const EOSIN: Palette = Palette {
name: "Eosin",
dark: Accent { fill: 0xf28ab3, fill_hover: 0xf6a8c7, on_fill: 0x0a0e17, mark: 0xf28ab3 },
light: Accent { fill: 0xb0285f, fill_hover: 0x93214f, on_fill: 0xffffff, mark: 0xb0285f },
};
pub const APPS: [Palette; 2] = [Palette::LIME, Palette::EOSIN];
pub fn accent(&self, dark: bool) -> Accent {
if dark { self.dark } else { self.light }
}
pub fn custom(rgb: Rgb) -> Palette {
Palette::from_rgb("Custom", rgb.0)
}
pub fn from_rgb(name: &'static str, rgb: u32) -> Palette {
let base = rgb_hsla(rgb);
Palette { name, dark: derive(base, true), light: derive(base, false) }
}
}
fn derive(base: Hsla, dark: bool) -> Accent {
let pick = |pair: (u32, u32)| rgb_hsla(if dark { pair.0 } else { pair.1 });
let (panel, raised, control, hover) = (pick(neutrals::PANEL), pick(neutrals::RAISED), pick(neutrals::CONTROL), pick(neutrals::HOVER));
let step = if dark { 0.01 } else { -0.01 };
let start = Hsla { s: if dark { base.s * 0.75 } else { base.s }, a: 1.0, ..base };
let stored = |c: Hsla| rgb_hsla(hsla_rgb(c));
let reach = |ok: &dyn Fn(Hsla) -> bool| {
let mut c = start;
for _ in 0..100 {
if ok(stored(c)) {
break;
}
c.l = (c.l + step).clamp(0.0, 1.0);
}
stored(c)
};
let fill = reach(&|c| contrast(c, panel) >= 2.0 && contrast(best_text_on(c), c) >= 4.5);
let on_fill = best_text_on(fill);
let fill_hover = Hsla { l: (fill.l + if on_fill.l > 0.5 { -0.06 } else { 0.06 }).clamp(0.0, 1.0), ..fill };
let mark = reach(&|c| [panel, raised].iter().all(|b| contrast(c, *b) >= 4.5) && [control, hover].iter().all(|b| contrast(c, *b) >= 3.0));
Accent { fill: hsla_rgb(fill), fill_hover: hsla_rgb(fill_hover), on_fill: hsla_rgb(on_fill), mark: hsla_rgb(mark) }
}
pub fn best_text_on(c: Hsla) -> Hsla {
let (white, ink) = (rgb_hsla(0xffffff), rgb_hsla(0x0a0e17));
if contrast(white, c) >= contrast(ink, c) { white } else { ink }
}
pub fn hsla_rgb(c: Hsla) -> u32 {
let r = c.to_rgb();
let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u32;
(b(r.r) << 16) | (b(r.g) << 8) | b(r.b)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rgb_reads_and_writes_hex() {
assert_eq!(Rgb::parse("#c6f26b"), Some(Rgb(0xc6f26b)));
assert_eq!(Rgb::parse(" C6F26B "), Some(Rgb(0xc6f26b)));
assert_eq!(Rgb::parse("#fa0"), Some(Rgb(0xffaa00)));
assert_eq!(Rgb::parse("#12345"), None);
assert_eq!(Rgb::parse("#zzzzzz"), None);
assert_eq!(serde_json::to_string(&Rgb(0x0a1030)).unwrap(), "\"#0a1030\"");
assert_eq!(serde_json::from_str::<Option<Rgb>>("\"#b0285f\"").unwrap(), Some(Rgb(0xb0285f)));
assert!(serde_json::from_str::<Rgb>("\"rose\"").is_err());
}
#[test]
fn arbitrary_colours_pass_in_both_themes() {
let picks = [("pale yellow", 0xfffacd), ("near-black navy", 0x0a1030), ("pure red", 0xff0000), ("white", 0xffffff), ("black", 0x000000), ("lime", 0xc6f26b), ("mid grey", 0x808080)];
for (name, rgb) in picks {
let p = Palette::from_rgb(name, rgb);
for dark in [true, false] {
let a = p.accent(dark);
let panel = rgb_hsla(if dark { neutrals::PANEL.0 } else { neutrals::PANEL.1 });
let (fill, mark) = (rgb_hsla(a.fill), rgb_hsla(a.mark));
assert!(contrast(fill, panel) >= 2.0, "{name} dark={dark}: fill on panel {:.2}:1", contrast(fill, panel));
assert!(contrast(rgb_hsla(a.on_fill), fill) >= 4.5, "{name} dark={dark}: text on fill");
assert!(contrast(rgb_hsla(a.on_fill), rgb_hsla(a.fill_hover)) >= 4.5, "{name} dark={dark}: text on hover");
assert!(contrast(mark, panel) >= 4.5, "{name} dark={dark}: mark on panel {:.2}:1", contrast(mark, panel));
let src = rgb_hsla(rgb);
if src.s > 0.2 && (0.1..0.9).contains(&fill.l) {
let d = (fill.h - src.h).abs().min(1.0 - (fill.h - src.h).abs());
assert!(d < 0.02, "{name} dark={dark}: hue {} → {}", src.h, fill.h);
}
}
}
}
#[test]
fn dark_accents_are_less_colourful() {
let p = Palette::from_rgb("red", 0xff0000);
let (dark, light) = (rgb_hsla(p.dark.fill), rgb_hsla(p.light.fill));
assert!(dark.s < light.s, "dark {} vs light {}", dark.s, light.s);
}
}