dampen_core/codegen/
theme.rs

1//! Theme code generation for production builds
2//!
3//! This module generates static Rust code from theme definitions.
4//! The generated code allows themes to be compiled directly into the binary
5//! with zero runtime parsing overhead.
6
7use super::GeneratedCode;
8use crate::ir::style::Color;
9use crate::ir::theme::ThemeDocument;
10
11/// Generate Rust code for a theme document
12///
13/// This function generates a Rust module containing functions to access
14/// themes at runtime without any parsing overhead.
15///
16/// # Arguments
17///
18/// * `document` - The parsed theme document
19/// * `module_name` - Name for the generated module (e.g., "app" → app_theme module)
20///
21/// # Returns
22///
23/// Ok(GeneratedCode) with the generated Rust code, or an error if validation fails
24///
25/// # Example Output
26///
27/// ```rust,ignore
28/// // Generated theme code
29/// pub fn app_theme() -> iced::Theme {
30///     app_default_theme()
31/// }
32///
33/// pub fn app_themes() -> HashMap<&'static str, iced::Theme> {
34///     let mut themes = HashMap::new();
35///     themes.insert("light", app_theme_light());
36///     themes.insert("dark", app_theme_dark());
37///     themes
38/// }
39///
40/// fn app_theme_light() -> iced::Theme {
41///     iced::Theme::custom(
42///         "light".to_string(),
43///         iced::theme::Palette {
44///             background: iced::Color::from_rgb8(0xEC, 0xF0, 0xF1),
45///             text: iced::Color::from_rgb8(0x2C, 0x3E, 0x50),
46///             primary: iced::Color::from_rgb8(0x34, 0x98, 0xDB),
47///             success: iced::Color::from_rgb8(0x27, 0xAE, 0x60),
48///             warning: iced::Color::from_rgb8(0xF3, 0x9C, 0x12),
49///             danger: iced::Color::from_rgb8(0xE7, 0x4C, 0x3C),
50///         }
51///     )
52/// }
53/// ```
54pub fn generate_theme_code(
55    document: &ThemeDocument,
56    module_name: &str,
57) -> Result<GeneratedCode, String> {
58    if document.themes.is_empty() {
59        return Err("THEME_001: At least one theme must be defined".to_string());
60    }
61
62    let mut code = String::new();
63
64    code.push_str("//! Generated theme code - DO NOT EDIT\n");
65    code.push_str("//! This file is auto-generated by the dampen codegen.\n\n");
66
67    code.push_str("use std::collections::HashMap;\n");
68    code.push_str("use iced::Color;\n");
69    code.push_str("use iced::Theme;\n\n");
70
71    code.push_str("/// Get the theme by name, or the default if not found\n");
72    code.push_str(&format!("pub fn {}_theme() -> Theme {{\n", module_name));
73    code.push_str(&format!("    {}_default_theme()\n", module_name));
74    code.push_str("}\n\n");
75
76    code.push_str("/// Get a specific theme by name\n");
77    code.push_str(&format!(
78        "pub fn {}_theme_named(name: &str) -> Option<Theme> {{\n",
79        module_name
80    ));
81    code.push_str(&format!("    let themes = {}_themes();\n", module_name));
82    code.push_str("    themes.get(name).cloned()\n");
83    code.push_str("}\n\n");
84
85    code.push_str("/// Get all available themes\n");
86    code.push_str(&format!(
87        "pub fn {}_themes() -> HashMap<&'static str, Theme> {{\n",
88        module_name
89    ));
90    code.push_str("    let mut themes = HashMap::new();\n");
91
92    let mut theme_names: Vec<&str> = document.themes.keys().map(|s| s.as_str()).collect();
93    theme_names.sort();
94
95    for theme_name in &theme_names {
96        code.push_str(&format!(
97            "    themes.insert(\"{}\", {}_{}());\n",
98            theme_name, module_name, theme_name
99        ));
100    }
101
102    code.push_str("    themes\n");
103    code.push_str("}\n\n");
104
105    code.push_str("/// Get the default theme\n");
106    code.push_str(&format!(
107        "pub fn {}_default_theme() -> Theme {{\n",
108        module_name
109    ));
110
111    let effective_default = document.effective_default(None);
112    code.push_str(&format!("    {}_{}()\n", module_name, effective_default));
113    code.push_str("}\n\n");
114
115    code.push_str("/// Get the default theme name as a string\n");
116    code.push_str(&format!(
117        "pub fn {}_default_theme_name() -> &'static str {{\n",
118        module_name
119    ));
120    code.push_str(&format!("    \"{}\"\n", effective_default));
121    code.push_str("}\n\n");
122
123    code.push_str("/// Get whether the theme follows system preference\n");
124    code.push_str(&format!(
125        "pub fn {}_follows_system() -> bool {{\n",
126        module_name
127    ));
128    code.push_str(&format!("    {}\n", document.follow_system));
129    code.push_str("}\n\n");
130
131    for theme_name in &theme_names {
132        let theme = match document.themes.get(*theme_name) {
133            Some(t) => t,
134            None => continue,
135        };
136
137        let theme_fn_name = format!("{}_{}", module_name, theme_name);
138        code.push_str(&format!("/// Theme: {}\n", theme_name));
139        code.push_str("fn ");
140        code.push_str(&theme_fn_name);
141        code.push_str("() -> Theme {\n");
142
143        let palette = &theme.palette;
144        let primary = color_to_rgb8_tuple(palette.primary.as_ref());
145        let background = color_to_rgb8_tuple(palette.background.as_ref());
146        let text = color_to_rgb8_tuple(palette.text.as_ref());
147        let success = color_to_rgb8_tuple(palette.success.as_ref());
148        let warning = color_to_rgb8_tuple(palette.warning.as_ref());
149        let danger = color_to_rgb8_tuple(palette.danger.as_ref());
150
151        code.push_str("    Theme::custom(\n");
152        code.push_str(&format!("        \"{}\".to_string(),\n", theme_name));
153        code.push_str("        iced::theme::Palette {\n");
154        code.push_str(&format!(
155            "            background: iced::Color::from_rgb8(0x{:02X}, 0x{:02X}, 0x{:02X}),\n",
156            (background.0 * 255.0) as u8,
157            (background.1 * 255.0) as u8,
158            (background.2 * 255.0) as u8
159        ));
160        code.push_str(&format!(
161            "            text: iced::Color::from_rgb8(0x{:02X}, 0x{:02X}, 0x{:02X}),\n",
162            (text.0 * 255.0) as u8,
163            (text.1 * 255.0) as u8,
164            (text.2 * 255.0) as u8
165        ));
166        code.push_str(&format!(
167            "            primary: iced::Color::from_rgb8(0x{:02X}, 0x{:02X}, 0x{:02X}),\n",
168            (primary.0 * 255.0) as u8,
169            (primary.1 * 255.0) as u8,
170            (primary.2 * 255.0) as u8
171        ));
172        code.push_str(&format!(
173            "            success: iced::Color::from_rgb8(0x{:02X}, 0x{:02X}, 0x{:02X}),\n",
174            (success.0 * 255.0) as u8,
175            (success.1 * 255.0) as u8,
176            (success.2 * 255.0) as u8
177        ));
178        code.push_str(&format!(
179            "            warning: iced::Color::from_rgb8(0x{:02X}, 0x{:02X}, 0x{:02X}),\n",
180            (warning.0 * 255.0) as u8,
181            (warning.1 * 255.0) as u8,
182            (warning.2 * 255.0) as u8
183        ));
184        code.push_str(&format!(
185            "            danger: iced::Color::from_rgb8(0x{:02X}, 0x{:02X}, 0x{:02X}),\n",
186            (danger.0 * 255.0) as u8,
187            (danger.1 * 255.0) as u8,
188            (danger.2 * 255.0) as u8
189        ));
190        code.push_str("        }\n");
191        code.push_str("    )\n");
192        code.push_str("}\n\n");
193    }
194
195    let source_file = format!("{}/theme.dampen", module_name);
196    Ok(GeneratedCode::new(
197        code,
198        format!("{}_theme", module_name),
199        std::path::PathBuf::from(source_file),
200    ))
201}
202
203/// Convert a color to RGB tuple (0.0-1.0 range)
204fn color_to_rgb8_tuple(color: Option<&Color>) -> (f32, f32, f32) {
205    match color {
206        Some(c) => (c.r, c.g, c.b),
207        None => (0.0, 0.0, 0.0),
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::ir::style::Color;
215    use crate::ir::theme::{SpacingScale, Theme, ThemePalette, Typography};
216
217    fn create_test_palette_with_hex(hex: &str) -> ThemePalette {
218        ThemePalette {
219            primary: Some(Color::from_hex(hex).unwrap()),
220            secondary: Some(Color::from_hex("#2ecc71").unwrap()),
221            success: Some(Color::from_hex("#27ae60").unwrap()),
222            warning: Some(Color::from_hex("#f39c12").unwrap()),
223            danger: Some(Color::from_hex("#e74c3c").unwrap()),
224            background: Some(Color::from_hex("#ecf0f1").unwrap()),
225            surface: Some(Color::from_hex("#ffffff").unwrap()),
226            text: Some(Color::from_hex("#2c3e50").unwrap()),
227            text_secondary: Some(Color::from_hex("#7f8c8d").unwrap()),
228        }
229    }
230
231    fn create_test_theme(name: &str, primary_hex: &str) -> Theme {
232        Theme {
233            name: name.to_string(),
234            palette: create_test_palette_with_hex(primary_hex),
235            typography: Typography {
236                font_family: Some("sans-serif".to_string()),
237                font_size_base: Some(16.0),
238                font_size_small: Some(12.0),
239                font_size_large: Some(24.0),
240                font_weight: crate::ir::theme::FontWeight::Normal,
241                line_height: Some(1.5),
242            },
243            spacing: SpacingScale { unit: Some(8.0) },
244            base_styles: std::collections::HashMap::new(),
245            extends: None,
246        }
247    }
248
249    #[test]
250    fn test_generate_theme_code_basic() {
251        let doc = ThemeDocument {
252            themes: std::collections::HashMap::from([(
253                "light".to_string(),
254                create_test_theme("light", "#3498db"),
255            )]),
256            default_theme: Some("light".to_string()),
257            follow_system: false,
258        };
259
260        let result = generate_theme_code(&doc, "test");
261
262        assert!(result.is_ok());
263        let code = result.unwrap().code;
264
265        assert!(code.contains("pub fn test_theme()"));
266        assert!(code.contains("pub fn test_themes()"));
267        assert!(code.contains("pub fn test_default_theme()"));
268        assert!(code.contains("fn test_light()"));
269        assert!(code.contains("Theme::custom"));
270        assert!(code.contains("Color::from_rgb8"));
271    }
272
273    #[test]
274    fn test_generate_theme_code_multiple_themes() {
275        let doc = ThemeDocument {
276            themes: std::collections::HashMap::from([
277                ("light".to_string(), create_test_theme("light", "#3498db")),
278                ("dark".to_string(), create_test_theme("dark", "#5dade2")),
279            ]),
280            default_theme: Some("light".to_string()),
281            follow_system: true,
282        };
283
284        let result = generate_theme_code(&doc, "app");
285
286        assert!(result.is_ok());
287        let code = result.unwrap().code;
288
289        assert!(code.contains("fn app_light()"));
290        assert!(code.contains("fn app_dark()"));
291        assert!(code.contains("themes.insert(\"light\""));
292        assert!(code.contains("themes.insert(\"dark\""));
293    }
294
295    #[test]
296    fn test_generate_theme_code_empty_themes_error() {
297        let doc = ThemeDocument {
298            themes: std::collections::HashMap::new(),
299            default_theme: None,
300            follow_system: true,
301        };
302
303        let result = generate_theme_code(&doc, "app");
304
305        assert!(result.is_err());
306        let err = result.unwrap_err();
307        assert!(err.contains("THEME_001") || err.contains("no themes"));
308    }
309
310    #[test]
311    fn test_generate_theme_code_valid_rust_syntax() {
312        let doc = ThemeDocument {
313            themes: std::collections::HashMap::from([(
314                "test".to_string(),
315                create_test_theme("test", "#ff0000"),
316            )]),
317            default_theme: Some("test".to_string()),
318            follow_system: false,
319        };
320
321        let result = generate_theme_code(&doc, "test");
322
323        assert!(result.is_ok());
324        let code = result.unwrap().code;
325
326        let parsed = syn::parse_file(&code);
327        assert!(
328            parsed.is_ok(),
329            "Generated code should be valid Rust syntax: {:?}",
330            parsed.err()
331        );
332    }
333
334    #[test]
335    fn test_generate_theme_code_contains_color_values() {
336        let doc = ThemeDocument {
337            themes: std::collections::HashMap::from([(
338                "custom".to_string(),
339                create_test_theme("custom", "#AABBCC"),
340            )]),
341            default_theme: Some("custom".to_string()),
342            follow_system: false,
343        };
344
345        let result = generate_theme_code(&doc, "myapp");
346
347        assert!(result.is_ok());
348        let code = result.unwrap().code;
349
350        assert!(
351            code.contains("0xAA") || code.contains("0xBB") || code.contains("0xCC"),
352            "Generated code should contain the color values"
353        );
354    }
355}