tailwind_rs_core/
theme.rs

1//! Theme system for tailwind-rs
2
3use crate::error::{Result, TailwindError};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7/// Represents a color value in the theme system
8#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
9pub enum Color {
10    /// Hex color value (e.g., "#3b82f6")
11    Hex(String),
12    /// RGB color value
13    Rgb { r: u8, g: u8, b: u8 },
14    /// RGBA color value
15    Rgba { r: u8, g: u8, b: u8, a: f32 },
16    /// HSL color value
17    Hsl { h: f32, s: f32, l: f32 },
18    /// HSLA color value
19    Hsla { h: f32, s: f32, l: f32, a: f32 },
20    /// Named color reference
21    Named(String),
22}
23
24impl Color {
25    /// Create a new hex color
26    pub fn hex(value: impl Into<String>) -> Self {
27        Self::Hex(value.into())
28    }
29
30    /// Create a new RGB color
31    pub fn rgb(r: u8, g: u8, b: u8) -> Self {
32        Self::Rgb { r, g, b }
33    }
34
35    /// Create a new RGBA color
36    pub fn rgba(r: u8, g: u8, b: u8, a: f32) -> Self {
37        Self::Rgba { r, g, b, a }
38    }
39
40    /// Create a new HSL color
41    pub fn hsl(h: f32, s: f32, l: f32) -> Self {
42        Self::Hsl { h, s, l }
43    }
44
45    /// Create a new HSLA color
46    pub fn hsla(h: f32, s: f32, l: f32, a: f32) -> Self {
47        Self::Hsla { h, s, l, a }
48    }
49
50    /// Create a new named color
51    pub fn named(name: impl Into<String>) -> Self {
52        Self::Named(name.into())
53    }
54
55    /// Convert color to CSS string
56    pub fn to_css(&self) -> String {
57        match self {
58            Color::Hex(value) => value.clone(),
59            Color::Rgb { r, g, b } => format!("rgb({}, {}, {})", r, g, b),
60            Color::Rgba { r, g, b, a } => format!("rgba({}, {}, {}, {})", r, g, b, a),
61            Color::Hsl { h, s, l } => format!("hsl({}, {}%, {}%)", h, s * 100.0, l * 100.0),
62            Color::Hsla { h, s, l, a } => {
63                format!("hsla({}, {}%, {}%, {})", h, s * 100.0, l * 100.0, a)
64            }
65            Color::Named(name) => format!("var(--color-{})", name),
66        }
67    }
68}
69
70/// Represents a spacing value in the theme system
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72pub enum Spacing {
73    /// Pixel value
74    Px(f32),
75    /// Rem value
76    Rem(f32),
77    /// Em value
78    Em(f32),
79    /// Percentage value
80    Percent(f32),
81    /// Viewport width percentage
82    Vw(f32),
83    /// Viewport height percentage
84    Vh(f32),
85    /// Named spacing reference
86    Named(String),
87}
88
89impl Spacing {
90    /// Create a new pixel spacing
91    pub fn px(value: f32) -> Self {
92        Self::Px(value)
93    }
94
95    /// Create a new rem spacing
96    pub fn rem(value: f32) -> Self {
97        Self::Rem(value)
98    }
99
100    /// Create a new em spacing
101    pub fn em(value: f32) -> Self {
102        Self::Em(value)
103    }
104
105    /// Create a new percentage spacing
106    pub fn percent(value: f32) -> Self {
107        Self::Percent(value)
108    }
109
110    /// Create a new viewport width spacing
111    pub fn vw(value: f32) -> Self {
112        Self::Vw(value)
113    }
114
115    /// Create a new viewport height spacing
116    pub fn vh(value: f32) -> Self {
117        Self::Vh(value)
118    }
119
120    /// Create a new named spacing
121    pub fn named(name: impl Into<String>) -> Self {
122        Self::Named(name.into())
123    }
124
125    /// Convert spacing to CSS string
126    pub fn to_css(&self) -> String {
127        match self {
128            Spacing::Px(value) => format!("{}px", value),
129            Spacing::Rem(value) => format!("{}rem", value),
130            Spacing::Em(value) => format!("{}em", value),
131            Spacing::Percent(value) => format!("{}%", value),
132            Spacing::Vw(value) => format!("{}vw", value),
133            Spacing::Vh(value) => format!("{}vh", value),
134            Spacing::Named(name) => format!("var(--spacing-{})", name),
135        }
136    }
137}
138
139/// Represents a border radius value in the theme system
140#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
141pub enum BorderRadius {
142    /// Pixel value
143    Px(f32),
144    /// Rem value
145    Rem(f32),
146    /// Percentage value
147    Percent(f32),
148    /// Named border radius reference
149    Named(String),
150}
151
152impl BorderRadius {
153    /// Create a new pixel border radius
154    pub fn px(value: f32) -> Self {
155        Self::Px(value)
156    }
157
158    /// Create a new rem border radius
159    pub fn rem(value: f32) -> Self {
160        Self::Rem(value)
161    }
162
163    /// Create a new percentage border radius
164    pub fn percent(value: f32) -> Self {
165        Self::Percent(value)
166    }
167
168    /// Create a new named border radius
169    pub fn named(name: impl Into<String>) -> Self {
170        Self::Named(name.into())
171    }
172
173    /// Convert border radius to CSS string
174    pub fn to_css(&self) -> String {
175        match self {
176            BorderRadius::Px(value) => format!("{}px", value),
177            BorderRadius::Rem(value) => format!("{}rem", value),
178            BorderRadius::Percent(value) => format!("{}%", value),
179            BorderRadius::Named(name) => format!("var(--border-radius-{})", name),
180        }
181    }
182}
183
184/// Represents a box shadow value in the theme system
185#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
186pub struct BoxShadow {
187    pub offset_x: f32,
188    pub offset_y: f32,
189    pub blur_radius: f32,
190    pub spread_radius: f32,
191    pub color: Color,
192    pub inset: bool,
193}
194
195impl BoxShadow {
196    /// Create a new box shadow
197    pub fn new(
198        offset_x: f32,
199        offset_y: f32,
200        blur_radius: f32,
201        spread_radius: f32,
202        color: Color,
203        inset: bool,
204    ) -> Self {
205        Self {
206            offset_x,
207            offset_y,
208            blur_radius,
209            spread_radius,
210            color,
211            inset,
212        }
213    }
214
215    /// Convert box shadow to CSS string
216    pub fn to_css(&self) -> String {
217        let inset = if self.inset { "inset " } else { "" };
218        format!(
219            "{}box-shadow: {}px {}px {}px {}px {}",
220            inset,
221            self.offset_x,
222            self.offset_y,
223            self.blur_radius,
224            self.spread_radius,
225            self.color.to_css()
226        )
227    }
228}
229
230/// Represents a theme value that can be any of the theme types
231#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
232pub enum ThemeValue {
233    Color(Color),
234    Spacing(Spacing),
235    BorderRadius(BorderRadius),
236    BoxShadow(BoxShadow),
237    String(String),
238    Number(f32),
239    Boolean(bool),
240}
241
242/// Main theme structure
243#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
244pub struct Theme {
245    pub name: String,
246    pub colors: HashMap<String, Color>,
247    pub spacing: HashMap<String, Spacing>,
248    pub border_radius: HashMap<String, BorderRadius>,
249    pub box_shadows: HashMap<String, BoxShadow>,
250    pub custom: HashMap<String, ThemeValue>,
251}
252
253impl Theme {
254    /// Create a new theme
255    pub fn new(name: impl Into<String>) -> Self {
256        Self {
257            name: name.into(),
258            colors: HashMap::new(),
259            spacing: HashMap::new(),
260            border_radius: HashMap::new(),
261            box_shadows: HashMap::new(),
262            custom: HashMap::new(),
263        }
264    }
265
266    /// Add a color to the theme
267    pub fn add_color(&mut self, name: impl Into<String>, color: Color) {
268        self.colors.insert(name.into(), color);
269    }
270
271    /// Add spacing to the theme
272    pub fn add_spacing(&mut self, name: impl Into<String>, spacing: Spacing) {
273        self.spacing.insert(name.into(), spacing);
274    }
275
276    /// Add border radius to the theme
277    pub fn add_border_radius(&mut self, name: impl Into<String>, radius: BorderRadius) {
278        self.border_radius.insert(name.into(), radius);
279    }
280
281    /// Add box shadow to the theme
282    pub fn add_box_shadow(&mut self, name: impl Into<String>, shadow: BoxShadow) {
283        self.box_shadows.insert(name.into(), shadow);
284    }
285
286    /// Add custom value to the theme
287    pub fn add_custom(&mut self, name: impl Into<String>, value: ThemeValue) {
288        self.custom.insert(name.into(), value);
289    }
290
291    /// Get a color from the theme
292    pub fn get_color(&self, name: &str) -> Result<&Color> {
293        self.colors.get(name).ok_or_else(|| {
294            TailwindError::theme(format!(
295                "Color '{}' not found in theme '{}'",
296                name, self.name
297            ))
298        })
299    }
300
301    /// Get spacing from the theme
302    pub fn get_spacing(&self, name: &str) -> Result<&Spacing> {
303        self.spacing.get(name).ok_or_else(|| {
304            TailwindError::theme(format!(
305                "Spacing '{}' not found in theme '{}'",
306                name, self.name
307            ))
308        })
309    }
310
311    /// Get border radius from the theme
312    pub fn get_border_radius(&self, name: &str) -> Result<&BorderRadius> {
313        self.border_radius.get(name).ok_or_else(|| {
314            TailwindError::theme(format!(
315                "Border radius '{}' not found in theme '{}'",
316                name, self.name
317            ))
318        })
319    }
320
321    /// Get box shadow from the theme
322    pub fn get_box_shadow(&self, name: &str) -> Result<&BoxShadow> {
323        self.box_shadows.get(name).ok_or_else(|| {
324            TailwindError::theme(format!(
325                "Box shadow '{}' not found in theme '{}'",
326                name, self.name
327            ))
328        })
329    }
330
331    /// Get custom value from the theme
332    pub fn get_custom(&self, name: &str) -> Result<&ThemeValue> {
333        self.custom.get(name).ok_or_else(|| {
334            TailwindError::theme(format!(
335                "Custom value '{}' not found in theme '{}'",
336                name, self.name
337            ))
338        })
339    }
340}
341
342/// Create a default theme with common values
343pub fn create_default_theme() -> Theme {
344    let mut theme = Theme::new("default");
345
346    // Add default colors
347    theme.add_color("primary", Color::hex("#3b82f6"));
348    theme.add_color("secondary", Color::hex("#64748b"));
349    theme.add_color("success", Color::hex("#10b981"));
350    theme.add_color("warning", Color::hex("#f59e0b"));
351    theme.add_color("error", Color::hex("#ef4444"));
352    theme.add_color("white", Color::hex("#ffffff"));
353    theme.add_color("black", Color::hex("#000000"));
354    theme.add_color("gray-100", Color::hex("#f3f4f6"));
355    theme.add_color("gray-500", Color::hex("#6b7280"));
356    theme.add_color("gray-900", Color::hex("#111827"));
357
358    // Add default spacing
359    theme.add_spacing("xs", Spacing::rem(0.25));
360    theme.add_spacing("sm", Spacing::rem(0.5));
361    theme.add_spacing("md", Spacing::rem(1.0));
362    theme.add_spacing("lg", Spacing::rem(1.5));
363    theme.add_spacing("xl", Spacing::rem(2.0));
364    theme.add_spacing("2xl", Spacing::rem(3.0));
365
366    // Add default border radius
367    theme.add_border_radius("sm", BorderRadius::rem(0.125));
368    theme.add_border_radius("md", BorderRadius::rem(0.375));
369    theme.add_border_radius("lg", BorderRadius::rem(0.5));
370    theme.add_border_radius("xl", BorderRadius::rem(0.75));
371    theme.add_border_radius("full", BorderRadius::percent(50.0));
372
373    // Add default box shadows
374    theme.add_box_shadow(
375        "sm",
376        BoxShadow::new(0.0, 1.0, 2.0, 0.0, Color::hex("#000000"), false),
377    );
378    theme.add_box_shadow(
379        "md",
380        BoxShadow::new(0.0, 4.0, 6.0, -1.0, Color::hex("#000000"), false),
381    );
382    theme.add_box_shadow(
383        "lg",
384        BoxShadow::new(0.0, 10.0, 15.0, -3.0, Color::hex("#000000"), false),
385    );
386
387    theme
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    #[test]
395    fn test_color_creation() {
396        let hex_color = Color::hex("#3b82f6");
397        assert_eq!(hex_color, Color::Hex("#3b82f6".to_string()));
398
399        let rgb_color = Color::rgb(59, 130, 246);
400        assert_eq!(
401            rgb_color,
402            Color::Rgb {
403                r: 59,
404                g: 130,
405                b: 246
406            }
407        );
408
409        let named_color = Color::named("primary");
410        assert_eq!(named_color, Color::Named("primary".to_string()));
411    }
412
413    #[test]
414    fn test_color_to_css() {
415        let hex_color = Color::hex("#3b82f6");
416        assert_eq!(hex_color.to_css(), "#3b82f6");
417
418        let rgb_color = Color::rgb(59, 130, 246);
419        assert_eq!(rgb_color.to_css(), "rgb(59, 130, 246)");
420
421        let named_color = Color::named("primary");
422        assert_eq!(named_color.to_css(), "var(--color-primary)");
423    }
424
425    #[test]
426    fn test_spacing_creation() {
427        let px_spacing = Spacing::px(16.0);
428        assert_eq!(px_spacing, Spacing::Px(16.0));
429
430        let rem_spacing = Spacing::rem(1.0);
431        assert_eq!(rem_spacing, Spacing::Rem(1.0));
432
433        let named_spacing = Spacing::named("md");
434        assert_eq!(named_spacing, Spacing::Named("md".to_string()));
435    }
436
437    #[test]
438    fn test_spacing_to_css() {
439        let px_spacing = Spacing::px(16.0);
440        assert_eq!(px_spacing.to_css(), "16px");
441
442        let rem_spacing = Spacing::rem(1.0);
443        assert_eq!(rem_spacing.to_css(), "1rem");
444
445        let named_spacing = Spacing::named("md");
446        assert_eq!(named_spacing.to_css(), "var(--spacing-md)");
447    }
448
449    #[test]
450    fn test_theme_creation() {
451        let mut theme = Theme::new("test");
452        assert_eq!(theme.name, "test");
453
454        theme.add_color("primary", Color::hex("#3b82f6"));
455        assert!(theme.colors.contains_key("primary"));
456
457        let color = theme.get_color("primary").unwrap();
458        assert_eq!(color, &Color::hex("#3b82f6"));
459    }
460
461    #[test]
462    fn test_theme_error_handling() {
463        let theme = Theme::new("test");
464        let result = theme.get_color("nonexistent");
465        assert!(result.is_err());
466
467        if let Err(TailwindError::Theme { message }) = result {
468            assert!(message.contains("Color 'nonexistent' not found"));
469        }
470    }
471
472    #[test]
473    fn test_default_theme() {
474        let theme = create_default_theme();
475        assert_eq!(theme.name, "default");
476
477        // Test that default colors exist
478        assert!(theme.get_color("primary").is_ok());
479        assert!(theme.get_color("secondary").is_ok());
480        assert!(theme.get_color("success").is_ok());
481
482        // Test that default spacing exists
483        assert!(theme.get_spacing("sm").is_ok());
484        assert!(theme.get_spacing("md").is_ok());
485        assert!(theme.get_spacing("lg").is_ok());
486
487        // Test that default border radius exists
488        assert!(theme.get_border_radius("sm").is_ok());
489        assert!(theme.get_border_radius("md").is_ok());
490        assert!(theme.get_border_radius("lg").is_ok());
491    }
492}