1use std::collections::HashMap;
13
14#[derive(Debug, Clone, Default, fig::ToValue, fig::FromValue)]
23pub struct ColorPalette {
24 #[fig(default, skip_serializing_if = "Option::is_none")]
26 pub bg: Option<String>,
27 #[fig(default, skip_serializing_if = "Option::is_none")]
29 pub text: Option<String>,
30 #[fig(default, skip_serializing_if = "Option::is_none")]
32 pub text_muted: Option<String>,
33 #[fig(default, skip_serializing_if = "Option::is_none")]
35 pub accent: Option<String>,
36 #[fig(default, skip_serializing_if = "Option::is_none")]
38 pub accent_hover: Option<String>,
39 #[fig(default, skip_serializing_if = "Option::is_none")]
41 pub border: Option<String>,
42 #[fig(default, skip_serializing_if = "Option::is_none")]
44 pub code_bg: Option<String>,
45 #[fig(default, skip_serializing_if = "Option::is_none")]
47 pub surface_bg: Option<String>,
48 #[fig(default, skip_serializing_if = "Option::is_none")]
50 pub surface_border: Option<String>,
51 #[fig(default, skip_serializing_if = "Option::is_none")]
53 pub surface_shadow: Option<String>,
54 #[fig(default, skip_serializing_if = "Option::is_none")]
56 pub divider_color: Option<String>,
57}
58
59impl ColorPalette {
60 pub fn to_css_vars(&self) -> String {
62 let mut vars = String::new();
63 let mappings: &[(&Option<String>, &str)] = &[
64 (&self.bg, "--bg"),
65 (&self.text, "--text"),
66 (&self.text_muted, "--text-muted"),
67 (&self.accent, "--accent"),
68 (&self.accent_hover, "--accent-hover"),
69 (&self.border, "--border"),
70 (&self.code_bg, "--code-bg"),
71 (&self.surface_bg, "--surface-bg"),
72 (&self.surface_border, "--surface-border"),
73 (&self.surface_shadow, "--surface-shadow"),
74 (&self.divider_color, "--divider-color"),
75 ];
76 for (value, name) in mappings {
77 if let Some(v) = value {
78 vars.push_str(&format!(" {}: {};\n", name, v));
79 }
80 }
81 vars
82 }
83}
84
85#[derive(Debug, Clone, Default, fig::ToValue, fig::FromValue, PartialEq)]
91#[fig(rename_all = "lowercase")]
92pub enum FontFamily {
93 Inter,
95 #[default]
97 System,
98 Serif,
100 Mono,
102}
103
104impl FontFamily {
105 pub fn to_css(&self) -> &'static str {
107 match self {
108 Self::Inter => {
109 r#""Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif"#
110 }
111 Self::System => {
112 r#"-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif"#
113 }
114 Self::Serif => r#""Georgia", "Times New Roman", serif"#,
115 Self::Mono => r#""SF Mono", Monaco, "Cascadia Code", "Fira Code", monospace"#,
116 }
117 }
118
119 pub fn from_str_lossy(s: &str) -> Self {
121 match s {
122 "inter" => Self::Inter,
123 "system" => Self::System,
124 "serif" => Self::Serif,
125 "mono" => Self::Mono,
126 _ => Self::System,
127 }
128 }
129}
130
131#[derive(Debug, Clone, Default, fig::ToValue, fig::FromValue, PartialEq)]
133#[fig(rename_all = "lowercase")]
134pub enum ContentWidth {
135 Narrow,
137 #[default]
139 Medium,
140 Wide,
142 Full,
144}
145
146impl ContentWidth {
147 pub fn to_css(&self) -> &'static str {
149 match self {
150 Self::Narrow => "55ch",
151 Self::Medium => "65ch",
152 Self::Wide => "85ch",
153 Self::Full => "none",
154 }
155 }
156
157 pub fn from_str_lossy(s: &str) -> Self {
159 match s {
160 "narrow" => Self::Narrow,
161 "medium" => Self::Medium,
162 "wide" => Self::Wide,
163 "full" => Self::Full,
164 _ => Self::Medium,
165 }
166 }
167}
168
169#[derive(Debug, Clone, Default, fig::ToValue, fig::FromValue)]
173pub struct TypographySettings {
174 #[fig(default)]
176 pub font_family: FontFamily,
177 #[fig(default, skip_serializing_if = "Option::is_none")]
179 pub base_font_size: Option<f64>,
180 #[fig(default, skip_serializing_if = "Option::is_none")]
182 pub line_height: Option<f64>,
183 #[fig(default)]
185 pub content_width: ContentWidth,
186}
187
188impl TypographySettings {
189 pub fn to_css_vars(&self) -> String {
191 let mut vars = String::new();
192
193 vars.push_str(&format!(
194 " --font-family: {};\n",
195 self.font_family.to_css()
196 ));
197
198 if let Some(size) = self.base_font_size {
199 vars.push_str(&format!(" --font-size: {}px;\n", size));
200 }
201
202 if let Some(lh) = self.line_height {
203 vars.push_str(&format!(" --line-height: {};\n", lh));
204 }
205
206 vars.push_str(&format!(
207 " --content-max-width: {};\n",
208 self.content_width.to_css()
209 ));
210
211 vars
212 }
213}
214
215#[derive(Debug, Clone)]
221pub struct FaviconAsset {
222 pub filename: String,
224 pub mime_type: String,
226 pub data: Vec<u8>,
228}
229
230#[derive(Debug, Clone, Default, fig::ToValue, fig::FromValue)]
238pub struct ThemeAppearance {
239 #[fig(default, skip_serializing_if = "Option::is_none")]
241 pub id: Option<String>,
242 #[fig(default)]
244 pub light: ColorPalette,
245 #[fig(default)]
247 pub dark: ColorPalette,
248 #[fig(skip)]
250 pub favicon: Option<FaviconAsset>,
251 #[fig(default)]
253 pub typography: Option<TypographySettings>,
254}
255
256impl ThemeAppearance {
257 pub fn to_css_overrides(&self) -> String {
261 let light_vars = self.light.to_css_vars();
262 let dark_vars = self.dark.to_css_vars();
263 let typo_vars = self
264 .typography
265 .as_ref()
266 .map(|t| t.to_css_vars())
267 .unwrap_or_default();
268
269 if light_vars.is_empty() && dark_vars.is_empty() && typo_vars.is_empty() {
270 return String::new();
271 }
272
273 let mut css = String::new();
274 if !light_vars.is_empty() || !typo_vars.is_empty() {
276 css.push_str(&format!(":root {{\n{}{}}}\n", typo_vars, light_vars));
277 }
278 if !dark_vars.is_empty() {
279 css.push_str(&format!(
280 "@media (prefers-color-scheme: dark) {{\n :root {{\n{}\n }}\n}}\n",
281 dark_vars
282 ));
283 }
284 css
285 }
286
287 pub fn from_app_palette(
300 light: &HashMap<String, String>,
301 dark: &HashMap<String, String>,
302 ) -> Self {
303 Self {
304 id: None,
305 light: Self::map_palette(light),
306 dark: Self::map_palette(dark),
307 favicon: None,
308 typography: None,
309 }
310 }
311
312 pub fn generate_favicon_svg(&self) -> FaviconAsset {
314 let accent = self.light.accent.as_deref().unwrap_or("#6366f1");
315 let svg = format!(
316 r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="{}"/></svg>"#,
317 accent
318 );
319 FaviconAsset {
320 filename: "favicon.svg".to_string(),
321 mime_type: "image/svg+xml".to_string(),
322 data: svg.into_bytes(),
323 }
324 }
325
326 pub fn favicon_or_default(&self) -> FaviconAsset {
328 match &self.favicon {
329 Some(f) => f.clone(),
330 None => self.generate_favicon_svg(),
331 }
332 }
333
334 fn map_palette(colors: &HashMap<String, String>) -> ColorPalette {
335 ColorPalette {
336 bg: colors.get("background").cloned(),
337 text: colors.get("foreground").cloned(),
338 text_muted: colors.get("muted-foreground").cloned(),
339 accent: colors.get("primary").cloned(),
340 accent_hover: colors.get("ring").cloned(),
341 border: colors.get("border").cloned(),
342 code_bg: colors.get("secondary").cloned(),
343 surface_bg: colors.get("card").cloned(),
344 surface_border: colors.get("sidebar-border").cloned(),
345 surface_shadow: None,
346 divider_color: None,
347 }
348 }
349}