StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.
| 1 | //! Theming system for StratoUI |
| 2 | //! |
| 3 | //! Provides comprehensive theming support including dark/light modes, |
| 4 | //! custom color schemes, typography, spacing, and component styling |
| 5 | |
| 6 | use parking_lot::RwLock; |
| 7 | use serde::{Deserialize, Serialize}; |
| 8 | use std::collections::HashMap; |
| 9 | use std::sync::Arc; |
| 10 | |
| 11 | /// Color representation with alpha channel |
| 12 | #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] |
| 13 | pub struct Color { |
| 14 | pub r: f32, |
| 15 | pub g: f32, |
| 16 | pub b: f32, |
| 17 | pub a: f32, |
| 18 | } |
| 19 | |
| 20 | impl Color { |
| 21 | /// Create a new color from RGB values (0.0 - 1.0) |
| 22 | pub const fn rgb(r: f32, g: f32, b: f32) -> Self { |
| 23 | Self { r, g, b, a: 1.0 } |
| 24 | } |
| 25 | |
| 26 | /// Create a new color from RGBA values (0.0 - 1.0) |
| 27 | pub const fn rgba(r: f32, g: f32, b: f32, a: f32) -> Self { |
| 28 | Self { r, g, b, a } |
| 29 | } |
| 30 | |
| 31 | /// Create a color from hex string (#RRGGBB or #RRGGBBAA) |
| 32 | pub fn from_hex(hex: &str) -> Result<Self, &'static str> { |
| 33 | let hex = hex.trim_start_matches('#'); |
| 34 | |
| 35 | match hex.len() { |
| 36 | 6 => { |
| 37 | let r = u8::from_str_radix(&hex[0..2], 16).map_err(|_| "Invalid hex color")?; |
| 38 | let g = u8::from_str_radix(&hex[2..4], 16).map_err(|_| "Invalid hex color")?; |
| 39 | let b = u8::from_str_radix(&hex[4..6], 16).map_err(|_| "Invalid hex color")?; |
| 40 | Ok(Self::rgb( |
| 41 | r as f32 / 255.0, |
| 42 | g as f32 / 255.0, |
| 43 | b as f32 / 255.0, |
| 44 | )) |
| 45 | } |
| 46 | 8 => { |
| 47 | let r = u8::from_str_radix(&hex[0..2], 16).map_err(|_| "Invalid hex color")?; |
| 48 | let g = u8::from_str_radix(&hex[2..4], 16).map_err(|_| "Invalid hex color")?; |
| 49 | let b = u8::from_str_radix(&hex[4..6], 16).map_err(|_| "Invalid hex color")?; |
| 50 | let a = u8::from_str_radix(&hex[6..8], 16).map_err(|_| "Invalid hex color")?; |
| 51 | Ok(Self::rgba( |
| 52 | r as f32 / 255.0, |
| 53 | g as f32 / 255.0, |
| 54 | b as f32 / 255.0, |
| 55 | a as f32 / 255.0, |
| 56 | )) |
| 57 | } |
| 58 | _ => Err("Invalid hex color length"), |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | /// Convert to hex string |
| 63 | pub fn to_hex(&self) -> String { |
| 64 | if self.a < 1.0 { |
| 65 | format!( |
| 66 | "#{:02X}{:02X}{:02X}{:02X}", |
| 67 | (self.r * 255.0).round() as u8, |
| 68 | (self.g * 255.0).round() as u8, |
| 69 | (self.b * 255.0).round() as u8, |
| 70 | (self.a * 255.0).round() as u8 |
| 71 | ) |
| 72 | } else { |
| 73 | format!( |
| 74 | "#{:02X}{:02X}{:02X}", |
| 75 | (self.r * 255.0).round() as u8, |
| 76 | (self.g * 255.0).round() as u8, |
| 77 | (self.b * 255.0).round() as u8 |
| 78 | ) |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | /// Lighten the color by a factor (0.0 - 1.0) |
| 83 | pub fn lighten(&self, factor: f32) -> Self { |
| 84 | Self { |
| 85 | r: (self.r + (1.0 - self.r) * factor).min(1.0), |
| 86 | g: (self.g + (1.0 - self.g) * factor).min(1.0), |
| 87 | b: (self.b + (1.0 - self.b) * factor).min(1.0), |
| 88 | a: self.a, |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | /// Darken the color by a factor (0.0 - 1.0) |
| 93 | pub fn darken(&self, factor: f32) -> Self { |
| 94 | Self { |
| 95 | r: (self.r * (1.0 - factor)).max(0.0), |
| 96 | g: (self.g * (1.0 - factor)).max(0.0), |
| 97 | b: (self.b * (1.0 - factor)).max(0.0), |
| 98 | a: self.a, |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | /// Set alpha channel |
| 103 | pub fn with_alpha(&self, alpha: f32) -> Self { |
| 104 | Self { |
| 105 | r: self.r, |
| 106 | g: self.g, |
| 107 | b: self.b, |
| 108 | a: alpha.clamp(0.0, 1.0), |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | /// Convert to array format [r, g, b, a] for renderer compatibility |
| 113 | pub fn to_array(&self) -> [f32; 4] { |
| 114 | [self.r, self.g, self.b, self.a] |
| 115 | } |
| 116 | |
| 117 | /// Mix with another color |
| 118 | pub fn mix(&self, other: &Color, factor: f32) -> Self { |
| 119 | let factor = factor.clamp(0.0, 1.0); |
| 120 | Self { |
| 121 | r: self.r + (other.r - self.r) * factor, |
| 122 | g: self.g + (other.g - self.g) * factor, |
| 123 | b: self.b + (other.b - self.b) * factor, |
| 124 | a: self.a + (other.a - self.a) * factor, |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | /// Convert to types::Color for rendering |
| 129 | pub fn to_types_color(&self) -> crate::types::Color { |
| 130 | crate::types::Color { |
| 131 | r: self.r, |
| 132 | g: self.g, |
| 133 | b: self.b, |
| 134 | a: self.a, |
| 135 | } |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | /// Common color constants |
| 140 | impl Color { |
| 141 | pub const TRANSPARENT: Color = Color::rgba(0.0, 0.0, 0.0, 0.0); |
| 142 | pub const BLACK: Color = Color::rgb(0.0, 0.0, 0.0); |
| 143 | pub const WHITE: Color = Color::rgb(1.0, 1.0, 1.0); |
| 144 | pub const RED: Color = Color::rgb(1.0, 0.0, 0.0); |
| 145 | pub const GREEN: Color = Color::rgb(0.0, 1.0, 0.0); |
| 146 | pub const BLUE: Color = Color::rgb(0.0, 0.0, 1.0); |
| 147 | pub const YELLOW: Color = Color::rgb(1.0, 1.0, 0.0); |
| 148 | pub const CYAN: Color = Color::rgb(0.0, 1.0, 1.0); |
| 149 | pub const MAGENTA: Color = Color::rgb(1.0, 0.0, 1.0); |
| 150 | pub const GRAY: Color = Color::rgb(0.5, 0.5, 0.5); |
| 151 | } |
| 152 | |
| 153 | /// Typography configuration |
| 154 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 155 | pub struct Typography { |
| 156 | /// Font family |
| 157 | pub font_family: String, |
| 158 | /// Base font size in pixels |
| 159 | pub base_size: f32, |
| 160 | /// Line height multiplier |
| 161 | pub line_height: f32, |
| 162 | /// Font weight |
| 163 | pub weight: FontWeight, |
| 164 | /// Letter spacing |
| 165 | pub letter_spacing: f32, |
| 166 | } |
| 167 | |
| 168 | impl Default for Typography { |
| 169 | fn default() -> Self { |
| 170 | // Use platform-specific default fonts with proper fallbacks |
| 171 | #[cfg(target_os = "windows")] |
| 172 | let font_family = "Segoe UI, Tahoma, Arial, sans-serif"; |
| 173 | |
| 174 | #[cfg(target_os = "macos")] |
| 175 | let font_family = "SF Pro Display, Helvetica Neue, Arial, sans-serif"; |
| 176 | |
| 177 | #[cfg(target_os = "linux")] |
| 178 | let font_family = "Ubuntu, DejaVu Sans, Liberation Sans, Arial, sans-serif"; |
| 179 | |
| 180 | #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] |
| 181 | let font_family = "Arial, sans-serif"; |
| 182 | |
| 183 | Self { |
| 184 | font_family: font_family.to_string(), |
| 185 | base_size: 14.0, |
| 186 | line_height: 1.5, |
| 187 | weight: FontWeight::Normal, |
| 188 | letter_spacing: 0.0, |
| 189 | } |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | /// Font weight enumeration |
| 194 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 195 | pub enum FontWeight { |
| 196 | Thin = 100, |
| 197 | ExtraLight = 200, |
| 198 | Light = 300, |
| 199 | Normal = 400, |
| 200 | Medium = 500, |
| 201 | SemiBold = 600, |
| 202 | Bold = 700, |
| 203 | ExtraBold = 800, |
| 204 | Black = 900, |
| 205 | } |
| 206 | |
| 207 | /// Spacing configuration |
| 208 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 209 | pub struct Spacing { |
| 210 | /// Extra small spacing |
| 211 | pub xs: f32, |
| 212 | /// Small spacing |
| 213 | pub sm: f32, |
| 214 | /// Medium spacing |
| 215 | pub md: f32, |
| 216 | /// Large spacing |
| 217 | pub lg: f32, |
| 218 | /// Extra large spacing |
| 219 | pub xl: f32, |
| 220 | /// Extra extra large spacing |
| 221 | pub xxl: f32, |
| 222 | } |
| 223 | |
| 224 | impl Default for Spacing { |
| 225 | fn default() -> Self { |
| 226 | Self { |
| 227 | xs: 4.0, |
| 228 | sm: 8.0, |
| 229 | md: 16.0, |
| 230 | lg: 24.0, |
| 231 | xl: 32.0, |
| 232 | xxl: 48.0, |
| 233 | } |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | /// Border radius configuration |
| 238 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 239 | pub struct BorderRadius { |
| 240 | /// No radius |
| 241 | pub none: f32, |
| 242 | /// Small radius |
| 243 | pub sm: f32, |
| 244 | /// Medium radius |
| 245 | pub md: f32, |
| 246 | /// Large radius |
| 247 | pub lg: f32, |
| 248 | /// Full radius (circular) |
| 249 | pub full: f32, |
| 250 | } |
| 251 | |
| 252 | impl Default for BorderRadius { |
| 253 | fn default() -> Self { |
| 254 | Self { |
| 255 | none: 0.0, |
| 256 | sm: 4.0, |
| 257 | md: 8.0, |
| 258 | lg: 12.0, |
| 259 | full: 9999.0, |
| 260 | } |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | /// Shadow configuration |
| 265 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 266 | pub struct Shadow { |
| 267 | pub color: Color, |
| 268 | pub offset_x: f32, |
| 269 | pub offset_y: f32, |
| 270 | pub blur_radius: f32, |
| 271 | pub spread_radius: f32, |
| 272 | } |
| 273 | |
| 274 | impl Shadow { |
| 275 | pub fn new(color: Color, offset_x: f32, offset_y: f32, blur_radius: f32) -> Self { |
| 276 | Self { |
| 277 | color, |
| 278 | offset_x, |
| 279 | offset_y, |
| 280 | blur_radius, |
| 281 | spread_radius: 0.0, |
| 282 | } |
| 283 | } |
| 284 | } |
| 285 | |
| 286 | /// Elevation shadows |
| 287 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 288 | pub struct Elevation { |
| 289 | pub none: Option<Shadow>, |
| 290 | pub sm: Shadow, |
| 291 | pub md: Shadow, |
| 292 | pub lg: Shadow, |
| 293 | pub xl: Shadow, |
| 294 | } |
| 295 | |
| 296 | impl Default for Elevation { |
| 297 | fn default() -> Self { |
| 298 | Self { |
| 299 | none: None, |
| 300 | sm: Shadow::new(Color::rgba(0.0, 0.0, 0.0, 0.1), 0.0, 1.0, 3.0), |
| 301 | md: Shadow::new(Color::rgba(0.0, 0.0, 0.0, 0.15), 0.0, 4.0, 6.0), |
| 302 | lg: Shadow::new(Color::rgba(0.0, 0.0, 0.0, 0.2), 0.0, 10.0, 15.0), |
| 303 | xl: Shadow::new(Color::rgba(0.0, 0.0, 0.0, 0.25), 0.0, 20.0, 25.0), |
| 304 | } |
| 305 | } |
| 306 | } |
| 307 | |
| 308 | /// Color palette for a theme |
| 309 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 310 | pub struct ColorPalette { |
| 311 | // Primary colors |
| 312 | pub primary: Color, |
| 313 | pub primary_variant: Color, |
| 314 | |
| 315 | // Secondary colors |
| 316 | pub secondary: Color, |
| 317 | pub secondary_variant: Color, |
| 318 | |
| 319 | // Background colors |
| 320 | pub background: Color, |
| 321 | pub surface: Color, |
| 322 | pub surface_variant: Color, |
| 323 | |
| 324 | // Text colors |
| 325 | pub on_primary: Color, |
| 326 | pub on_secondary: Color, |
| 327 | pub on_background: Color, |
| 328 | pub on_surface: Color, |
| 329 | |
| 330 | // State colors |
| 331 | pub error: Color, |
| 332 | pub on_error: Color, |
| 333 | pub warning: Color, |
| 334 | pub on_warning: Color, |
| 335 | pub success: Color, |
| 336 | pub on_success: Color, |
| 337 | pub info: Color, |
| 338 | pub on_info: Color, |
| 339 | |
| 340 | // Outline and divider |
| 341 | pub outline: Color, |
| 342 | pub outline_variant: Color, |
| 343 | pub divider: Color, |
| 344 | |
| 345 | // Disabled state |
| 346 | pub disabled: Color, |
| 347 | pub on_disabled: Color, |
| 348 | } |
| 349 | |
| 350 | impl ColorPalette { |
| 351 | /// Create a light theme color palette |
| 352 | pub fn light() -> Self { |
| 353 | Self { |
| 354 | primary: Color::from_hex("#6366F1").unwrap(), |
| 355 | primary_variant: Color::from_hex("#4F46E5").unwrap(), |
| 356 | secondary: Color::from_hex("#EC4899").unwrap(), |
| 357 | secondary_variant: Color::from_hex("#DB2777").unwrap(), |
| 358 | background: Color::WHITE, |
| 359 | surface: Color::from_hex("#F9FAFB").unwrap(), |
| 360 | surface_variant: Color::from_hex("#F3F4F6").unwrap(), |
| 361 | on_primary: Color::WHITE, |
| 362 | on_secondary: Color::WHITE, |
| 363 | on_background: Color::from_hex("#111827").unwrap(), |
| 364 | on_surface: Color::from_hex("#FFFF00").unwrap(), // Bright yellow for visibility |
| 365 | error: Color::from_hex("#EF4444").unwrap(), |
| 366 | on_error: Color::WHITE, |
| 367 | warning: Color::from_hex("#F59E0B").unwrap(), |
| 368 | on_warning: Color::WHITE, |
| 369 | success: Color::from_hex("#10B981").unwrap(), |
| 370 | on_success: Color::WHITE, |
| 371 | info: Color::from_hex("#3B82F6").unwrap(), |
| 372 | on_info: Color::WHITE, |
| 373 | outline: Color::from_hex("#D1D5DB").unwrap(), |
| 374 | outline_variant: Color::from_hex("#E5E7EB").unwrap(), |
| 375 | divider: Color::from_hex("#F3F4F6").unwrap(), |
| 376 | disabled: Color::from_hex("#9CA3AF").unwrap(), |
| 377 | on_disabled: Color::from_hex("#6B7280").unwrap(), |
| 378 | } |
| 379 | } |
| 380 | |
| 381 | /// Create a dark theme color palette |
| 382 | pub fn dark() -> Self { |
| 383 | Self { |
| 384 | primary: Color::from_hex("#818CF8").unwrap(), |
| 385 | primary_variant: Color::from_hex("#6366F1").unwrap(), |
| 386 | secondary: Color::from_hex("#F472B6").unwrap(), |
| 387 | secondary_variant: Color::from_hex("#EC4899").unwrap(), |
| 388 | background: Color::from_hex("#111827").unwrap(), |
| 389 | surface: Color::from_hex("#1F2937").unwrap(), |
| 390 | surface_variant: Color::from_hex("#374151").unwrap(), |
| 391 | on_primary: Color::from_hex("#1E1B4B").unwrap(), |
| 392 | on_secondary: Color::from_hex("#831843").unwrap(), |
| 393 | on_background: Color::from_hex("#F9FAFB").unwrap(), |
| 394 | on_surface: Color::from_hex("#00FFFF").unwrap(), // Bright cyan for visibility |
| 395 | error: Color::from_hex("#F87171").unwrap(), |
| 396 | on_error: Color::from_hex("#7F1D1D").unwrap(), |
| 397 | warning: Color::from_hex("#FBBF24").unwrap(), |
| 398 | on_warning: Color::from_hex("#78350F").unwrap(), |
| 399 | success: Color::from_hex("#34D399").unwrap(), |
| 400 | on_success: Color::from_hex("#064E3B").unwrap(), |
| 401 | info: Color::from_hex("#60A5FA").unwrap(), |
| 402 | on_info: Color::from_hex("#1E3A8A").unwrap(), |
| 403 | outline: Color::from_hex("#4B5563").unwrap(), |
| 404 | outline_variant: Color::from_hex("#374151").unwrap(), |
| 405 | divider: Color::from_hex("#374151").unwrap(), |
| 406 | disabled: Color::from_hex("#6B7280").unwrap(), |
| 407 | on_disabled: Color::from_hex("#9CA3AF").unwrap(), |
| 408 | } |
| 409 | } |
| 410 | } |
| 411 | |
| 412 | /// Theme mode enumeration |
| 413 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 414 | pub enum ThemeMode { |
| 415 | Light, |
| 416 | Dark, |
| 417 | System, |
| 418 | } |
| 419 | |
| 420 | /// Complete theme configuration |
| 421 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 422 | pub struct Theme { |
| 423 | /// Theme name |
| 424 | pub name: String, |
| 425 | /// Theme mode |
| 426 | pub mode: ThemeMode, |
| 427 | /// Color palette |
| 428 | pub colors: ColorPalette, |
| 429 | /// Typography settings |
| 430 | pub typography: Typography, |
| 431 | /// Spacing configuration |
| 432 | pub spacing: Spacing, |
| 433 | /// Border radius configuration |
| 434 | pub border_radius: BorderRadius, |
| 435 | /// Elevation shadows |
| 436 | pub elevation: Elevation, |
| 437 | /// Custom properties |
| 438 | pub custom: HashMap<String, String>, |
| 439 | } |
| 440 | |
| 441 | impl Theme { |
| 442 | /// Create a new light theme |
| 443 | pub fn light() -> Self { |
| 444 | Self { |
| 445 | name: "Light".to_string(), |
| 446 | mode: ThemeMode::Light, |
| 447 | colors: ColorPalette::light(), |
| 448 | typography: Typography::default(), |
| 449 | spacing: Spacing::default(), |
| 450 | border_radius: BorderRadius::default(), |
| 451 | elevation: Elevation::default(), |
| 452 | custom: HashMap::new(), |
| 453 | } |
| 454 | } |
| 455 | |
| 456 | /// Create a new dark theme |
| 457 | pub fn dark() -> Self { |
| 458 | Self { |
| 459 | name: "Dark".to_string(), |
| 460 | mode: ThemeMode::Dark, |
| 461 | colors: ColorPalette::dark(), |
| 462 | typography: Typography::default(), |
| 463 | spacing: Spacing::default(), |
| 464 | border_radius: BorderRadius::default(), |
| 465 | elevation: Elevation::default(), |
| 466 | custom: HashMap::new(), |
| 467 | } |
| 468 | } |
| 469 | |
| 470 | /// Get a custom property |
| 471 | pub fn get_custom(&self, key: &str) -> Option<&String> { |
| 472 | self.custom.get(key) |
| 473 | } |
| 474 | |
| 475 | /// Set a custom property |
| 476 | pub fn set_custom(&mut self, key: String, value: String) { |
| 477 | self.custom.insert(key, value); |
| 478 | } |
| 479 | } |
| 480 | |
| 481 | /// Theme change event |
| 482 | #[derive(Debug, Clone)] |
| 483 | pub struct ThemeChangeEvent { |
| 484 | pub old_theme: String, |
| 485 | pub new_theme: String, |
| 486 | pub mode_changed: bool, |
| 487 | } |
| 488 | |
| 489 | /// Theme change listener |
| 490 | pub trait ThemeChangeListener: Send + Sync { |
| 491 | fn on_theme_changed(&self, event: &ThemeChangeEvent); |
| 492 | } |
| 493 | |
| 494 | /// Theme manager for handling theme switching and persistence |
| 495 | pub struct ThemeManager { |
| 496 | current_theme: Arc<RwLock<Theme>>, |
| 497 | themes: Arc<RwLock<HashMap<String, Theme>>>, |
| 498 | listeners: Arc<RwLock<Vec<Arc<dyn ThemeChangeListener>>>>, |
| 499 | system_theme_mode: Arc<RwLock<ThemeMode>>, |
| 500 | } |
| 501 | |
| 502 | impl ThemeManager { |
| 503 | /// Create a new theme manager |
| 504 | pub fn new() -> Self { |
| 505 | let mut themes = HashMap::new(); |
| 506 | let light_theme = Theme::light(); |
| 507 | let dark_theme = Theme::dark(); |
| 508 | |
| 509 | themes.insert(light_theme.name.clone(), light_theme.clone()); |
| 510 | themes.insert(dark_theme.name.clone(), dark_theme.clone()); |
| 511 | |
| 512 | Self { |
| 513 | current_theme: Arc::new(RwLock::new(light_theme)), |
| 514 | themes: Arc::new(RwLock::new(themes)), |
| 515 | listeners: Arc::new(RwLock::new(Vec::new())), |
| 516 | system_theme_mode: Arc::new(RwLock::new(ThemeMode::Light)), |
| 517 | } |
| 518 | } |
| 519 | |
| 520 | /// Get the current theme |
| 521 | pub fn current_theme(&self) -> Theme { |
| 522 | self.current_theme.read().clone() |
| 523 | } |
| 524 | |
| 525 | /// Set the current theme by name |
| 526 | pub fn set_theme(&self, theme_name: &str) -> Result<(), &'static str> { |
| 527 | let themes = self.themes.read(); |
| 528 | let new_theme = themes.get(theme_name).ok_or("Theme not found")?.clone(); |
| 529 | |
| 530 | let old_theme_name = self.current_theme.read().name.clone(); |
| 531 | let mode_changed = self.current_theme.read().mode != new_theme.mode; |
| 532 | |
| 533 | *self.current_theme.write() = new_theme; |
| 534 | |
| 535 | // Notify listeners |
| 536 | let event = ThemeChangeEvent { |
| 537 | old_theme: old_theme_name, |
| 538 | new_theme: theme_name.to_string(), |
| 539 | mode_changed, |
| 540 | }; |
| 541 | |
| 542 | self.notify_listeners(&event); |
| 543 | Ok(()) |
| 544 | } |
| 545 | |
| 546 | /// Switch between light and dark themes |
| 547 | pub fn toggle_theme_mode(&self) { |
| 548 | let current_mode = self.current_theme.read().mode; |
| 549 | let new_mode = match current_mode { |
| 550 | ThemeMode::Light => ThemeMode::Dark, |
| 551 | ThemeMode::Dark => ThemeMode::Light, |
| 552 | ThemeMode::System => { |
| 553 | // Toggle based on current system theme |
| 554 | let system_mode = *self.system_theme_mode.read(); |
| 555 | match system_mode { |
| 556 | ThemeMode::Light => ThemeMode::Dark, |
| 557 | _ => ThemeMode::Light, |
| 558 | } |
| 559 | } |
| 560 | }; |
| 561 | |
| 562 | let theme_name = match new_mode { |
| 563 | ThemeMode::Light => "Light", |
| 564 | ThemeMode::Dark => "Dark", |
| 565 | ThemeMode::System => "Light", // Fallback |
| 566 | }; |
| 567 | |
| 568 | let _ = self.set_theme(theme_name); |
| 569 | } |
| 570 | |
| 571 | /// Add a custom theme |
| 572 | pub fn add_theme(&self, theme: Theme) { |
| 573 | let theme_name = theme.name.clone(); |
| 574 | self.themes.write().insert(theme_name, theme); |
| 575 | } |
| 576 | |
| 577 | /// Remove a theme |
| 578 | pub fn remove_theme(&self, theme_name: &str) -> Result<(), &'static str> { |
| 579 | if theme_name == "Light" || theme_name == "Dark" { |
| 580 | return Err("Cannot remove built-in themes"); |
| 581 | } |
| 582 | |
| 583 | let mut themes = self.themes.write(); |
| 584 | themes.remove(theme_name).ok_or("Theme not found")?; |
| 585 | |
| 586 | Ok(()) |
| 587 | } |
| 588 | |
| 589 | /// Get all available theme names |
| 590 | pub fn get_theme_names(&self) -> Vec<String> { |
| 591 | self.themes.read().keys().cloned().collect() |
| 592 | } |
| 593 | |
| 594 | /// Add a theme change listener |
| 595 | pub fn add_listener(&self, listener: Arc<dyn ThemeChangeListener>) { |
| 596 | self.listeners.write().push(listener); |
| 597 | } |
| 598 | |
| 599 | /// Remove all listeners |
| 600 | pub fn clear_listeners(&self) { |
| 601 | self.listeners.write().clear(); |
| 602 | } |
| 603 | |
| 604 | /// Update system theme mode (for system theme detection) |
| 605 | pub fn update_system_theme_mode(&self, mode: ThemeMode) { |
| 606 | *self.system_theme_mode.write() = mode; |
| 607 | |
| 608 | // If current theme is system, update accordingly |
| 609 | if self.current_theme.read().mode == ThemeMode::System { |
| 610 | let theme_name = match mode { |
| 611 | ThemeMode::Light => "Light", |
| 612 | ThemeMode::Dark => "Dark", |
| 613 | ThemeMode::System => "Light", // Fallback |
| 614 | }; |
| 615 | let _ = self.set_theme(theme_name); |
| 616 | } |
| 617 | } |
| 618 | |
| 619 | /// Notify all listeners of theme change |
| 620 | fn notify_listeners(&self, event: &ThemeChangeEvent) { |
| 621 | let listeners = self.listeners.read(); |
| 622 | for listener in listeners.iter() { |
| 623 | listener.on_theme_changed(event); |
| 624 | } |
| 625 | } |
| 626 | |
| 627 | /// Export theme to JSON |
| 628 | pub fn export_theme(&self, theme_name: &str) -> Result<String, Box<dyn std::error::Error>> { |
| 629 | let themes = self.themes.read(); |
| 630 | let theme = themes.get(theme_name).ok_or("Theme not found")?; |
| 631 | |
| 632 | Ok(serde_json::to_string_pretty(theme)?) |
| 633 | } |
| 634 | |
| 635 | /// Import theme from JSON |
| 636 | pub fn import_theme(&self, json: &str) -> Result<(), Box<dyn std::error::Error>> { |
| 637 | let theme: Theme = serde_json::from_str(json)?; |
| 638 | self.add_theme(theme); |
| 639 | Ok(()) |
| 640 | } |
| 641 | } |
| 642 | |
| 643 | impl Default for ThemeManager { |
| 644 | fn default() -> Self { |
| 645 | Self::new() |
| 646 | } |
| 647 | } |
| 648 | |
| 649 | /// Utility functions for theme operations |
| 650 | pub mod utils { |
| 651 | use super::*; |
| 652 | |
| 653 | /// Detect system theme preference (placeholder - would need platform-specific implementation) |
| 654 | pub fn detect_system_theme() -> ThemeMode { |
| 655 | // This would need platform-specific implementation |
| 656 | // For now, return Light as default |
| 657 | ThemeMode::Light |
| 658 | } |
| 659 | |
| 660 | /// Generate a color palette from a primary color |
| 661 | pub fn generate_palette_from_primary(primary: Color) -> ColorPalette { |
| 662 | let mut palette = ColorPalette::light(); |
| 663 | palette.primary = primary; |
| 664 | palette.primary_variant = primary.darken(0.1); |
| 665 | palette |
| 666 | } |
| 667 | |
| 668 | /// Calculate contrast ratio between two colors |
| 669 | pub fn contrast_ratio(color1: &Color, color2: &Color) -> f32 { |
| 670 | let l1 = relative_luminance(color1); |
| 671 | let l2 = relative_luminance(color2); |
| 672 | |
| 673 | let lighter = l1.max(l2); |
| 674 | let darker = l1.min(l2); |
| 675 | |
| 676 | (lighter + 0.05) / (darker + 0.05) |
| 677 | } |
| 678 | |
| 679 | /// Calculate relative luminance of a color |
| 680 | fn relative_luminance(color: &Color) -> f32 { |
| 681 | let r = if color.r <= 0.03928 { |
| 682 | color.r / 12.92 |
| 683 | } else { |
| 684 | ((color.r + 0.055) / 1.055).powf(2.4) |
| 685 | }; |
| 686 | let g = if color.g <= 0.03928 { |
| 687 | color.g / 12.92 |
| 688 | } else { |
| 689 | ((color.g + 0.055) / 1.055).powf(2.4) |
| 690 | }; |
| 691 | let b = if color.b <= 0.03928 { |
| 692 | color.b / 12.92 |
| 693 | } else { |
| 694 | ((color.b + 0.055) / 1.055).powf(2.4) |
| 695 | }; |
| 696 | |
| 697 | 0.2126 * r + 0.7152 * g + 0.0722 * b |
| 698 | } |
| 699 | |
| 700 | /// Check if a color combination meets WCAG accessibility standards |
| 701 | pub fn meets_wcag_aa(foreground: &Color, background: &Color) -> bool { |
| 702 | contrast_ratio(foreground, background) >= 4.5 |
| 703 | } |
| 704 | |
| 705 | /// Check if a color combination meets WCAG AAA accessibility standards |
| 706 | pub fn meets_wcag_aaa(foreground: &Color, background: &Color) -> bool { |
| 707 | contrast_ratio(foreground, background) >= 7.0 |
| 708 | } |
| 709 | } |
| 710 | |
| 711 | #[cfg(test)] |
| 712 | mod tests { |
| 713 | use super::*; |
| 714 | |
| 715 | #[test] |
| 716 | fn test_color_creation() { |
| 717 | let color = Color::rgb(1.0, 0.5, 0.0); |
| 718 | assert_eq!(color.r, 1.0); |
| 719 | assert_eq!(color.g, 0.5); |
| 720 | assert_eq!(color.b, 0.0); |
| 721 | assert_eq!(color.a, 1.0); |
| 722 | } |
| 723 | |
| 724 | #[test] |
| 725 | fn test_color_from_hex() { |
| 726 | let color = Color::from_hex("#FF8000").unwrap(); |
| 727 | assert!((color.r - 1.0).abs() < 0.01); |
| 728 | assert!((color.g - 0.5).abs() < 0.01); |
| 729 | assert!((color.b - 0.0).abs() < 0.01); |
| 730 | } |
| 731 | |
| 732 | #[test] |
| 733 | fn test_color_to_hex() { |
| 734 | let color = Color::rgb(1.0, 0.5, 0.0); |
| 735 | assert_eq!(color.to_hex(), "#FF8000"); |
| 736 | } |
| 737 | |
| 738 | #[test] |
| 739 | fn test_color_lighten() { |
| 740 | let color = Color::rgb(0.5, 0.5, 0.5); |
| 741 | let lighter = color.lighten(0.2); |
| 742 | assert!(lighter.r > color.r); |
| 743 | assert!(lighter.g > color.g); |
| 744 | assert!(lighter.b > color.b); |
| 745 | } |
| 746 | |
| 747 | #[test] |
| 748 | fn test_color_darken() { |
| 749 | let color = Color::rgb(0.5, 0.5, 0.5); |
| 750 | let darker = color.darken(0.2); |
| 751 | assert!(darker.r < color.r); |
| 752 | assert!(darker.g < color.g); |
| 753 | assert!(darker.b < color.b); |
| 754 | } |
| 755 | |
| 756 | #[test] |
| 757 | fn test_theme_creation() { |
| 758 | let theme = Theme::light(); |
| 759 | assert_eq!(theme.name, "Light"); |
| 760 | assert_eq!(theme.mode, ThemeMode::Light); |
| 761 | } |
| 762 | |
| 763 | #[test] |
| 764 | fn test_theme_manager() { |
| 765 | let manager = ThemeManager::new(); |
| 766 | assert_eq!(manager.current_theme().name, "Light"); |
| 767 | |
| 768 | manager.set_theme("Dark").unwrap(); |
| 769 | assert_eq!(manager.current_theme().name, "Dark"); |
| 770 | } |
| 771 | |
| 772 | #[test] |
| 773 | fn test_contrast_ratio() { |
| 774 | let white = Color::WHITE; |
| 775 | let black = Color::BLACK; |
| 776 | let ratio = utils::contrast_ratio(&white, &black); |
| 777 | assert!(ratio > 20.0); // Should be 21:1 for perfect white/black |
| 778 | } |
| 779 | |
| 780 | #[test] |
| 781 | fn test_wcag_compliance() { |
| 782 | let white = Color::WHITE; |
| 783 | let black = Color::BLACK; |
| 784 | assert!(utils::meets_wcag_aa(&white, &black)); |
| 785 | assert!(utils::meets_wcag_aaa(&white, &black)); |
| 786 | } |
| 787 | } |
| 788 |