/// Theme system for Lightningbeam Editor /// /// Parses CSS rules from assets/styles.css at runtime /// and provides type-safe access to styles via selectors. use eframe::egui; use lightningcss::stylesheet::{ParserOptions, PrinterOptions, StyleSheet}; use lightningcss::traits::ToCss; use std::collections::HashMap; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ThemeMode { Light, Dark, System, // Follow system preference } impl ThemeMode { /// Convert from string ("light", "dark", or "system") pub fn from_string(s: &str) -> Self { match s.to_lowercase().as_str() { "light" => Self::Light, "dark" => Self::Dark, _ => Self::System, } } /// Convert to lowercase string pub fn to_string_lower(&self) -> String { match self { Self::Light => "light".to_string(), Self::Dark => "dark".to_string(), Self::System => "system".to_string(), } } } /// Style properties that can be applied to UI elements #[derive(Debug, Clone, Default)] pub struct Style { pub background_color: Option, pub border_color: Option, pub text_color: Option, pub width: Option, pub height: Option, // Add more properties as needed } #[derive(Debug, Clone)] pub struct Theme { light_variables: HashMap, dark_variables: HashMap, light_styles: HashMap, dark_styles: HashMap, current_mode: ThemeMode, } impl Theme { /// Load theme from CSS file pub fn from_css(css: &str) -> Result { let stylesheet = StyleSheet::parse( css, ParserOptions::default(), ).map_err(|e| format!("Failed to parse CSS: {:?}", e))?; let mut light_variables = HashMap::new(); let mut dark_variables = HashMap::new(); let mut light_styles = HashMap::new(); let mut dark_styles = HashMap::new(); // First pass: Extract CSS custom properties from :root for rule in &stylesheet.rules.0 { match rule { lightningcss::rules::CssRule::Style(style_rule) => { let selectors = style_rule.selectors.0.iter() .filter_map(|s| s.to_css_string(PrinterOptions::default()).ok()) .collect::>(); // Check if this is :root if selectors.iter().any(|s| s.contains(":root")) { extract_css_variables(&style_rule.declarations, &mut light_variables)?; } } lightningcss::rules::CssRule::Media(media_rule) => { let media_str = media_rule.query.to_css_string(PrinterOptions::default()) .unwrap_or_default(); if media_str.contains("prefers-color-scheme") && media_str.contains("dark") { for inner_rule in &media_rule.rules.0 { if let lightningcss::rules::CssRule::Style(style_rule) = inner_rule { let selectors = style_rule.selectors.0.iter() .filter_map(|s| s.to_css_string(PrinterOptions::default()).ok()) .collect::>(); if selectors.iter().any(|s| s.contains(":root")) { extract_css_variables(&style_rule.declarations, &mut dark_variables)?; } } } } } _ => {} } } // Second pass: Parse style rules and resolve var() references // We need to parse selectors TWICE - once with light variables, once with dark variables for rule in &stylesheet.rules.0 { match rule { lightningcss::rules::CssRule::Style(style_rule) => { let selectors = style_rule.selectors.0.iter() .filter_map(|s| s.to_css_string(PrinterOptions::default()).ok()) .collect::>(); for selector in selectors { let selector = selector.trim(); // Only process class and ID selectors if selector.starts_with('.') || selector.starts_with('#') { // Parse with light variables let light_style = parse_style_properties(&style_rule.declarations, &light_variables)?; light_styles.insert(selector.to_string(), light_style); // Also parse with dark variables (merge dark over light) let mut dark_vars = light_variables.clone(); dark_vars.extend(dark_variables.clone()); let dark_style = parse_style_properties(&style_rule.declarations, &dark_vars)?; dark_styles.insert(selector.to_string(), dark_style); } } } lightningcss::rules::CssRule::Media(media_rule) => { let media_str = media_rule.query.to_css_string(PrinterOptions::default()) .unwrap_or_default(); eprintln!("🔍 Found media query: {}", media_str); eprintln!(" Contains {} rules", media_rule.rules.0.len()); if media_str.contains("prefers-color-scheme") && media_str.contains("dark") { eprintln!(" ✓ This is a dark mode media query!"); for (i, inner_rule) in media_rule.rules.0.iter().enumerate() { eprintln!(" Rule {}: {:?}", i, std::mem::discriminant(inner_rule)); if let lightningcss::rules::CssRule::Style(style_rule) = inner_rule { let selectors = style_rule.selectors.0.iter() .filter_map(|s| s.to_css_string(PrinterOptions::default()).ok()) .collect::>(); eprintln!(" Found selectors: {:?}", selectors); for selector in selectors { let selector = selector.trim(); if selector.starts_with('.') || selector.starts_with('#') { // Merge dark and light variables (dark overrides light) let mut vars = light_variables.clone(); vars.extend(dark_variables.clone()); let style = parse_style_properties(&style_rule.declarations, &vars)?; dark_styles.insert(selector.to_string(), style); eprintln!(" Added dark style for: {}", selector); } } } } } } _ => {} } } Ok(Self { light_variables, dark_variables, light_styles, dark_styles, current_mode: ThemeMode::System, }) } /// Load theme from embedded CSS file pub fn load_default() -> Result { let css = include_str!("../assets/styles.css"); Self::from_css(css) } /// Set the current theme mode pub fn set_mode(&mut self, mode: ThemeMode) { self.current_mode = mode; } /// Get the current theme mode pub fn mode(&self) -> ThemeMode { self.current_mode } /// Get style for a selector (e.g., ".panel" or "#timeline-header") pub fn style(&self, selector: &str, ctx: &egui::Context) -> Style { let is_dark = match self.current_mode { ThemeMode::Light => false, ThemeMode::Dark => true, ThemeMode::System => ctx.style().visuals.dark_mode, }; if is_dark { // Try dark style first, fall back to light style self.dark_styles.get(selector).cloned() .or_else(|| self.light_styles.get(selector).cloned()) .unwrap_or_default() } else { self.light_styles.get(selector).cloned().unwrap_or_default() } } /// Get the number of loaded selectors pub fn len(&self) -> usize { self.light_styles.len() } /// Check if theme has no styles #[allow(dead_code)] // Used in tests pub fn is_empty(&self) -> bool { self.light_styles.is_empty() } /// Debug: print loaded theme info pub fn debug_print(&self) { println!("📊 Theme Debug Info:"); println!(" Light variables: {}", self.light_variables.len()); for (k, v) in self.light_variables.iter().take(5) { println!(" --{}: {}", k, v); } println!(" Dark variables: {}", self.dark_variables.len()); for (k, v) in self.dark_variables.iter().take(5) { println!(" --{}: {}", k, v); } println!(" Light styles: {}", self.light_styles.len()); for k in self.light_styles.keys().take(5) { println!(" {}", k); } println!(" Dark styles: {}", self.dark_styles.len()); for k in self.dark_styles.keys().take(5) { println!(" {}", k); } } } /// Extract CSS custom properties (--variables) from declarations fn extract_css_variables( declarations: &lightningcss::declaration::DeclarationBlock, variables: &mut HashMap, ) -> Result<(), String> { for property in &declarations.declarations { if let lightningcss::properties::Property::Custom(_) = property { let property_css = property.to_css_string(false, PrinterOptions::default()) .map_err(|e| format!("Failed to serialize property: {:?}", e))?; if let Some((name, value)) = property_css.split_once(':') { let name = name.trim().strip_prefix("--").unwrap_or(name.trim()).to_string(); let value = value.trim().to_string(); variables.insert(name, value); } } } Ok(()) } /// Parse style properties from CSS declarations into a Style struct, resolving var() references fn parse_style_properties( declarations: &lightningcss::declaration::DeclarationBlock, variables: &HashMap, ) -> Result { let mut style = Style::default(); for property in &declarations.declarations { // Convert property to CSS string and parse let prop_str = property.to_css_string(false, PrinterOptions::default()) .map_err(|e| format!("Failed to serialize property: {:?}", e))?; // Parse property name and value if let Some((name, value)) = prop_str.split_once(':') { let name = name.trim(); let value = value.trim().trim_end_matches(';'); match name { "background-color" => { style.background_color = parse_color_value(value, variables); } "border-color" | "border-top-color" => { style.border_color = parse_color_value(value, variables); } "color" => { style.text_color = parse_color_value(value, variables); } "width" => { style.width = parse_dimension_value(value, variables); } "height" => { style.height = parse_dimension_value(value, variables); } _ => {} } } } Ok(style) } /// Parse a CSS color value (hex or var()) fn parse_color_value(value: &str, variables: &HashMap) -> Option { let value = value.trim(); // Check if it's a var() reference if let Some(var_name) = parse_var_reference(value) { let resolved = variables.get(&var_name)?; return parse_hex_color(resolved); } // Try to parse as direct hex color parse_hex_color(value) } /// Parse a CSS dimension value (px or var()) fn parse_dimension_value(value: &str, variables: &HashMap) -> Option { let value = value.trim(); // Check if it's a var() reference if let Some(var_name) = parse_var_reference(value) { let resolved = variables.get(&var_name)?; return parse_dimension_string(resolved); } // Try to parse as direct dimension parse_dimension_string(value) } /// Parse a var() reference to get the variable name fn parse_var_reference(value: &str) -> Option { let trimmed = value.trim(); if trimmed.starts_with("var(") && trimmed.ends_with(')') { let inner = trimmed.strip_prefix("var(")?.strip_suffix(')')?; let var_name = inner.trim().strip_prefix("--")?; Some(var_name.to_string()) } else { None } } /// Parse hex color string to egui::Color32 fn parse_hex_color(value: &str) -> Option { let value = value.trim(); if !value.starts_with('#') { return None; } let hex = value.trim_start_matches('#'); match hex.len() { 3 => { let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?; let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?; let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?; Some(egui::Color32::from_rgb(r, g, b)) } 6 => { let r = u8::from_str_radix(&hex[0..2], 16).ok()?; let g = u8::from_str_radix(&hex[2..4], 16).ok()?; let b = u8::from_str_radix(&hex[4..6], 16).ok()?; Some(egui::Color32::from_rgb(r, g, b)) } 8 => { let r = u8::from_str_radix(&hex[0..2], 16).ok()?; let g = u8::from_str_radix(&hex[2..4], 16).ok()?; let b = u8::from_str_radix(&hex[4..6], 16).ok()?; let a = u8::from_str_radix(&hex[6..8], 16).ok()?; Some(egui::Color32::from_rgba_unmultiplied(r, g, b, a)) } _ => None, } } /// Parse dimension string (e.g., "50px" or "25") fn parse_dimension_string(value: &str) -> Option { let value = value.trim(); if let Some(stripped) = value.strip_suffix("px") { stripped.trim().parse::().ok() } else { value.parse::().ok() } } #[cfg(test)] mod tests { use super::*; #[test] fn test_load_default_theme() { let theme = Theme::load_default().expect("Failed to load default theme"); assert!(!theme.is_empty(), "Theme should have styles loaded"); } }