Krolyxon
Tag v0.1.1

Branches

Tags

Refs →

remagesrcconfigtheme.rs

330 lines · 10.4 KB · Rust

1//! Colour tokens, presets, palette derivation and pywal import.
2
3use std::path::Path;
4
5use serde::{Deserialize, Deserializer, Serialize, Serializer};
6
7/// An sRGB colour with straight alpha.
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub struct Rgba(pub [u8; 4]);
10
11impl 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
69impl 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
80impl<'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
87impl 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.
94macro_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
127tokens!(
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")]
146pub enum Preset {
147 #[default]
148 NothingDark,
149 NothingLight,
150}
151
152impl 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
170impl 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.
201pub 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`.
212pub 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)]
230mod 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}