1use anyhow::{bail, Context, Result};
9use serde::Deserialize;
10use std::collections::HashMap;
11use std::path::Path;
12
13const SANS: &str = "-apple-system, 'Segoe UI', Inter, 'Helvetica Neue', Arial, sans-serif";
14
15#[derive(Clone, Debug)]
17pub struct Theme {
18 pub id: String,
19 pub label: String,
20 pub dark: bool,
22 pub flat: bool,
24 pub radius: u32,
26 pub font_sans: String,
27 pub font_display: String,
28 pub bg: String,
29 pub card: String,
30 pub border: String,
31 pub border_strong: String,
32 pub text: String,
33 pub muted: String,
34 pub faint: String,
35 pub accent: String,
36 pub accent_soft: Option<String>,
38 pub grid_year: String,
39 pub grid_month: String,
40 pub track: String,
41 pub release: String,
43 pub ctx_area: String,
44 pub ctx_line: String,
45}
46
47impl Theme {
48 pub fn avatar_l(&self) -> u32 {
50 if self.dark {
51 48
52 } else {
53 62
54 }
55 }
56
57 fn accent_soft(&self) -> String {
58 self.accent_soft
59 .clone()
60 .unwrap_or_else(|| rgba(&self.accent, 0.12))
61 }
62
63 fn shadow(&self) -> &'static str {
64 if self.flat {
65 "none"
66 } else if self.dark {
67 "0 1px 2px rgba(0,0,0,.4)"
68 } else {
69 "0 1px 2px rgba(16,24,40,.04), 0 1px 6px rgba(16,24,40,.05)"
70 }
71 }
72
73 fn shadow_lg(&self) -> &'static str {
74 if self.dark {
75 "0 10px 32px rgba(0,0,0,.55)"
76 } else {
77 "0 8px 28px rgba(16,24,40,.14)"
78 }
79 }
80
81 fn dim(&self) -> &'static str {
82 if self.dark {
83 "rgba(120,132,148,.14)"
84 } else {
85 "rgba(120,132,148,.18)"
86 }
87 }
88
89 pub fn css_vars(&self) -> Vec<(String, String)> {
91 vec![
92 ("--bg".into(), self.bg.clone()),
93 ("--card".into(), self.card.clone()),
94 ("--border".into(), self.border.clone()),
95 ("--border-strong".into(), self.border_strong.clone()),
96 ("--text".into(), self.text.clone()),
97 ("--muted".into(), self.muted.clone()),
98 ("--faint".into(), self.faint.clone()),
99 ("--accent".into(), self.accent.clone()),
100 ("--accent-soft".into(), self.accent_soft()),
101 ("--shadow".into(), self.shadow().into()),
102 ("--shadow-lg".into(), self.shadow_lg().into()),
103 ("--grid-year".into(), self.grid_year.clone()),
104 ("--grid-month".into(), self.grid_month.clone()),
105 ("--track".into(), self.track.clone()),
106 ("--release".into(), self.release.clone()),
107 ("--radius".into(), format!("{}px", self.radius)),
108 ("--font-sans".into(), self.font_sans.clone()),
109 ("--font-display".into(), self.font_display.clone()),
110 (
111 "color-scheme".into(),
112 if self.dark { "dark" } else { "light" }.into(),
113 ),
114 ]
115 }
116
117 pub fn chart_json(&self) -> serde_json::Value {
119 serde_json::json!({
120 "label": self.label,
121 "text": self.text,
122 "muted": self.muted,
123 "faint": self.faint,
124 "gridYear": self.grid_year,
125 "gridMonth": self.grid_month,
126 "track": self.track,
127 "release": self.release,
128 "card": self.card,
129 "ctxArea": self.ctx_area,
130 "ctxLine": self.ctx_line,
131 "dim": self.dim(),
132 "font": self.font_sans,
133 "flat": self.flat,
134 })
135 }
136
137 pub fn to_json(&self) -> serde_json::Value {
139 let css: serde_json::Map<String, serde_json::Value> = self
140 .css_vars()
141 .into_iter()
142 .map(|(k, v)| (k, serde_json::Value::String(v)))
143 .collect();
144 serde_json::json!({
145 "id": self.id,
146 "label": self.label,
147 "css": css,
148 "chart": self.chart_json(),
149 })
150 }
151}
152
153fn rgba(hex: &str, alpha: f64) -> String {
155 let h = hex.trim_start_matches('#');
156 if h.len() == 6 {
157 if let (Ok(r), Ok(g), Ok(b)) = (
158 u8::from_str_radix(&h[0..2], 16),
159 u8::from_str_radix(&h[2..4], 16),
160 u8::from_str_radix(&h[4..6], 16),
161 ) {
162 return format!("rgba({r},{g},{b},{alpha})");
163 }
164 }
165 format!("rgba(120,132,148,{alpha})")
166}
167
168pub fn builtins() -> Vec<Theme> {
170 vec![
171 Theme {
172 id: "light".into(),
173 label: "Light".into(),
174 dark: false,
175 flat: false,
176 radius: 12,
177 font_sans: SANS.into(),
178 font_display: SANS.into(),
179 bg: "#f6f7f9".into(),
180 card: "#ffffff".into(),
181 border: "#e4e7ec".into(),
182 border_strong: "#d4d9e0".into(),
183 text: "#1c2530".into(),
184 muted: "#5d6b7c".into(),
185 faint: "#98a3b1".into(),
186 accent: "#2f6feb".into(),
187 accent_soft: Some("rgba(47,111,235,.1)".into()),
188 grid_year: "#e2e6ec".into(),
189 grid_month: "#eef1f5".into(),
190 track: "#e8ebf0".into(),
191 release: "#6b7a99".into(),
192 ctx_area: "#c9d7f5".into(),
193 ctx_line: "#7d9ce8".into(),
194 },
195 Theme {
196 id: "dark".into(),
197 label: "Dark".into(),
198 dark: true,
199 flat: false,
200 radius: 12,
201 font_sans: SANS.into(),
202 font_display: SANS.into(),
203 bg: "#0d1117".into(),
204 card: "#151b23".into(),
205 border: "#262d37".into(),
206 border_strong: "#333c48".into(),
207 text: "#e6ebf2".into(),
208 muted: "#9aa7b6".into(),
209 faint: "#5f6c7b".into(),
210 accent: "#2f6feb".into(),
211 accent_soft: Some("rgba(83,140,255,.13)".into()),
212 grid_year: "#232b35".into(),
213 grid_month: "#1a212a".into(),
214 track: "#2a323d".into(),
215 release: "#8893ad".into(),
216 ctx_area: "#23344f".into(),
217 ctx_line: "#4a6da8".into(),
218 },
219 Theme {
220 id: "wikipedia".into(),
221 label: "Wikipedia".into(),
222 dark: false,
223 flat: true,
224 radius: 2,
225 font_sans: "sans-serif".into(),
226 font_display: "'Linux Libertine', Georgia, 'Times New Roman', serif".into(),
227 bg: "#ffffff".into(),
228 card: "#ffffff".into(),
229 border: "#c8ccd1".into(),
230 border_strong: "#a2a9b1".into(),
231 text: "#202122".into(),
232 muted: "#54595d".into(),
233 faint: "#72777d".into(),
234 accent: "#3366cc".into(),
235 accent_soft: Some("rgba(51,102,204,.1)".into()),
236 grid_year: "#c8ccd1".into(),
237 grid_month: "#eaecf0".into(),
238 track: "#e8e8e8".into(),
239 release: "#000000".into(),
240 ctx_area: "#cdd9f2".into(),
241 ctx_line: "#5b81d4".into(),
242 },
243 ]
244}
245
246#[derive(Deserialize, Default)]
249#[serde(deny_unknown_fields)]
250struct RawTheme {
251 label: Option<String>,
252 extends: Option<String>,
253 dark: Option<bool>,
254 flat: Option<bool>,
255 radius: Option<u32>,
256 font_sans: Option<String>,
257 font_display: Option<String>,
258 bg: Option<String>,
259 card: Option<String>,
260 border: Option<String>,
261 border_strong: Option<String>,
262 text: Option<String>,
263 muted: Option<String>,
264 faint: Option<String>,
265 accent: Option<String>,
266 accent_soft: Option<String>,
267 grid_year: Option<String>,
268 grid_month: Option<String>,
269 track: Option<String>,
270 release: Option<String>,
271 ctx_area: Option<String>,
272 ctx_line: Option<String>,
273}
274
275#[derive(Deserialize, Default)]
276#[serde(deny_unknown_fields)]
277struct RawConfig {
278 default: Option<String>,
280 available: Option<Vec<String>>,
282 lock: Option<bool>,
284 themes: Option<HashMap<String, RawTheme>>,
285}
286
287pub struct ThemeSet {
289 pub all: Vec<Theme>,
291 pub custom: Vec<Theme>,
293 pub order: Vec<String>,
295 pub default: Option<String>,
297 pub lock: bool,
299}
300
301impl Default for ThemeSet {
302 fn default() -> Self {
303 let all = builtins();
304 let order = all.iter().map(|t| t.id.clone()).collect();
305 ThemeSet {
306 all,
307 custom: Vec::new(),
308 order,
309 default: None,
310 lock: false,
311 }
312 }
313}
314
315impl ThemeSet {
316 pub fn get(&self, id: &str) -> Option<&Theme> {
317 self.all.iter().find(|t| t.id == id)
318 }
319}
320
321fn resolve(id: &str, raw: &RawTheme, base: &Theme) -> Theme {
322 Theme {
323 id: id.to_string(),
324 label: raw.label.clone().unwrap_or_else(|| id.to_string()),
325 dark: raw.dark.unwrap_or(base.dark),
326 flat: raw.flat.unwrap_or(base.flat),
327 radius: raw.radius.unwrap_or(base.radius),
328 font_sans: raw
329 .font_sans
330 .clone()
331 .unwrap_or_else(|| base.font_sans.clone()),
332 font_display: raw
333 .font_display
334 .clone()
335 .unwrap_or_else(|| base.font_display.clone()),
336 bg: raw.bg.clone().unwrap_or_else(|| base.bg.clone()),
337 card: raw.card.clone().unwrap_or_else(|| base.card.clone()),
338 border: raw.border.clone().unwrap_or_else(|| base.border.clone()),
339 border_strong: raw
340 .border_strong
341 .clone()
342 .unwrap_or_else(|| base.border_strong.clone()),
343 text: raw.text.clone().unwrap_or_else(|| base.text.clone()),
344 muted: raw.muted.clone().unwrap_or_else(|| base.muted.clone()),
345 faint: raw.faint.clone().unwrap_or_else(|| base.faint.clone()),
346 accent: raw.accent.clone().unwrap_or_else(|| base.accent.clone()),
347 accent_soft: raw.accent_soft.clone().or_else(|| {
350 if raw.accent.is_none() {
351 base.accent_soft.clone()
352 } else {
353 None
354 }
355 }),
356 grid_year: raw
357 .grid_year
358 .clone()
359 .unwrap_or_else(|| base.grid_year.clone()),
360 grid_month: raw
361 .grid_month
362 .clone()
363 .unwrap_or_else(|| base.grid_month.clone()),
364 track: raw.track.clone().unwrap_or_else(|| base.track.clone()),
365 release: raw.release.clone().unwrap_or_else(|| base.release.clone()),
366 ctx_area: raw
367 .ctx_area
368 .clone()
369 .unwrap_or_else(|| base.ctx_area.clone()),
370 ctx_line: raw
371 .ctx_line
372 .clone()
373 .unwrap_or_else(|| base.ctx_line.clone()),
374 }
375}
376
377pub fn load_config(path: &Path) -> Result<ThemeSet> {
379 let text =
380 std::fs::read_to_string(path).with_context(|| format!("cannot read {}", path.display()))?;
381 let raw: RawConfig = serde_json::from_str(&text)
382 .with_context(|| format!("invalid theme config {}", path.display()))?;
383
384 let builtin = builtins();
385 let mut all = builtin.clone();
386 let mut custom = Vec::new();
387
388 if let Some(themes) = &raw.themes {
389 let mut ids: Vec<&String> = themes.keys().collect();
391 ids.sort();
392 for id in ids {
393 let rt = &themes[id];
394 if builtin.iter().any(|t| &t.id == id) {
395 bail!("theme id '{id}' shadows a built-in theme; pick another id");
396 }
397 let base = match &rt.extends {
398 Some(e) => all
399 .iter()
400 .find(|t| &t.id == e)
401 .cloned()
402 .with_context(|| format!("theme '{id}' extends unknown theme '{e}'"))?,
403 None => builtin[0].clone(), };
405 let theme = resolve(id, rt, &base);
406 all.push(theme.clone());
407 custom.push(theme);
408 }
409 }
410
411 let order = match &raw.available {
412 Some(list) => {
413 for id in list {
414 if !all.iter().any(|t| &t.id == id) {
415 bail!("'available' lists unknown theme '{id}'");
416 }
417 }
418 list.clone()
419 }
420 None => all.iter().map(|t| t.id.clone()).collect(),
421 };
422
423 if let Some(d) = &raw.default {
424 if !all.iter().any(|t| &t.id == d) {
425 bail!("'default' theme '{d}' is not defined");
426 }
427 }
428
429 Ok(ThemeSet {
430 all,
431 custom,
432 order,
433 default: raw.default.clone(),
434 lock: raw.lock.unwrap_or(false),
435 })
436}