1use crate::core::Color;
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
8pub enum ThemeMode {
9 Light,
10 #[default]
11 Dark,
12}
13
14#[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#[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#[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#[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#[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
200pub 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}