Skip to main content

agpu/
theme.rs

1//! Theming — complete theme system with dark/light modes and custom palettes.
2
3use crate::core::Color;
4use serde::{Deserialize, Serialize};
5
6/// Color scheme mode.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
8pub enum ThemeMode {
9    Light,
10    #[default]
11    Dark,
12}
13
14/// Spacing scale for consistent layout.
15#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
16pub struct Spacing {
17    pub xs: f32,
18    pub sm: f32,
19    pub md: f32,
20    pub lg: f32,
21    pub xl: f32,
22}
23
24impl Default for Spacing {
25    fn default() -> Self {
26        Self {
27            xs: 2.0,
28            sm: 4.0,
29            md: 8.0,
30            lg: 16.0,
31            xl: 32.0,
32        }
33    }
34}
35
36/// Typography scale.
37#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
38pub struct Typography {
39    pub body: f32,
40    pub small: f32,
41    pub heading1: f32,
42    pub heading2: f32,
43    pub heading3: f32,
44    pub mono: f32,
45}
46
47impl Default for Typography {
48    fn default() -> Self {
49        Self {
50            body: 14.0,
51            small: 11.0,
52            heading1: 28.0,
53            heading2: 22.0,
54            heading3: 17.0,
55            mono: 13.0,
56        }
57    }
58}
59
60/// Border radius scale.
61#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
62pub struct BorderRadius {
63    pub none: f32,
64    pub sm: f32,
65    pub md: f32,
66    pub lg: f32,
67    pub full: f32,
68}
69
70impl Default for BorderRadius {
71    fn default() -> Self {
72        Self {
73            none: 0.0,
74            sm: 2.0,
75            md: 4.0,
76            lg: 8.0,
77            full: 9999.0,
78        }
79    }
80}
81
82/// Color palette for a theme.
83#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
84pub struct Palette {
85    pub background: Color,
86    pub surface: Color,
87    pub primary: Color,
88    pub secondary: Color,
89    pub accent: Color,
90    pub error: Color,
91    pub warning: Color,
92    pub success: Color,
93    pub text_primary: Color,
94    pub text_secondary: Color,
95    pub text_disabled: Color,
96    pub border: Color,
97    pub hover: Color,
98    pub pressed: Color,
99    pub focus_ring: Color,
100}
101
102impl Palette {
103    pub fn dark() -> Self {
104        Self {
105            background: Color::rgba(0.08, 0.08, 0.10, 1.0),
106            surface: Color::rgba(0.14, 0.14, 0.18, 1.0),
107            primary: Color::rgba(0.35, 0.55, 0.95, 1.0),
108            secondary: Color::rgba(0.55, 0.35, 0.85, 1.0),
109            accent: Color::rgba(0.0, 0.8, 0.65, 1.0),
110            error: Color::rgba(0.9, 0.25, 0.25, 1.0),
111            warning: Color::rgba(0.95, 0.7, 0.2, 1.0),
112            success: Color::rgba(0.2, 0.8, 0.35, 1.0),
113            text_primary: Color::rgba(0.92, 0.92, 0.95, 1.0),
114            text_secondary: Color::rgba(0.65, 0.65, 0.7, 1.0),
115            text_disabled: Color::rgba(0.4, 0.4, 0.45, 1.0),
116            border: Color::rgba(0.25, 0.25, 0.3, 1.0),
117            hover: Color::rgba(1.0, 1.0, 1.0, 0.06),
118            pressed: Color::rgba(1.0, 1.0, 1.0, 0.1),
119            focus_ring: Color::rgba(0.35, 0.55, 0.95, 0.5),
120        }
121    }
122
123    pub fn light() -> Self {
124        Self {
125            background: Color::rgba(0.97, 0.97, 0.98, 1.0),
126            surface: Color::WHITE,
127            primary: Color::rgba(0.2, 0.4, 0.85, 1.0),
128            secondary: Color::rgba(0.5, 0.3, 0.8, 1.0),
129            accent: Color::rgba(0.0, 0.65, 0.55, 1.0),
130            error: Color::rgba(0.85, 0.2, 0.2, 1.0),
131            warning: Color::rgba(0.9, 0.6, 0.1, 1.0),
132            success: Color::rgba(0.15, 0.7, 0.3, 1.0),
133            text_primary: Color::rgba(0.1, 0.1, 0.12, 1.0),
134            text_secondary: Color::rgba(0.4, 0.4, 0.45, 1.0),
135            text_disabled: Color::rgba(0.65, 0.65, 0.7, 1.0),
136            border: Color::rgba(0.82, 0.82, 0.85, 1.0),
137            hover: Color::rgba(0.0, 0.0, 0.0, 0.04),
138            pressed: Color::rgba(0.0, 0.0, 0.0, 0.08),
139            focus_ring: Color::rgba(0.2, 0.4, 0.85, 0.4),
140        }
141    }
142}
143
144/// A complete theme definition.
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct Theme {
147    pub name: String,
148    pub mode: ThemeMode,
149    pub palette: Palette,
150    pub spacing: Spacing,
151    pub typography: Typography,
152    pub border_radius: BorderRadius,
153}
154
155impl Theme {
156    pub fn dark() -> Self {
157        Self {
158            name: "Dark".to_string(),
159            mode: ThemeMode::Dark,
160            palette: Palette::dark(),
161            spacing: Spacing::default(),
162            typography: Typography::default(),
163            border_radius: BorderRadius::default(),
164        }
165    }
166
167    pub fn light() -> Self {
168        Self {
169            name: "Light".to_string(),
170            mode: ThemeMode::Light,
171            palette: Palette::light(),
172            spacing: Spacing::default(),
173            typography: Typography::default(),
174            border_radius: BorderRadius::default(),
175        }
176    }
177
178    pub fn custom(name: impl Into<String>, mode: ThemeMode, palette: Palette) -> Self {
179        Self {
180            name: name.into(),
181            mode,
182            palette,
183            spacing: Spacing::default(),
184            typography: Typography::default(),
185            border_radius: BorderRadius::default(),
186        }
187    }
188
189    pub fn is_dark(&self) -> bool {
190        self.mode == ThemeMode::Dark
191    }
192}
193
194impl Default for Theme {
195    fn default() -> Self {
196        Self::dark()
197    }
198}
199
200/// Theme manager that holds the current theme and allows switching.
201pub struct ThemeManager {
202    themes: Vec<Theme>,
203    active: usize,
204}
205
206impl ThemeManager {
207    pub fn new() -> Self {
208        Self {
209            themes: vec![Theme::dark(), Theme::light()],
210            active: 0,
211        }
212    }
213
214    pub fn current(&self) -> &Theme {
215        &self.themes[self.active]
216    }
217
218    pub fn set_active(&mut self, index: usize) {
219        if index < self.themes.len() {
220            self.active = index;
221        }
222    }
223
224    pub fn toggle(&mut self) {
225        self.active = (self.active + 1) % self.themes.len();
226    }
227
228    pub fn add(&mut self, theme: Theme) {
229        self.themes.push(theme);
230    }
231
232    pub fn list(&self) -> Vec<&str> {
233        self.themes.iter().map(|t| t.name.as_str()).collect()
234    }
235
236    pub fn find(&self, name: &str) -> Option<usize> {
237        self.themes.iter().position(|t| t.name == name)
238    }
239
240    pub fn set_by_name(&mut self, name: &str) -> bool {
241        if let Some(idx) = self.find(name) {
242            self.active = idx;
243            true
244        } else {
245            false
246        }
247    }
248}
249
250impl Default for ThemeManager {
251    fn default() -> Self {
252        Self::new()
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn theme_dark_defaults() {
262        let theme = Theme::dark();
263        assert!(theme.is_dark());
264        assert_eq!(theme.mode, ThemeMode::Dark);
265    }
266
267    #[test]
268    fn theme_light() {
269        let theme = Theme::light();
270        assert!(!theme.is_dark());
271    }
272
273    #[test]
274    fn palette_dark_bg_is_dark() {
275        let p = Palette::dark();
276        assert!(p.background.r < 0.2);
277    }
278
279    #[test]
280    fn palette_light_bg_is_light() {
281        let p = Palette::light();
282        assert!(p.background.r > 0.8);
283    }
284
285    #[test]
286    fn spacing_defaults() {
287        let s = Spacing::default();
288        assert!(s.xs < s.sm);
289        assert!(s.sm < s.md);
290        assert!(s.md < s.lg);
291        assert!(s.lg < s.xl);
292    }
293
294    #[test]
295    fn typography_defaults() {
296        let t = Typography::default();
297        assert!(t.small < t.body);
298        assert!(t.body < t.heading3);
299    }
300
301    #[test]
302    fn border_radius_defaults() {
303        let r = BorderRadius::default();
304        assert_eq!(r.none, 0.0);
305        assert!(r.full > 1000.0);
306    }
307
308    #[test]
309    fn theme_custom() {
310        let theme = Theme::custom("Ocean", ThemeMode::Dark, Palette::dark());
311        assert_eq!(theme.name, "Ocean");
312    }
313
314    #[test]
315    fn theme_manager_toggle() {
316        let mut tm = ThemeManager::new();
317        assert!(tm.current().is_dark());
318        tm.toggle();
319        assert!(!tm.current().is_dark());
320        tm.toggle();
321        assert!(tm.current().is_dark());
322    }
323
324    #[test]
325    fn theme_manager_add_and_find() {
326        let mut tm = ThemeManager::new();
327        tm.add(Theme::custom(
328            "Solarized",
329            ThemeMode::Light,
330            Palette::light(),
331        ));
332        assert_eq!(tm.list().len(), 3);
333        assert!(tm.find("Solarized").is_some());
334    }
335
336    #[test]
337    fn theme_manager_set_by_name() {
338        let mut tm = ThemeManager::new();
339        tm.add(Theme::custom("Ocean", ThemeMode::Dark, Palette::dark()));
340        assert!(tm.set_by_name("Ocean"));
341        assert_eq!(tm.current().name, "Ocean");
342        assert!(!tm.set_by_name("Nonexistent"));
343    }
344
345    #[test]
346    fn theme_serialize_roundtrip() {
347        let theme = Theme::dark();
348        let json = serde_json::to_string(&theme).unwrap();
349        let parsed: Theme = serde_json::from_str(&json).unwrap();
350        assert_eq!(parsed.name, "Dark");
351    }
352}