theme/
common.rs

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/// Enum representing browser storage options for persisting the selected theme.
10#[derive(Debug, Clone, PartialEq, Default, Copy)]
11pub enum StorageType {
12    /// Use the browser's `LocalStorage` for persisting data.
13    #[default]
14    LocalStorage,
15    /// Use the browser's `SessionStorage` for persisting data.
16    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    /// Compose this theme with a base theme if `base` is provided.
82    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}