1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::rc::Rc;
4
5#[cfg(target_arch = "wasm32")]
6pub(crate) const SYSTEM_THEME_QUERY: &str = "(prefers-color-scheme: dark)";
7pub(crate) const DEFAULT_STORAGE_KEY: &str = "theme";
8
9#[derive(Debug, Clone, PartialEq, Default, Copy)]
11pub enum StorageType {
12 #[default]
14 LocalStorage,
15 SessionStorage,
17}
18
19#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
20pub struct ColorTokens {
21 pub primary: String,
22 pub secondary: String,
23 pub background: String,
24 pub text: String,
25 pub error: Option<String>,
26 pub warning: Option<String>,
27 pub success: Option<String>,
28}
29
30impl ColorTokens {
31 pub fn merge_with(&self, other: &ColorTokens) -> ColorTokens {
32 ColorTokens {
33 primary: other.primary.clone(),
34 secondary: other.secondary.clone(),
35 background: other.background.clone(),
36 text: other.text.clone(),
37 error: other.error.clone().or_else(|| self.error.clone()),
38 warning: other.warning.clone().or_else(|| self.warning.clone()),
39 success: other.success.clone().or_else(|| self.success.clone()),
40 }
41 }
42
43 pub fn validate(&self) -> Result<(), String> {
44 fn is_valid_hex(color: &str) -> bool {
45 let color = color.trim_start_matches('#');
46 color.len() == 6 || color.len() == 3 && u32::from_str_radix(color, 16).is_ok()
47 }
48
49 for (field_name, value) in [
50 ("primary", &self.primary),
51 ("secondary", &self.secondary),
52 ("background", &self.background),
53 ("text", &self.text),
54 ] {
55 if !is_valid_hex(value) {
56 return Err(format!("Invalid hex color for '{}': {}", field_name, value));
57 }
58 }
59
60 Ok(())
61 }
62}
63
64#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
65pub struct CustomTheme {
66 pub name: String,
67 pub tokens: ColorTokens,
68 pub base: Option<String>,
69}
70
71impl CustomTheme {
72 pub fn validate(&self) -> Result<(), String> {
73 if self.name.trim().is_empty() {
74 return Err("Theme name cannot be empty.".to_string());
75 }
76
77 self.tokens.validate()?;
78 Ok(())
79 }
80
81 pub fn compose_with_base(
83 &self,
84 available_themes: &HashMap<String, Rc<CustomTheme>>,
85 ) -> Result<ColorTokens, String> {
86 if let Some(ref base_name) = self.base {
87 if let Some(base_theme) = available_themes.get(base_name) {
88 Ok(base_theme.tokens.merge_with(&self.tokens))
89 } else {
90 Err(format!("Base theme '{}' not found.", base_name))
91 }
92 } else {
93 Ok(self.tokens.clone())
94 }
95 }
96}
97
98#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
99pub enum Theme {
100 Light,
101 Dark,
102 #[default]
103 System,
104 Custom(Rc<CustomTheme>),
105}
106impl std::str::FromStr for Theme {
107 type Err = ();
108
109 fn from_str(s: &str) -> Result<Self, Self::Err> {
110 match s {
111 "light" | "Light" => Ok(Theme::Light),
112 "dark" | "Dark" => Ok(Theme::Dark),
113 "system" | "System" => Ok(Theme::System),
114 _ => Err(()),
115 }
116 }
117}
118
119impl Theme {
120 pub fn as_str(&self) -> String {
121 match self {
122 Theme::Light => "light".to_string(),
123 Theme::Dark => "dark".to_string(),
124 Theme::System => "system".to_string(),
125 Theme::Custom(custom) => custom.name.clone(),
126 }
127 }
128
129 pub fn is_dark(&self, system_fallback: Option<bool>) -> bool {
130 match self {
131 Theme::Dark => true,
132 Theme::Light => false,
133 Theme::System => system_fallback.unwrap_or(false),
134 Theme::Custom(custom) => custom.tokens.background.to_lowercase() != "#ffffff",
135 }
136 }
137
138 pub fn colors(
139 &self,
140 available_themes: Option<&HashMap<String, Rc<CustomTheme>>>,
141 ) -> ColorTokens {
142 match self {
143 Theme::Light => ColorTokens {
144 primary: "#ffffff".into(),
145 secondary: "#f0f0f0".into(),
146 background: "#ffffff".into(),
147 text: "#000000".into(),
148 error: None,
149 warning: None,
150 success: None,
151 },
152 Theme::Dark => ColorTokens {
153 primary: "#000000".into(),
154 secondary: "#1a1a1a".into(),
155 background: "#000000".into(),
156 text: "#ffffff".into(),
157 error: None,
158 warning: None,
159 success: None,
160 },
161 Theme::System => ColorTokens {
162 primary: "#ffffff".into(),
163 secondary: "#f0f0f0".into(),
164 background: "#ffffff".into(),
165 text: "#000000".into(),
166 error: None,
167 warning: None,
168 success: None,
169 },
170 Theme::Custom(custom) => {
171 if let Some(themes) = available_themes {
172 match custom.compose_with_base(themes) {
173 Ok(tokens) => tokens,
174 Err(_) => custom.tokens.clone(),
175 }
176 } else {
177 custom.tokens.clone()
178 }
179 }
180 }
181 }
182}