| 1 | //! Colour tokens, presets, palette derivation and pywal import. |
| 2 | |
| 3 | use std::path::Path; |
| 4 | |
| 5 | use serde::{Deserialize, Deserializer, Serialize, Serializer}; |
| 6 | |
| 7 | /// An sRGB colour with straight alpha. |
| 8 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 9 | pub struct Rgba(pub [u8; 4]); |
| 10 | |
| 11 | impl Rgba { |
| 12 | pub const fn rgb(hex: u32) -> Self { |
| 13 | Self([(hex >> 16) as u8, (hex >> 8) as u8, hex as u8, 255]) |
| 14 | } |
| 15 | |
| 16 | /// Mix `self` (share `t`) over `other` in sRGB space. |
| 17 | pub fn mix_over(self, other: Rgba, t: f32) -> Rgba { |
| 18 | let mut out = [0u8; 4]; |
| 19 | for (i, o) in out.iter_mut().enumerate() { |
| 20 | let v = other.0[i] as f32 + (self.0[i] as f32 - other.0[i] as f32) * t; |
| 21 | *o = v.round().clamp(0.0, 255.0) as u8; |
| 22 | } |
| 23 | Rgba(out) |
| 24 | } |
| 25 | |
| 26 | /// Linear-light RGBA for the GPU. |
| 27 | pub fn linear(self) -> [f32; 4] { |
| 28 | let f = |c: u8| { |
| 29 | let c = c as f32 / 255.0; |
| 30 | if c <= 0.04045 { |
| 31 | c / 12.92 |
| 32 | } else { |
| 33 | ((c + 0.055) / 1.055).powf(2.4) |
| 34 | } |
| 35 | }; |
| 36 | [ |
| 37 | f(self.0[0]), |
| 38 | f(self.0[1]), |
| 39 | f(self.0[2]), |
| 40 | self.0[3] as f32 / 255.0, |
| 41 | ] |
| 42 | } |
| 43 | |
| 44 | /// `#RGB`, `#RRGGBB` or `#RRGGBBAA`; the `#` is optional. |
| 45 | pub fn parse(s: &str) -> Result<Rgba, String> { |
| 46 | let hex = s.trim().strip_prefix('#').unwrap_or(s.trim()); |
| 47 | let bad = || format!("expected a colour like \"#D71921\", got \"{s}\""); |
| 48 | if !hex.chars().all(|c| c.is_ascii_hexdigit()) { |
| 49 | return Err(bad()); |
| 50 | } |
| 51 | let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).map_err(|_| bad()); |
| 52 | match hex.len() { |
| 53 | 3 => { |
| 54 | let n = |i: usize| u8::from_str_radix(&hex[i..=i], 16).map(|v| v * 17); |
| 55 | Ok(Rgba([ |
| 56 | n(0).map_err(|_| bad())?, |
| 57 | n(1).map_err(|_| bad())?, |
| 58 | n(2).map_err(|_| bad())?, |
| 59 | 255, |
| 60 | ])) |
| 61 | } |
| 62 | 6 => Ok(Rgba([byte(0)?, byte(2)?, byte(4)?, 255])), |
| 63 | 8 => Ok(Rgba([byte(0)?, byte(2)?, byte(4)?, byte(6)?])), |
| 64 | _ => Err(bad()), |
| 65 | } |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | impl std::fmt::Display for Rgba { |
| 70 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 71 | let [r, g, b, a] = self.0; |
| 72 | if a == 255 { |
| 73 | write!(f, "#{r:02X}{g:02X}{b:02X}") |
| 74 | } else { |
| 75 | write!(f, "#{r:02X}{g:02X}{b:02X}{a:02X}") |
| 76 | } |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | impl<'de> Deserialize<'de> for Rgba { |
| 81 | fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> { |
| 82 | let s = String::deserialize(d)?; |
| 83 | Rgba::parse(&s).map_err(serde::de::Error::custom) |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | impl Serialize for Rgba { |
| 88 | fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> { |
| 89 | s.serialize_str(&self.to_string()) |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | /// Generates the token struct, the optional overrides in `[theme]`, and per-token helpers. |
| 94 | macro_rules! tokens { |
| 95 | ($($name:ident),* $(,)?) => { |
| 96 | #[derive(Clone, Debug, PartialEq)] |
| 97 | pub struct Theme { |
| 98 | $(pub $name: Rgba,)* |
| 99 | } |
| 100 | |
| 101 | #[derive(Clone, Debug, Default, PartialEq, Deserialize)] |
| 102 | #[serde(default, deny_unknown_fields)] |
| 103 | pub struct ThemeConfig { |
| 104 | pub preset: Preset, |
| 105 | /// pywal's `colors.json`. |
| 106 | pub pywal: Option<String>, |
| 107 | $(pub $name: Option<Rgba>,)* |
| 108 | } |
| 109 | |
| 110 | impl Theme { |
| 111 | /// Every token mixed from `self` (t = 0) to `other` (t = 1). |
| 112 | pub fn mix(&self, other: &Theme, t: f32) -> Theme { |
| 113 | Theme { |
| 114 | $($name: other.$name.mix_over(self.$name, t),)* |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | fn apply(&mut self, cfg: &ThemeConfig) { |
| 119 | $(if let Some(c) = cfg.$name { |
| 120 | self.$name = c; |
| 121 | })* |
| 122 | } |
| 123 | } |
| 124 | }; |
| 125 | } |
| 126 | |
| 127 | tokens!( |
| 128 | background, |
| 129 | foreground, |
| 130 | surface, |
| 131 | surface_raised, |
| 132 | border, |
| 133 | border_visible, |
| 134 | text_disabled, |
| 135 | text_secondary, |
| 136 | text_primary, |
| 137 | text_display, |
| 138 | accent, |
| 139 | success, |
| 140 | warning, |
| 141 | image_background, |
| 142 | ); |
| 143 | |
| 144 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] |
| 145 | #[serde(rename_all = "kebab-case")] |
| 146 | pub enum Preset { |
| 147 | #[default] |
| 148 | NothingDark, |
| 149 | NothingLight, |
| 150 | } |
| 151 | |
| 152 | impl Preset { |
| 153 | /// Background, foreground, accent. |
| 154 | fn colors(self) -> [Rgba; 3] { |
| 155 | match self { |
| 156 | Preset::NothingDark => [ |
| 157 | Rgba::rgb(0x000000), |
| 158 | Rgba::rgb(0xFFFFFF), |
| 159 | Rgba::rgb(0xD71921), |
| 160 | ], |
| 161 | Preset::NothingLight => [ |
| 162 | Rgba::rgb(0xF5F5F5), |
| 163 | Rgba::rgb(0x000000), |
| 164 | Rgba::rgb(0xD71921), |
| 165 | ], |
| 166 | } |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | impl Theme { |
| 171 | /// Derive every token from background, foreground and accent. The fg shares reproduce the |
| 172 | /// Nothing dark palette exactly from black/white. |
| 173 | pub fn derive(background: Rgba, foreground: Rgba, accent: Rgba) -> Self { |
| 174 | let mix = |t: f32| foreground.mix_over(background, t); |
| 175 | Self { |
| 176 | background, |
| 177 | foreground, |
| 178 | surface: mix(0.067), |
| 179 | surface_raised: mix(0.10), |
| 180 | border: mix(0.133), |
| 181 | border_visible: mix(0.20), |
| 182 | text_disabled: mix(0.40), |
| 183 | text_secondary: mix(0.60), |
| 184 | text_primary: mix(0.91), |
| 185 | text_display: foreground, |
| 186 | accent, |
| 187 | success: Rgba::rgb(0x4A9E5C), |
| 188 | warning: Rgba::rgb(0xD4A843), |
| 189 | image_background: background, |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | pub fn nothing_dark() -> Self { |
| 194 | let [bg, fg, accent] = Preset::NothingDark.colors(); |
| 195 | Self::derive(bg, fg, accent) |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | /// Preset, then pywal, then explicit base colours; derive the rest, then explicit tokens win. |
| 200 | /// `image_background` follows the final background unless set. |
| 201 | pub fn resolve(cfg: &ThemeConfig, pywal: Option<[Rgba; 3]>) -> Theme { |
| 202 | let [mut bg, mut fg, mut accent] = pywal.unwrap_or_else(|| cfg.preset.colors()); |
| 203 | bg = cfg.background.unwrap_or(bg); |
| 204 | fg = cfg.foreground.unwrap_or(fg); |
| 205 | accent = cfg.accent.unwrap_or(accent); |
| 206 | let mut theme = Theme::derive(bg, fg, accent); |
| 207 | theme.apply(cfg); |
| 208 | theme |
| 209 | } |
| 210 | |
| 211 | /// Background, foreground and accent from pywal's `colors.json`. |
| 212 | pub fn read_pywal(path: &Path) -> Result<[Rgba; 3], String> { |
| 213 | let src = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?; |
| 214 | let json: serde_json::Value = |
| 215 | serde_json::from_str(&src).map_err(|e| format!("{}: {e}", path.display()))?; |
| 216 | let get = |a: &str, b: &str| -> Result<Rgba, String> { |
| 217 | let s = json[a][b] |
| 218 | .as_str() |
| 219 | .ok_or_else(|| format!("{}: missing {a}.{b}", path.display()))?; |
| 220 | Rgba::parse(s).map_err(|e| format!("{}: {a}.{b}: {e}", path.display())) |
| 221 | }; |
| 222 | Ok([ |
| 223 | get("special", "background")?, |
| 224 | get("special", "foreground")?, |
| 225 | get("colors", "color1")?, |
| 226 | ]) |
| 227 | } |
| 228 | |
| 229 | #[cfg(test)] |
| 230 | mod tests { |
| 231 | use super::*; |
| 232 | |
| 233 | #[test] |
| 234 | fn derivation_reproduces_nothing_dark() { |
| 235 | let t = Theme::nothing_dark(); |
| 236 | assert_eq!(t.surface, Rgba::rgb(0x111111)); |
| 237 | assert_eq!(t.surface_raised, Rgba::rgb(0x1A1A1A)); |
| 238 | assert_eq!(t.border, Rgba::rgb(0x222222)); |
| 239 | assert_eq!(t.border_visible, Rgba::rgb(0x333333)); |
| 240 | assert_eq!(t.text_disabled, Rgba::rgb(0x666666)); |
| 241 | assert_eq!(t.text_secondary, Rgba::rgb(0x999999)); |
| 242 | assert_eq!(t.text_primary, Rgba::rgb(0xE8E8E8)); |
| 243 | assert_eq!(t.text_display, Rgba::rgb(0xFFFFFF)); |
| 244 | } |
| 245 | |
| 246 | #[test] |
| 247 | fn linear_conversion() { |
| 248 | assert_eq!(Rgba::rgb(0x000000).linear(), [0.0, 0.0, 0.0, 1.0]); |
| 249 | assert_eq!(Rgba::rgb(0xFFFFFF).linear(), [1.0, 1.0, 1.0, 1.0]); |
| 250 | let mid = Rgba::rgb(0x808080).linear()[0]; |
| 251 | assert!((mid - 0.2158).abs() < 1e-3); |
| 252 | } |
| 253 | |
| 254 | #[test] |
| 255 | fn hex_parsing() { |
| 256 | assert_eq!(Rgba::parse("#D71921"), Ok(Rgba::rgb(0xD71921))); |
| 257 | assert_eq!(Rgba::parse("d71921"), Ok(Rgba::rgb(0xD71921))); |
| 258 | assert_eq!(Rgba::parse("#fff"), Ok(Rgba::rgb(0xFFFFFF))); |
| 259 | assert_eq!(Rgba::parse("#00000080"), Ok(Rgba([0, 0, 0, 0x80]))); |
| 260 | assert!(Rgba::parse("#12345").is_err()); |
| 261 | assert!(Rgba::parse("#gggggg").is_err()); |
| 262 | assert!(Rgba::parse("#ÿÿÿ").is_err()); |
| 263 | assert_eq!(Rgba::rgb(0xD71921).to_string(), "#D71921"); |
| 264 | } |
| 265 | |
| 266 | #[test] |
| 267 | fn resolution_order() { |
| 268 | let mut cfg = ThemeConfig::default(); |
| 269 | assert_eq!(resolve(&cfg, None), Theme::nothing_dark()); |
| 270 | cfg.preset = Preset::NothingLight; |
| 271 | let light = resolve(&cfg, None); |
| 272 | assert_eq!(light.background, Rgba::rgb(0xF5F5F5)); |
| 273 | assert_eq!(light.image_background, Rgba::rgb(0xF5F5F5)); |
| 274 | assert_eq!(light.text_display, Rgba::rgb(0x000000)); |
| 275 | // pywal replaces the preset; explicit colours win over pywal. |
| 276 | let wal = [ |
| 277 | Rgba::rgb(0x102030), |
| 278 | Rgba::rgb(0xF0E0D0), |
| 279 | Rgba::rgb(0x00FF00), |
| 280 | ]; |
| 281 | cfg.accent = Some(Rgba::rgb(0x0000FF)); |
| 282 | cfg.border = Some(Rgba::rgb(0x123456)); |
| 283 | let t = resolve(&cfg, Some(wal)); |
| 284 | assert_eq!(t.background, Rgba::rgb(0x102030)); |
| 285 | assert_eq!(t.accent, Rgba::rgb(0x0000FF)); |
| 286 | assert_eq!(t.border, Rgba::rgb(0x123456)); |
| 287 | assert_eq!( |
| 288 | t.surface, |
| 289 | Rgba::rgb(0xF0E0D0).mix_over(Rgba::rgb(0x102030), 0.067) |
| 290 | ); |
| 291 | } |
| 292 | |
| 293 | #[test] |
| 294 | fn mixing_themes() { |
| 295 | let a = Theme::nothing_dark(); |
| 296 | let b = resolve( |
| 297 | &ThemeConfig { |
| 298 | preset: Preset::NothingLight, |
| 299 | ..Default::default() |
| 300 | }, |
| 301 | None, |
| 302 | ); |
| 303 | assert_eq!(a.mix(&b, 0.0), a); |
| 304 | assert_eq!(a.mix(&b, 1.0), b); |
| 305 | assert_eq!(a.mix(&b, 0.5).background, Rgba([123, 123, 123, 255])); |
| 306 | } |
| 307 | |
| 308 | #[test] |
| 309 | fn pywal_import() { |
| 310 | let dir = std::env::temp_dir().join(format!("remage-wal-{}", std::process::id())); |
| 311 | std::fs::create_dir_all(&dir).unwrap(); |
| 312 | let p = dir.join("colors.json"); |
| 313 | std::fs::write( |
| 314 | &p, |
| 315 | r##"{"special":{"background":"#0b0c10","foreground":"#c5c6c7","cursor":"#c5c6c7"}, |
| 316 | "colors":{"color0":"#0b0c10","color1":"#66fcf1"}}"##, |
| 317 | ) |
| 318 | .unwrap(); |
| 319 | assert_eq!( |
| 320 | read_pywal(&p), |
| 321 | Ok([ |
| 322 | Rgba::rgb(0x0B0C10), |
| 323 | Rgba::rgb(0xC5C6C7), |
| 324 | Rgba::rgb(0x66FCF1) |
| 325 | ]) |
| 326 | ); |
| 327 | std::fs::write(&p, "{}").unwrap(); |
| 328 | assert!(read_pywal(&p).unwrap_err().contains("special.background")); |
| 329 | } |
| 330 | } |