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