Skip to main content

astrelis_ui/
theme.rs

1//! Theme system for consistent UI styling.
2//!
3//! Provides centralized color palettes, typography, spacing, and shape definitions
4//! for building cohesive user interfaces.
5//!
6//! # Example
7//!
8//! ```ignore
9//! use astrelis_ui::*;
10//!
11//! // Use built-in dark theme
12//! let theme = Theme::dark();
13//!
14//! // Or create a custom theme
15//! let custom = Theme::builder()
16//!     .primary(Color::from_rgb_u8(60, 120, 200))
17//!     .secondary(Color::from_rgb_u8(100, 180, 100))
18//!     .build();
19//!
20//! // Apply theme to UI system
21//! ui_system.set_theme(custom);
22//!
23//! // Widgets automatically use theme colors
24//! let button = Button::new("Click").color_role(ColorRole::Primary);
25//! ```
26
27use astrelis_render::Color;
28
29/// Color role for semantic color assignment.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31pub enum ColorRole {
32    /// Primary brand color
33    Primary,
34    /// Secondary brand color
35    Secondary,
36    /// Background color
37    Background,
38    /// Surface color (cards, panels)
39    Surface,
40    /// Error/danger color
41    Error,
42    /// Warning color
43    Warning,
44    /// Success color
45    Success,
46    /// Info color
47    Info,
48    /// Primary text color
49    TextPrimary,
50    /// Secondary/muted text color
51    TextSecondary,
52    /// Disabled text color
53    TextDisabled,
54    /// Border color
55    Border,
56    /// Divider color
57    Divider,
58}
59
60/// Color palette for a theme.
61#[derive(Debug, Clone)]
62pub struct ColorPalette {
63    /// Primary brand color
64    pub primary: Color,
65    /// Secondary brand color
66    pub secondary: Color,
67    /// Background color
68    pub background: Color,
69    /// Surface color (cards, panels, elevated elements)
70    pub surface: Color,
71    /// Error/danger color
72    pub error: Color,
73    /// Warning color
74    pub warning: Color,
75    /// Success color
76    pub success: Color,
77    /// Info color
78    pub info: Color,
79    /// Primary text color
80    pub text_primary: Color,
81    /// Secondary/muted text color
82    pub text_secondary: Color,
83    /// Disabled text color
84    pub text_disabled: Color,
85    /// Border color
86    pub border: Color,
87    /// Divider color
88    pub divider: Color,
89    /// Hover overlay color (applied on top of elements)
90    pub hover_overlay: Color,
91    /// Active/pressed overlay color
92    pub active_overlay: Color,
93}
94
95impl ColorPalette {
96    /// Get a color by its role.
97    pub fn get(&self, role: ColorRole) -> Color {
98        match role {
99            ColorRole::Primary => self.primary,
100            ColorRole::Secondary => self.secondary,
101            ColorRole::Background => self.background,
102            ColorRole::Surface => self.surface,
103            ColorRole::Error => self.error,
104            ColorRole::Warning => self.warning,
105            ColorRole::Success => self.success,
106            ColorRole::Info => self.info,
107            ColorRole::TextPrimary => self.text_primary,
108            ColorRole::TextSecondary => self.text_secondary,
109            ColorRole::TextDisabled => self.text_disabled,
110            ColorRole::Border => self.border,
111            ColorRole::Divider => self.divider,
112        }
113    }
114
115    /// Create a dark color palette (high-contrast, near-black aesthetic).
116    pub fn dark() -> Self {
117        Self {
118            primary: Color::from_rgb_u8(140, 120, 255),
119            secondary: Color::from_rgb_u8(100, 150, 235),
120            background: Color::from_rgb_u8(10, 10, 14),
121            surface: Color::from_rgb_u8(18, 18, 24),
122            error: Color::from_rgb_u8(240, 80, 100),
123            warning: Color::from_rgb_u8(240, 180, 70),
124            success: Color::from_rgb_u8(60, 200, 130),
125            info: Color::from_rgb_u8(90, 175, 245),
126            text_primary: Color::from_rgb_u8(240, 240, 252),
127            text_secondary: Color::from_rgb_u8(120, 120, 145),
128            text_disabled: Color::from_rgb_u8(65, 65, 82),
129            border: Color::from_rgb_u8(35, 35, 48),
130            divider: Color::from_rgb_u8(25, 25, 35),
131            hover_overlay: Color::from_rgba_u8(255, 255, 255, 10),
132            active_overlay: Color::from_rgba_u8(255, 255, 255, 20),
133        }
134    }
135
136    /// Create a light color palette (cool minimal aesthetic).
137    pub fn light() -> Self {
138        Self {
139            primary: Color::from_rgb_u8(100, 80, 220),
140            secondary: Color::from_rgb_u8(70, 110, 195),
141            background: Color::from_rgb_u8(248, 248, 252),
142            surface: Color::from_rgb_u8(255, 255, 255),
143            error: Color::from_rgb_u8(210, 65, 80),
144            warning: Color::from_rgb_u8(205, 150, 50),
145            success: Color::from_rgb_u8(50, 165, 100),
146            info: Color::from_rgb_u8(70, 140, 210),
147            text_primary: Color::from_rgb_u8(25, 25, 35),
148            text_secondary: Color::from_rgb_u8(100, 100, 120),
149            text_disabled: Color::from_rgb_u8(165, 165, 180),
150            border: Color::from_rgb_u8(225, 225, 235),
151            divider: Color::from_rgb_u8(235, 235, 242),
152            hover_overlay: Color::from_rgba_u8(0, 0, 0, 8),
153            active_overlay: Color::from_rgba_u8(0, 0, 0, 16),
154        }
155    }
156}
157
158impl Default for ColorPalette {
159    fn default() -> Self {
160        Self::dark()
161    }
162}
163
164/// Typography settings for a theme.
165#[derive(Debug, Clone)]
166pub struct Typography {
167    /// Default font family
168    pub font_family: String,
169    /// Heading font sizes (h1-h6)
170    pub heading_sizes: [f32; 6],
171    /// Body text size
172    pub body_size: f32,
173    /// Small text size (captions, labels)
174    pub small_size: f32,
175    /// Tiny text size (hints, footnotes)
176    pub tiny_size: f32,
177    /// Line height multiplier
178    pub line_height: f32,
179}
180
181impl Typography {
182    /// Create default typography settings.
183    pub fn new() -> Self {
184        Self {
185            font_family: String::new(), // System default
186            heading_sizes: [48.0, 40.0, 32.0, 24.0, 20.0, 16.0],
187            body_size: 14.0,
188            small_size: 12.0,
189            tiny_size: 10.0,
190            line_height: 1.5,
191        }
192    }
193
194    /// Get a heading size by level (1-6).
195    pub fn heading_size(&self, level: usize) -> f32 {
196        if level == 0 || level > 6 {
197            self.body_size
198        } else {
199            self.heading_sizes[level - 1]
200        }
201    }
202}
203
204impl Default for Typography {
205    fn default() -> Self {
206        Self::new()
207    }
208}
209
210/// Spacing scale for consistent layout.
211#[derive(Debug, Clone, Copy)]
212pub struct Spacing {
213    /// Extra small spacing (2px)
214    pub xs: f32,
215    /// Small spacing (4px)
216    pub sm: f32,
217    /// Medium spacing (8px)
218    pub md: f32,
219    /// Large spacing (16px)
220    pub lg: f32,
221    /// Extra large spacing (24px)
222    pub xl: f32,
223    /// Extra extra large spacing (32px)
224    pub xxl: f32,
225}
226
227impl Spacing {
228    /// Create default spacing scale.
229    pub fn new() -> Self {
230        Self {
231            xs: 2.0,
232            sm: 4.0,
233            md: 8.0,
234            lg: 16.0,
235            xl: 24.0,
236            xxl: 32.0,
237        }
238    }
239
240    /// Get spacing by name.
241    pub fn get(&self, name: &str) -> f32 {
242        match name {
243            "xs" => self.xs,
244            "sm" => self.sm,
245            "md" => self.md,
246            "lg" => self.lg,
247            "xl" => self.xl,
248            "xxl" => self.xxl,
249            _ => self.md,
250        }
251    }
252}
253
254impl Default for Spacing {
255    fn default() -> Self {
256        Self::new()
257    }
258}
259
260/// Shape definitions for consistent rounded corners.
261#[derive(Debug, Clone, Copy)]
262pub struct Shapes {
263    /// No rounding
264    pub none: f32,
265    /// Small border radius (2px)
266    pub sm: f32,
267    /// Medium border radius (4px)
268    pub md: f32,
269    /// Large border radius (8px)
270    pub lg: f32,
271    /// Extra large border radius (16px)
272    pub xl: f32,
273    /// Fully rounded (pill shape)
274    pub full: f32,
275}
276
277impl Shapes {
278    /// Create default shape definitions.
279    pub fn new() -> Self {
280        Self {
281            none: 0.0,
282            sm: 4.0,
283            md: 6.0,
284            lg: 10.0,
285            xl: 14.0,
286            full: 9999.0, // Large value for fully rounded
287        }
288    }
289
290    /// Get shape by name.
291    pub fn get(&self, name: &str) -> f32 {
292        match name {
293            "none" => self.none,
294            "sm" => self.sm,
295            "md" => self.md,
296            "lg" => self.lg,
297            "xl" => self.xl,
298            "full" => self.full,
299            _ => self.md,
300        }
301    }
302}
303
304impl Default for Shapes {
305    fn default() -> Self {
306        Self::new()
307    }
308}
309
310/// A complete theme definition.
311#[derive(Debug, Clone)]
312pub struct Theme {
313    /// Color palette
314    pub colors: ColorPalette,
315    /// Typography settings
316    pub typography: Typography,
317    /// Spacing scale
318    pub spacing: Spacing,
319    /// Shape definitions
320    pub shapes: Shapes,
321}
322
323impl Theme {
324    /// Create a new theme with default settings.
325    pub fn new() -> Self {
326        Self {
327            colors: ColorPalette::default(),
328            typography: Typography::default(),
329            spacing: Spacing::default(),
330            shapes: Shapes::default(),
331        }
332    }
333
334    /// Create a dark theme.
335    pub fn dark() -> Self {
336        Self {
337            colors: ColorPalette::dark(),
338            typography: Typography::new(),
339            spacing: Spacing::new(),
340            shapes: Shapes::new(),
341        }
342    }
343
344    /// Create a light theme.
345    pub fn light() -> Self {
346        Self {
347            colors: ColorPalette::light(),
348            typography: Typography::new(),
349            spacing: Spacing::new(),
350            shapes: Shapes::new(),
351        }
352    }
353
354    /// Create a theme builder.
355    pub fn builder() -> ThemeBuilder {
356        ThemeBuilder::new()
357    }
358
359    /// Get a color by role.
360    pub fn color(&self, role: ColorRole) -> Color {
361        self.colors.get(role)
362    }
363}
364
365impl Default for Theme {
366    fn default() -> Self {
367        Self::dark()
368    }
369}
370
371/// Builder for creating custom themes.
372pub struct ThemeBuilder {
373    theme: Theme,
374}
375
376impl ThemeBuilder {
377    /// Create a new theme builder.
378    pub fn new() -> Self {
379        Self {
380            theme: Theme::default(),
381        }
382    }
383
384    /// Start with a dark theme.
385    pub fn dark() -> Self {
386        Self {
387            theme: Theme::dark(),
388        }
389    }
390
391    /// Start with a light theme.
392    pub fn light() -> Self {
393        Self {
394            theme: Theme::light(),
395        }
396    }
397
398    /// Set the primary color.
399    pub fn primary(mut self, color: Color) -> Self {
400        self.theme.colors.primary = color;
401        self
402    }
403
404    /// Set the secondary color.
405    pub fn secondary(mut self, color: Color) -> Self {
406        self.theme.colors.secondary = color;
407        self
408    }
409
410    /// Set the background color.
411    pub fn background(mut self, color: Color) -> Self {
412        self.theme.colors.background = color;
413        self
414    }
415
416    /// Set the surface color.
417    pub fn surface(mut self, color: Color) -> Self {
418        self.theme.colors.surface = color;
419        self
420    }
421
422    /// Set the error color.
423    pub fn error(mut self, color: Color) -> Self {
424        self.theme.colors.error = color;
425        self
426    }
427
428    /// Set the font family.
429    pub fn font_family(mut self, family: impl Into<String>) -> Self {
430        self.theme.typography.font_family = family.into();
431        self
432    }
433
434    /// Set the body font size.
435    pub fn body_size(mut self, size: f32) -> Self {
436        self.theme.typography.body_size = size;
437        self
438    }
439
440    /// Set a custom color palette.
441    pub fn colors(mut self, colors: ColorPalette) -> Self {
442        self.theme.colors = colors;
443        self
444    }
445
446    /// Set custom typography.
447    pub fn typography(mut self, typography: Typography) -> Self {
448        self.theme.typography = typography;
449        self
450    }
451
452    /// Set custom spacing.
453    pub fn spacing(mut self, spacing: Spacing) -> Self {
454        self.theme.spacing = spacing;
455        self
456    }
457
458    /// Set custom shapes.
459    pub fn shapes(mut self, shapes: Shapes) -> Self {
460        self.theme.shapes = shapes;
461        self
462    }
463
464    /// Build the theme.
465    pub fn build(self) -> Theme {
466        self.theme
467    }
468}
469
470impl Default for ThemeBuilder {
471    fn default() -> Self {
472        Self::new()
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    #[test]
481    fn test_dark_theme() {
482        let theme = Theme::dark();
483        assert_eq!(theme.colors.primary, Color::from_rgb_u8(140, 120, 255));
484        assert_eq!(theme.typography.body_size, 14.0);
485    }
486
487    #[test]
488    fn test_light_theme() {
489        let theme = Theme::light();
490        assert_eq!(theme.colors.background, Color::from_rgb_u8(248, 248, 252));
491    }
492
493    #[test]
494    fn test_theme_builder() {
495        let theme = Theme::builder()
496            .primary(Color::RED)
497            .secondary(Color::BLUE)
498            .body_size(16.0)
499            .build();
500
501        assert_eq!(theme.colors.primary, Color::RED);
502        assert_eq!(theme.colors.secondary, Color::BLUE);
503        assert_eq!(theme.typography.body_size, 16.0);
504    }
505
506    #[test]
507    fn test_color_roles() {
508        let theme = Theme::dark();
509        let primary = theme.color(ColorRole::Primary);
510        assert_eq!(primary, theme.colors.primary);
511    }
512
513    #[test]
514    fn test_spacing() {
515        let spacing = Spacing::new();
516        assert_eq!(spacing.xs, 2.0);
517        assert_eq!(spacing.lg, 16.0);
518        assert_eq!(spacing.get("md"), 8.0);
519    }
520
521    #[test]
522    fn test_shapes() {
523        let shapes = Shapes::new();
524        assert_eq!(shapes.sm, 4.0);
525        assert_eq!(shapes.lg, 10.0);
526        assert_eq!(shapes.get("md"), 6.0);
527    }
528
529    #[test]
530    fn test_typography_heading_sizes() {
531        let typography = Typography::new();
532        assert_eq!(typography.heading_size(1), 48.0);
533        assert_eq!(typography.heading_size(6), 16.0);
534        assert_eq!(typography.heading_size(0), typography.body_size);
535        assert_eq!(typography.heading_size(7), typography.body_size);
536    }
537}